diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..8d43bbb
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+end_of_line = lf
+# editorconfig-tools is unable to ignore longs strings or urls
+max_line_length = off
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..be30c8c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,85 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Typescript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# dotenv environment variables file
+.env
+.env.mcm
+
+# gatsby files
+.cache/
+
+# Mac files
+.DS_Store
+
+# Yarn
+yarn-error.log
+.pnp/
+.pnp.js
+# Yarn Integrity file
+.yarn-integrity
+/.idea/
+
+# MCM
+.secrets
+.scannerwork
+.env.bat
+
+### IDE ###
+.vscode/
+.terminals.json
+.launch.json
+.package-lock.json
+
+# test and debug file
+api-manifest.yml
+idp-manifest.yml
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..8acd150
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,63 @@
+#### WORKFLOW ####
+workflow:
+ rules:
+ - if: $CI_COMMIT_TAG
+ when: never
+ - if: $CI_PIPELINE_SOURCE == 'merge_request_event' && '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH'
+ when: never
+ - when: always
+
+#### STAGES ####
+stages:
+ # Prepare context for build stages: fetch thirdparty source code, compile build tools, etc.
+ - prepare
+ # Compile, generally turn source code into derived objects
+ - pre-image
+ # Build source code
+ - build
+ # Build, tag and push container images
+ - image
+ # # First level of testing e.g. unit tests
+ - test
+ # Prepare additional deployment descriptors e.g. K8s manifests, Helm charts, etc.
+ #- bundle
+ - verify
+ - verify-all
+ # Deploy to development environment, from feature branch, time-limited (24 hrs)
+ - deploy-preview
+ # Deploy to testing environment, from feature branch, time-limited (24 hrs)
+ - deploy-testing
+ - smoke-test
+ - integration-test
+ - functional-test
+ # Stage to clean data, sast, rollback version or play api documentation
+ - utils
+ # Remove not wanted files or directory for publication
+ - publication
+ # Push build images from RC
+ - helm-push-image
+ # Push gitlab source to cloud
+ - helm-gitlab
+ # Package the helm
+ - helm-package
+ # Publish to public repo
+ - cleanup
+
+#### INCLUDES ####
+include:
+ - local: "commons/.gitlab-ci.yml"
+ - local: "api/.gitlab-ci.yml"
+ - local: "idp/.gitlab-ci.yml"
+ - local: "administration/.gitlab-ci.yml"
+ - local: "s3/.gitlab-ci.yml"
+ - local: "vault/.gitlab-ci.yml"
+ - local: "analytics/.gitlab-ci.yml"
+ - local: "test/.gitlab-ci.yml"
+ - local: "website/.gitlab-ci.yml"
+ - local: "simulation-maas/.gitlab-ci.yml"
+ - local: "antivirus/.gitlab-ci.yml"
+ - local: "mailhog/.gitlab-ci.yml"
+ - local: "bus/.gitlab-ci.yml"
+ - local: "publication/.gitlab-ci.yml"
+ # rules:
+ # - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49fdee2..fed1747 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,26 +6,28 @@ Le versionning des releases suit le [semantic versioning](http://semver.org).
### 1.10.0
- [X] Version production octobre 2022
- #### Identification / Authentification
+ #### Identification / Authentification du Citoyen
- [X] Création de compte Citoyen
- [X] Authentification sur le site
- [X] Gestion du consentement (partage des données, demande de portabilité)
-- [X] Affiliations salariés / employeurs financeurs
-- [X] Gestion des communautés financeur
-- [X] Création de compte Financeur (gestionnaire / superviseur)
-- [X] ...
+- [X] Association du compte MaaS au compte moB
+- [X] Affiliation salarié à une entreprise financeur
#### Dispositifs d'incitation (i.e. Aides)
-- [X] Creation / Edition des aides
-- [X] Visualiser les aides disponibles (nationales / territoriales / employeur)
-- [X] ...
+- [X] Creation / Edition des aides du catalogue, qu'elles soient souscriptibles en externe ou directement dans moB
+- [X] Visualisation des aides disponibles (nationales / territoriales / employeur)
#### Souscriptions
-- [X] Réceptionner les données de souscription à une aide du citoyen
+- [X] Souscription à une aide à la mobilité en fournissant les justificatifs demandés ou communiqués
+- [X] Réception des données de souscription à une aide du citoyen
- [X] Traitement de pièces justificatives attachés à une souscription
- [X] Reconstitution de justificatifs à partir de données de facture de fournisseur de service de mobilités
-- [X] ...
- #### Reportings
-- [X] Tableau de bord financeur sur le nombre de souscriptions
-- [X] Tableau de bord citoyen
+- [X] Notification des différents états de traitement de la souscription par e-mail et sur le tableau de bord citoyen
+ #### Back-office Financeur
+- [X] Création de compte Financeur (gestionnaire / superviseur)
+- [X] Validation des demandes d'affiliations des citoyens salariés
+- [X] Consultation et traitement par le gestionnaire financeur des souscriptions
+- [X] Gestion des communautés
+- [X] Tableau de bord financeur sur le nombre de souscriptions validées et par aide, et d'autres indicateurs de suivi de pilotage de la politique de mobilité
+- [X] Intégration du Système d'Information Ressources Humaines pour permettre le traitement des souscriptions dans l'outil existant du financeur
#### Autres fonctionnalités
- [X] Publication Opensource
- [X] Audit du code et de l'infrastructure
diff --git a/README.md b/README.md
index c2e3402..066901b 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,14 @@
> **Note** : La publication du code sera échelonnée en plusieurs parties et accompagné de documents permettant à l’usager d’appréhender facilement les éléments partagés. Basé un mode de fonctionnement agile, les éléments constitutifs du projet sont susceptibles d’évoluer au fil de l’eau, en amont de tout déploiement éventuel, du fait : des retours des tests, de la prise en compte de l'ensemble des exigences en matière d'accessibilité, de la précision des éléments légaux, de tout autre élément susceptible de conduire à des modifications.
# Rappel du contexte
-Le programme Mon Compte Mobilité, porté par Capgemini Invent et la Fabrique des Mobilité, est une plateforme de services neutre qui facilite les relations entre citoyens, employeurs, collectivités et opérateurs de mobilité autour d’un compte personnel de mobilité et d'une passerelle (i.e. Gateway) d'échanges de services standardisés à destination des MaaS. Son ambition est d’accélérer les mutations des mobilités pour réduire massivement l’autosolisme et encourager l’utilisation des mobilités douces.
+Le programme Mon Compte Mobilité, porté par Capgemini Invent et la Fabrique des Mobilité, est une plateforme de services neutre qui facilite les relations entre citoyens, employeurs, collectivités et opérateurs de mobilité autour d’un compte personnel de mobilité et d'une passerelle (i.e. Gateway) d'échanges de services standardisés à destination des MaaS. Son ambition est d’accélérer les mutations des mobilités pour réduire massivement l’autosolisme et encourager l’utilisation des mobilités douces.
Ce programme répond parfaitement à une des propositions de la convention citoyenne : mettre en place un portail unique permettant de savoir à tout moment, rapidement et simplement, quels sont les moyens et dispositifs existants sur un territoire pour se déplacer.
Le projet Mon Compte Mobilité est lauréat de l’appel à projet pour des programmes de Certificats d’économie d’énergie par l’arrêté du 27 février 2020, et publié au journal officiel le 8 mars 2020.
Mon Compte Mobilité (ou **moB**) est un compte unique pour chaque utilisateur qui permet :
-- A chaque citoyen de visualiser les dispositifs d’incitation nationaux, de sa collectivité ou son employeur pour en bénéficier comme il le souhaite auprès des différentes offres de mobilité et de gérer son consentement à la portabilité de ses données personnelles
-- A chaque entreprise de paramétrer et mettre en œuvre la politique de mobilité qu’elle souhaite pour ses collaborateurs
+- A chaque citoyen de visualiser les dispositifs d’incitation nationaux, de sa collectivité ou son employeur pour en bénéficier comme il le souhaite auprès des différentes offres de mobilité et de gérer son consentement à la portabilité de ses données personnelles
+- A chaque entreprise de paramétrer et mettre en œuvre la politique de mobilité qu’elle souhaite pour ses collaborateurs
- A chaque AOM (Autorité Organisatrice de la Mobilité) de créer et piloter ses politiques d’incitation pour encourager l’utilisation de modes de mobilité plus durables sur son territoire
- A chaque opérateur de mobilité (MSP, Mobility Service Provider) de mettre en visibilité ses offres et de faciliter l’utilisation des incitatifs sur celles-ci, et de contribuer à la politique incitative de mobilité durable des territoires
@@ -49,7 +49,7 @@ Merci de vous référer au fichier dédié : [LICENSE.txt](https://github.com/fa
# Périmètre de la publication
-La présente publication sera complétée le 18 novembre 2022. Seront alors publiées les fonctionnalités de Mon Compte Mobilité ayant pour vocation de
+La présente publication sera complétée le 18 novembre 2022. Seront alors publiées les fonctionnalités de Mon Compte Mobilité ayant pour vocation de
Permettre à l’Utilisateur non-authentifié :
- D’accéder au catalogue d’aides publiques proposées par les Territoires partenaires
diff --git a/administration/.eslintignore b/administration/.eslintignore
new file mode 100644
index 0000000..06d792d
--- /dev/null
+++ b/administration/.eslintignore
@@ -0,0 +1,2 @@
+*.css
+*.svg
diff --git a/administration/.eslintrc.js b/administration/.eslintrc.js
new file mode 100644
index 0000000..e62a310
--- /dev/null
+++ b/administration/.eslintrc.js
@@ -0,0 +1,86 @@
+module.exports = {
+ extends: [
+ 'airbnb-typescript',
+ 'airbnb/hooks',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:jest/recommended',
+ 'prettier',
+ 'plugin:prettier/recommended',
+ ],
+ plugins: ['react', '@typescript-eslint', 'jest', 'react-hooks'],
+ env: {
+ browser: true,
+ es2021: true,
+ },
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ ecmaVersion: 12,
+ sourceType: 'module',
+ project: ['./tsconfig.json'],
+ },
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-non-null-asserted-optional-chain" : "off",
+ "@typescript-eslint/no-non-null-assertion" : "off",
+ 'max-len': 0,
+ 'react/prop-types': 0,
+ 'prettier/prettier': [
+ 'error',
+ { endOfLine: 'auto' },
+ { singleQuote: true },
+ ],
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.tsx'] }],
+ 'react/jsx-one-expression-per-line': 0,
+ '@typescript-eslint/ban-ts-comment': 'off',
+ 'react/jsx-props-no-spreading': 'off',
+ 'no-use-before-define': 'off',
+ '@typescript-eslint/no-use-before-define': ['error'],
+ 'react/jsx-filename-extension': [
+ 'warn',
+ {
+ extensions: ['.tsx'],
+ },
+ ],
+ 'import/extensions': [
+ 'error',
+ 'ignorePackages',
+ {
+ ts: 'never',
+ tsx: 'never',
+ },
+ ],
+ 'import/no-extraneous-dependencies': [
+ 'error',
+ {
+ devDependencies: ['**/*.test.tsx', '*/jest-configs/*'],
+ },
+ ],
+ 'no-shadow': 'off',
+ '@typescript-eslint/no-shadow': ['error'],
+ '@typescript-eslint/explicit-function-return-type': [
+ 'error',
+ {
+ allowExpressions: true,
+ },
+ ],
+ 'max-len': [
+ 'warn',
+ {
+ code: 80,
+ },
+ ],
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ 'import/prefer-default-export': 'off',
+ 'react/prop-types': 'off',
+ },
+ settings: {
+ 'import/resolver': {
+ typescript: {},
+ },
+ },
+};
diff --git a/administration/.gitignore b/administration/.gitignore
new file mode 100644
index 0000000..48c9383
--- /dev/null
+++ b/administration/.gitignore
@@ -0,0 +1,24 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+/public/keycloak.json
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/administration/.gitlab-ci.yml b/administration/.gitlab-ci.yml
new file mode 100644
index 0000000..32a193a
--- /dev/null
+++ b/administration/.gitlab-ci.yml
@@ -0,0 +1,58 @@
+include:
+ - local: 'administration/.gitlab-ci/preview.yml'
+ rules:
+ - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule"
+ - local: 'administration/.gitlab-ci/testing.yml'
+ rules:
+ - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger"
+ - local: 'administration/.gitlab-ci/helm.yml'
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "trigger"
+
+# Initialisation of specifique administration variable
+.admin-base:
+ variables:
+ MODULE_NAME: admin
+ MODULE_PATH: administration
+ ADMIN_BASE_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/nginx:1.21
+ ADMIN_IMAGE_NAME: ${REGISTRY_BASE_NAME}/${MODULE_NAME}:${IMAGE_TAG_NAME}
+ only:
+ changes:
+ - '*'
+ - 'commons/**/*'
+ - 'administration/**/*'
+
+# Build of testing environement image and creation of the cache
+.admin_build_script: &admin_build_script |
+ yarn install
+ export REACT_APP_ADMIN_ACCES_ROLE=${MCM_CMS_ACCESS_ROLE}
+ export REACT_APP_PACKAGE_VERSION=${PACKAGE_VERSION}
+ npm version ${PACKAGE_VERSION}
+ yarn build --production=true
+
+admin_build:
+ extends:
+ - .build-job
+ - .admin-base
+ script:
+ - *admin_build_script
+ cache:
+ key: ${MODULE_PATH}-${CI_COMMIT_REF_SLUG}
+ policy: push
+ paths:
+ - ${MODULE_PATH}/node_modules/
+ - ${MODULE_PATH}/yarn.lock
+ artifacts:
+ paths:
+ - ${MODULE_PATH}/build
+ - ${MODULE_PATH}/node_modules/
+ - ${MODULE_PATH}/yarn.lock
+ expire_in: 5 days
+
+# Static Application Security Testing for known vulnerabilities
+admin_sast:
+ extends:
+ - .sast-job
+ - .build-n-sast-job-tags
+ - .admin-base
+ needs: ['admin_build']
\ No newline at end of file
diff --git a/administration/.gitlab-ci/helm.yml b/administration/.gitlab-ci/helm.yml
new file mode 100644
index 0000000..3188851
--- /dev/null
+++ b/administration/.gitlab-ci/helm.yml
@@ -0,0 +1,4 @@
+admin_image_push:
+ extends:
+ - .helm-push-image-job
+ - .admin-base
diff --git a/administration/.gitlab-ci/preview.yml b/administration/.gitlab-ci/preview.yml
new file mode 100644
index 0000000..117f03a
--- /dev/null
+++ b/administration/.gitlab-ci/preview.yml
@@ -0,0 +1,18 @@
+admin_image_build:
+ extends:
+ - .preview-image-job
+ - .admin-base
+ needs: ['admin_build']
+
+admin_preview_deploy:
+ extends:
+ - .preview-deploy-job
+ - .admin-base
+ needs: ['admin_image_build']
+ environment:
+ on_stop: admin_preview_cleanup
+
+admin_preview_cleanup:
+ extends:
+ - .commons_preview_cleanup
+ - .admin-base
diff --git a/administration/.gitlab-ci/testing.yml b/administration/.gitlab-ci/testing.yml
new file mode 100644
index 0000000..79b7f56
--- /dev/null
+++ b/administration/.gitlab-ci/testing.yml
@@ -0,0 +1,11 @@
+admin_testing_image_build:
+ extends:
+ - .testing-image-job
+ - .admin-base
+ needs: ['admin_build']
+
+admin_testing_deploy:
+ extends:
+ - .testing-deploy-job
+ - .admin-base
+ needs: ['admin_testing_image_build']
diff --git a/administration/.prettierignore b/administration/.prettierignore
new file mode 100644
index 0000000..6012ab2
--- /dev/null
+++ b/administration/.prettierignore
@@ -0,0 +1,12 @@
+node_modules
+
+# Ignore artifacts:
+build
+coverage
+
+.cache
+.gitlab-ci.yml
+.scannerwork
+public/
+gatsby-config.js
+.eslintrc.js
diff --git a/administration/.prettierrc.json b/administration/.prettierrc.json
new file mode 100644
index 0000000..a574763
--- /dev/null
+++ b/administration/.prettierrc.json
@@ -0,0 +1,10 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": true,
+ "singleQuote": true,
+ "bracketSpacing": true,
+ "jsxBracketSameLine": false,
+ "arrowParens": "always",
+ "endOfLine": "auto"
+}
diff --git a/administration/Chart.yaml b/administration/Chart.yaml
new file mode 100644
index 0000000..c3dcb0d
--- /dev/null
+++ b/administration/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: 1.0.0
+description: A Helm chart for Kubernetes
+name: ${MODULE_PATH}
+type: application
+version: 1.0.0
diff --git a/administration/README.md b/administration/README.md
new file mode 100644
index 0000000..e6060f5
--- /dev/null
+++ b/administration/README.md
@@ -0,0 +1,57 @@
+# Description
+
+Le service administration se base sur le framework **[React-Admin](https://marmelab.com/react-admin/)**
+
+⚠ La version utilisée est la v3.19.11 de React-Admin
+
+Ce service permet, pour un administrateur fonctionnel, de gérer et de contribuer sur différents types de contenus (Aides, entreprises, collectivités, communautés ...) proposés et/ou à gérer dans la solution.
+
+# Installation en local
+
+Modifier le fichier json présent dans le dossier public avec les variables mentionnées ci-dessous
+
+```sh
+yarn install && yarn start
+```
+
+## Variables
+
+| Variables | Description | Obligatoire |
+| ----------------------- | ----------------------------------------- | ----------- |
+| IDP_FQDN | Url de l'IDP | Oui |
+| API_FQDN | Url de l'api | Oui |
+| IDP_MCM_REALM | Nom du realm mcm | Oui |
+| IDP_MCM_ADMIN_CLIENT_ID | Client id du client public administration | Oui |
+
+## URL / Port
+
+- URL : localhost
+- Port : 3001
+
+# Précisions pipelines
+
+L'image de l'administration est basée sur nginx.
+
+La variable PACKAGE_VERSION (voir commons) permet de repérer la dernière version publiée
+
+## Preview
+
+Pas de précisions nécéssaires pour ce service
+
+## Testing
+
+Pas de précisions nécéssaires pour ce service
+
+# Relation avec les autres services
+
+Comme présenté dans le schéma global de l'architecture ci-dessous
+
+![technicalArchitecture](../docs/assets/MOB-CME_Archi_technique_detaillee.png)
+
+React Admin est lié à l'api grâce à l'implémentation du dataProvider. Ainsi certains endpoints permettant la contribution sont ouverts à ce portail d'administration.
+
+Il est aussi lié à l'idp pour l'accès au portail soit par un compte dédié, soit par une liaison identity provider avec notre Azure AD.
+
+# Tests Unitaires
+
+Pas de tests unitaires nécéssaires pour ce service
diff --git a/administration/admin-dockerfile.yml b/administration/admin-dockerfile.yml
new file mode 100644
index 0000000..dba436f
--- /dev/null
+++ b/administration/admin-dockerfile.yml
@@ -0,0 +1,5 @@
+ARG ADMIN_BASE_IMAGE_NAME
+FROM ${ADMIN_BASE_IMAGE_NAME}
+
+COPY config-nginx/ /etc/nginx/conf.d
+COPY build/ /usr/share/nginx/html/
diff --git a/administration/administration-testing-values.yaml b/administration/administration-testing-values.yaml
new file mode 100644
index 0000000..e217b16
--- /dev/null
+++ b/administration/administration-testing-values.yaml
@@ -0,0 +1,122 @@
+configMaps:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: admin-keycloak-config
+ data:
+ keycloak.json: 'administration/keycloak.json'
+
+services:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ creationTimestamp: null
+ labels:
+ io.kompose.service: admin
+ name: admin
+ spec:
+ ports:
+ - name: '8081'
+ port: 8081
+ targetPort: 8081
+ selector:
+ io.kompose.service: admin
+ type: ClusterIP
+ status:
+ loadBalancer: {}
+
+deployments:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ labels:
+ io.kompose.service: admin
+ name: admin
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ io.kompose.service: admin
+ strategy: {}
+ template:
+ metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ labels:
+ io.kompose.network/web-nw: 'true'
+ io.kompose.service: admin
+ spec:
+ containers:
+ env:
+ API_FQDN: ${API_FQDN}
+ IDP_FQDN: ${IDP_FQDN}
+ MCM_IDP_CLIENTID_ADMIN: ${IDP_MCM_ADMIN_CLIENT_ID}
+ MCM_IDP_REALM: ${IDP_MCM_REALM}
+ image: ${ADMIN_IMAGE_NAME}
+ name: admin
+ ports:
+ - containerPort: 8081
+ resources: {}
+ volumeMounts:
+ - mountPath: /usr/share/nginx/html/keycloak.json
+ name: keycloak-config
+ subPath: keycloak.json
+ imagePullSecrets:
+ - name: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ restartPolicy: Always
+ securityContext:
+ fsGroup: 1000
+ volumes:
+ - configMap:
+ name: admin-keycloak-config
+ name: keycloak-config
+ status: {}
+
+networkPolicies:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: web-nw
+ spec:
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ com.capgemini.mcm.ingress: 'true'
+ podSelector:
+ matchLabels:
+ io.kompose.network/web-nw: 'true'
+
+ingressRoutes:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: admin
+ spec:
+ entryPoints:
+ - web
+ routes:
+ - kind: Rule
+ match: Host(`${ADMIN_FQDN}`)
+ services:
+ - kind: Service
+ name: admin
+ port: 8081
diff --git a/administration/config-nginx/default.conf b/administration/config-nginx/default.conf
new file mode 100644
index 0000000..0555409
--- /dev/null
+++ b/administration/config-nginx/default.conf
@@ -0,0 +1,44 @@
+server {
+ listen 8081;
+ server_name localhost;
+
+ #charset koi8-r;
+ #access_log /var/log/nginx/host.access.log main;
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ }
+
+ #error_page 404 /404.html;
+
+ # redirect server error pages to the static page /50x.html
+ #
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+
+ # proxy the PHP scripts to Apache listening on 127.0.0.1:80
+ #
+ #location ~ \.php$ {
+ # proxy_pass http://127.0.0.1;
+ #}
+
+ # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
+ #
+ #location ~ \.php$ {
+ # root html;
+ # fastcgi_pass 127.0.0.1:9000;
+ # fastcgi_index index.php;
+ # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
+ # include fastcgi_params;
+ #}
+
+ # deny access to .htaccess files, if Apache's document root
+ # concurs with nginx's one
+ #
+ #location ~ /\.ht {
+ # deny all;
+ #}
+}
diff --git a/administration/kompose.yml b/administration/kompose.yml
new file mode 100644
index 0000000..7d41570
--- /dev/null
+++ b/administration/kompose.yml
@@ -0,0 +1,24 @@
+version: '3'
+
+services:
+ admin:
+ image: ${ADMIN_IMAGE_NAME}
+ build:
+ context: .
+ dockerfile: ./admin-dockerfile.yml
+ args:
+ ADMIN_BASE_IMAGE_NAME: ${ADMIN_BASE_IMAGE_NAME}
+ networks:
+ - web-nw
+ ports:
+ - '8081'
+ environment:
+ - API_FQDN
+ - IDP_FQDN
+ - IDP_MCM_REALM
+ - IDP_MCM_ADMIN_CLIENT_ID
+ labels:
+ - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}'
+ - 'kompose.service.type=clusterip'
+networks:
+ web-nw:
diff --git a/administration/overlays/admin-certificate.yml b/administration/overlays/admin-certificate.yml
new file mode 100644
index 0000000..f788ff5
--- /dev/null
+++ b/administration/overlays/admin-certificate.yml
@@ -0,0 +1,12 @@
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: admin-cert
+spec:
+ dnsNames:
+ - '*.${landscape_subdomain}'
+ issuerRef:
+ group: cert-manager.io
+ kind: ClusterIssuer
+ name: ${CLUSTER_ISSUER}
+ secretName: ${SECRET_NAME}
diff --git a/administration/overlays/admin-deployment-namespace.yml b/administration/overlays/admin-deployment-namespace.yml
new file mode 100644
index 0000000..777cdd2
--- /dev/null
+++ b/administration/overlays/admin-deployment-namespace.yml
@@ -0,0 +1,4 @@
+apiVersion: apps/v1
+kind: Namespace
+metadata:
+ name: admin-test-override-namespace
diff --git a/administration/overlays/admin-ingressroute.yml b/administration/overlays/admin-ingressroute.yml
new file mode 100644
index 0000000..780f702
--- /dev/null
+++ b/administration/overlays/admin-ingressroute.yml
@@ -0,0 +1,22 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: IngressRoute
+metadata:
+ name: admin
+spec:
+ entryPoints:
+ - web
+ # - websecure
+ routes:
+ - match: Host(`${ADMIN_FQDN}`)
+ kind: Rule
+ services:
+ - kind: Service
+ name: admin
+ port: 8081
+ # tls:
+ # secretName: ${SECRET_NAME} # admin-tls # cert-dev
+ # domains:
+ # - main: ${BASE_DOMAIN}
+ # sans:
+ # - '*.preview.${BASE_DOMAIN}'
+ # - '*.testing.${BASE_DOMAIN}'
diff --git a/administration/overlays/admin_configmap_volumes.yml b/administration/overlays/admin_configmap_volumes.yml
new file mode 100644
index 0000000..6760208
--- /dev/null
+++ b/administration/overlays/admin_configmap_volumes.yml
@@ -0,0 +1,19 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: admin
+spec:
+ template:
+ spec:
+ containers:
+ - name: admin
+ volumeMounts:
+ - name: keycloak-config
+ mountPath: /usr/share/nginx/html/keycloak.json
+ subPath: keycloak.json
+ securityContext:
+ fsGroup: 1000
+ volumes:
+ - name: keycloak-config
+ configMap:
+ name: admin-keycloak-config
diff --git a/administration/overlays/config/keycloak.json b/administration/overlays/config/keycloak.json
new file mode 100644
index 0000000..5661125
--- /dev/null
+++ b/administration/overlays/config/keycloak.json
@@ -0,0 +1,8 @@
+{
+ "keycloakConfig": {
+ "url": "https://${IDP_FQDN}/auth",
+ "realm": "${IDP_MCM_REALM}",
+ "clientId": "${IDP_MCM_ADMIN_CLIENT_ID}"
+ },
+ "apiConfig": "https://${API_FQDN}/"
+}
diff --git a/administration/overlays/kustomization.yaml b/administration/overlays/kustomization.yaml
new file mode 100644
index 0000000..cba6e9b
--- /dev/null
+++ b/administration/overlays/kustomization.yaml
@@ -0,0 +1,17 @@
+commonAnnotations:
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ kubernetes.io/ingress.class: traefik
+
+resources:
+ - admin-ingressroute.yml
+ # - admin-certificate.yml
+
+patchesStrategicMerge:
+ - web_nw_networkpolicy_namespaceselector.yml
+ - admin_configmap_volumes.yml
+
+configMapGenerator:
+ - name: admin-keycloak-config
+ files:
+ - config/keycloak.json
diff --git a/administration/overlays/web_nw_networkpolicy_namespaceselector.yml b/administration/overlays/web_nw_networkpolicy_namespaceselector.yml
new file mode 100644
index 0000000..32da467
--- /dev/null
+++ b/administration/overlays/web_nw_networkpolicy_namespaceselector.yml
@@ -0,0 +1,10 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: web-nw
+spec:
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ com.capgemini.mcm.ingress: 'true'
diff --git a/administration/package.json b/administration/package.json
new file mode 100644
index 0000000..090cb5b
--- /dev/null
+++ b/administration/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "administration",
+ "version": "1.10.1",
+ "author": "Mon Compte Mobilité",
+ "private": true,
+ "dependencies": {
+ "@material-ui/core": "4.12.4",
+ "@material-ui/icons": "4.11.3",
+ "@testing-library/jest-dom": "5.16.5",
+ "@testing-library/react": "11.2.7",
+ "@testing-library/user-event": "12.8.3",
+ "@types/jest": "26.0.24",
+ "@types/node": "12.20.55",
+ "@types/react": "17.0.2",
+ "@types/react-dom": "17.0.2",
+ "axios": "0.21.4",
+ "keycloak-js": "16.1.1",
+ "lodash": "4.17.21",
+ "ra-i18n-polyglot": "3.19.11",
+ "ra-language-french": "3.19.11",
+ "react": "17.0.2",
+ "react-admin": "3.19.11",
+ "react-admin-lb4": "1.0.3",
+ "react-dom": "17.0.2",
+ "react-final-form": "6.5.9",
+ "react-query": "3.39.2",
+ "typescript": "4.1.2"
+ },
+ "resolutions": {
+ "@types/react": "17.0.2",
+ "@types/react-dom": "17.0.2"
+ },
+ "scripts": {
+ "start": "set PORT=3001 && react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject",
+ "format": "npm run prettier:fix && npm run lint:fix",
+ "lint": "eslint src/* --ext js,ts,tsx",
+ "lint:fix": "npm run lint -- --fix",
+ "prettier": "prettier -c",
+ "prettier:fix": "npm run prettier -- --write"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "4.33.0",
+ "@typescript-eslint/parser": "4.33.0",
+ "eslint": "7.32.0",
+ "eslint-config-airbnb": "18.2.1",
+ "eslint-config-airbnb-typescript": "12.3.1",
+ "eslint-config-prettier": "8.5.0",
+ "eslint-import-resolver-alias": "1.1.2",
+ "eslint-import-resolver-typescript": "2.7.1",
+ "eslint-plugin-import": "2.26.0",
+ "eslint-plugin-jsx-a11y": "6.6.1",
+ "eslint-plugin-prettier": "3.4.1",
+ "eslint-plugin-react": "7.31.10",
+ "eslint-plugin-react-hooks": "4.6.0",
+ "prettier": "2.7.1",
+ "react-scripts": "5.0.0"
+ }
+}
diff --git a/administration/public/index.html b/administration/public/index.html
new file mode 100644
index 0000000..cc49e05
--- /dev/null
+++ b/administration/public/index.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+ Administration | Mon Compte Mobilité
+
+
+
+
+
+
+
diff --git a/administration/public/mob-favicon.svg b/administration/public/mob-favicon.svg
new file mode 100644
index 0000000..ce42712
--- /dev/null
+++ b/administration/public/mob-favicon.svg
@@ -0,0 +1 @@
+
diff --git a/administration/public/robots.txt b/administration/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/administration/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/administration/src/App.tsx b/administration/src/App.tsx
new file mode 100644
index 0000000..b243cf8
--- /dev/null
+++ b/administration/src/App.tsx
@@ -0,0 +1,80 @@
+/* eslint-disable */
+import { Admin, Resource, resolveBrowserLocale } from 'react-admin';
+import polyglotI18nProvider from 'ra-i18n-polyglot';
+import frenchMessages from 'ra-language-french';
+import { QueryClient, QueryClientProvider } from 'react-query';
+
+import dataProvider from './api/provider/dataProvider';
+import AuthProvider from './modules/Auth/authProvider';
+import { KeycloakProviderInit } from './components/Keycloak/KeycloakProviderInit';
+import LogoutButton from './components/LoginForm/LogoutButton';
+import Dashboard from './components/Dashboard/Dashboard';
+import AccessRole from './components/Access/AccessRole';
+import EntrepriseForm from './components/Entreprises';
+import CollectiviteForm from './components/Collectivites/CollectiviteForm';
+import CollectiviteList from './components/Collectivites/CollectiviteList';
+import CommunateForm from './components/Communautes';
+import Aide from './components/Aide';
+import UtilisateurForm from './components/utilisateurs';
+import customTheme from './components/customTheme/customTheme';
+import TerritoryForm from './components/Territories';
+
+const queryClient = new QueryClient();
+
+const messages = {
+ fr: frenchMessages,
+};
+
+const i18nProvider = polyglotI18nProvider(
+ (locale) => (messages[locale] ? messages[locale] : messages.fr),
+ resolveBrowserLocale()
+);
+
+function App(): JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/administration/src/api/communautes.ts b/administration/src/api/communautes.ts
new file mode 100644
index 0000000..f0406c4
--- /dev/null
+++ b/administration/src/api/communautes.ts
@@ -0,0 +1,15 @@
+import axios from 'axios';
+import { URL_API } from '../utils/constant';
+import { getAuthHeader } from '../utils/httpHeaders';
+
+export const getFunderCommunityList = async (
+ funderId: string
+): Promise<{ id: string; name: string; funderId: string }[]> => {
+ const { data } = await axios.get(
+ `${await URL_API()}/funders/${funderId}/communities`,
+ {
+ headers: getAuthHeader(),
+ }
+ );
+ return data;
+};
diff --git a/administration/src/api/financeurs.ts b/administration/src/api/financeurs.ts
new file mode 100644
index 0000000..e334e09
--- /dev/null
+++ b/administration/src/api/financeurs.ts
@@ -0,0 +1,27 @@
+import axios from 'axios';
+import { URL_API } from '../utils/constant';
+import { getAuthHeader } from '../utils/httpHeaders';
+
+export const getFunders = async (): Promise<
+ {
+ name: string;
+ id: string;
+ funderType: string;
+ emailFormat?: string[];
+ }[]
+> => {
+ const { data } = await axios.get(`${await URL_API()}/funders`, {
+ headers: getAuthHeader(),
+ });
+ return data;
+};
+
+export const getClients = async (): Promise<{
+ clientId: string;
+ id: string;
+}> => {
+ const { data } = await axios.get(`${await URL_API()}/funders/clients`, {
+ headers: getAuthHeader(),
+ });
+ return data;
+};
diff --git a/administration/src/api/provider/dataProvider.ts b/administration/src/api/provider/dataProvider.ts
new file mode 100644
index 0000000..be204c7
--- /dev/null
+++ b/administration/src/api/provider/dataProvider.ts
@@ -0,0 +1,42 @@
+/* eslint-disable */
+
+import lb4Provider from 'react-admin-lb4';
+import { GET_LIST } from 'react-admin';
+import { URL_API } from '../../utils/constant';
+
+import { getAuthHeader } from '../../utils/httpHeaders';
+
+/**
+ * @param {string} type Request type, e.g GET_LIST, GET_ONE, DELETE, POST..
+ * @param {string} resource Resource name, e.g. "communautes"
+ * @param {Object} params Request parameters. Depends on the request type
+ */
+
+export default async (type: string, resource: string, params: any) => {
+ const data = await URL_API();
+ const dataProvider = lb4Provider(data, getAuthHeader);
+ const url: string = resourceConverter(type, resource);
+ return dataProvider(type, url, params);
+};
+
+const resourceConverter = (type: string, resource: string): string => {
+ switch (resource) {
+ case 'collectivites':
+ return 'collectivities';
+ case 'entreprises':
+ return 'enterprises';
+ case 'aides':
+ return 'incentives';
+ case 'communautes':
+ return 'funders/communities';
+ case 'utilisateurs':
+ if (type === GET_LIST) {
+ return 'users/funders';
+ }
+ return 'users';
+ case 'territoires':
+ return 'territories';
+ default:
+ return resource;
+ }
+};
diff --git a/administration/src/api/roles.ts b/administration/src/api/roles.ts
new file mode 100644
index 0000000..a1c0bde
--- /dev/null
+++ b/administration/src/api/roles.ts
@@ -0,0 +1,10 @@
+import axios from 'axios';
+import { URL_API } from '../utils/constant';
+import { getAuthHeader } from '../utils/httpHeaders';
+
+export const getRoles = async (): Promise => {
+ const { data } = await axios.get(`${await URL_API()}/users/roles`, {
+ headers: getAuthHeader(),
+ });
+ return data;
+};
diff --git a/administration/src/api/territories.ts b/administration/src/api/territories.ts
new file mode 100644
index 0000000..72d198a
--- /dev/null
+++ b/administration/src/api/territories.ts
@@ -0,0 +1,13 @@
+import axios from 'axios';
+import { URL_API } from '../utils/constant';
+import { getAuthHeader } from '../utils/httpHeaders';
+
+export const getTerritories = async (): Promise<{
+ name: string;
+ id: string;
+}> => {
+ const { data } = await axios.get(`${await URL_API()}/territories`, {
+ headers: getAuthHeader(),
+ });
+ return data;
+};
diff --git a/administration/src/assets/svg/spinner.svg b/administration/src/assets/svg/spinner.svg
new file mode 100644
index 0000000..39fba0f
--- /dev/null
+++ b/administration/src/assets/svg/spinner.svg
@@ -0,0 +1,69 @@
+
diff --git a/administration/src/components/Access/AccessRole.css b/administration/src/components/Access/AccessRole.css
new file mode 100644
index 0000000..21247b5
--- /dev/null
+++ b/administration/src/components/Access/AccessRole.css
@@ -0,0 +1,5 @@
+.no-admin {
+ margin-top: 50px;
+ margin-left: 10%;
+ font-size: 30px;
+}
diff --git a/administration/src/components/Access/AccessRole.tsx b/administration/src/components/Access/AccessRole.tsx
new file mode 100644
index 0000000..1eb15b2
--- /dev/null
+++ b/administration/src/components/Access/AccessRole.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import './AccessRole.css';
+import { useSession } from '../Keycloak/KeycloakProviderInit';
+
+/**
+ * Controle access role component
+ * @param props
+ * @constructor
+ */
+const AccessRole: React.FC = ({ children }) => {
+ const { keycloak } = useSession();
+
+ if (
+ keycloak &&
+ keycloak.realmAccess &&
+ keycloak.realmAccess.roles.includes(
+ `${process.env.REACT_APP_ADMIN_ACCES_ROLE || 'content_editor'}`
+ )
+ ) {
+ return <>{children}>;
+ }
+ keycloak.logout();
+ return (
+ Vous n êtes pas habilité.e à accéder à ce site
+ );
+};
+
+export default AccessRole;
diff --git a/administration/src/components/Aide/AideCreate.tsx b/administration/src/components/Aide/AideCreate.tsx
new file mode 100644
index 0000000..ceaa66b
--- /dev/null
+++ b/administration/src/components/Aide/AideCreate.tsx
@@ -0,0 +1,55 @@
+/* eslint-disable */
+import { FC } from 'react';
+import {
+ Create,
+ CreateProps,
+ SimpleForm,
+ useCreateContext,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+
+import AideCreateForm from './AideCreateForm';
+import AidesMessages from '../../utils/Aide/fr.json';
+import { errorFetching } from '../../utils/constant';
+
+const AideCreate: FC = (props) => {
+ const { save, record } = useCreateContext();
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onSuccess = ({ data }) => {
+ notify(
+ AidesMessages['aides.create.success'].replace('{aidTitle}', data.title),
+ 'success'
+ );
+ redirect('/aides');
+ refresh();
+ };
+
+ const onFailure = ({ message }) => {
+ const result =
+ message !== errorFetching.messageApi
+ ? AidesMessages[message]
+ : errorFetching.messageToDisplay;
+
+ notify(result, 'error');
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default AideCreate;
diff --git a/administration/src/components/Aide/AideCreateForm.tsx b/administration/src/components/Aide/AideCreateForm.tsx
new file mode 100644
index 0000000..cd845be
--- /dev/null
+++ b/administration/src/components/Aide/AideCreateForm.tsx
@@ -0,0 +1,302 @@
+/* eslint-disable */
+import React, { useEffect, useState } from 'react';
+import {
+ TextInput,
+ required,
+ DateInput,
+ SelectInput,
+ BooleanInput,
+ NumberInput,
+ AutocompleteArrayInput,
+ AutocompleteInput,
+ ArrayInput,
+ SimpleFormIterator,
+ FormDataConsumer,
+ useNotify,
+} from 'react-admin';
+import { useForm } from 'react-final-form';
+import { CardContent, Box } from '@material-ui/core';
+import { useQuery } from 'react-query';
+
+import {
+ TRANSPORT_CHOICE,
+ INCENTIVE_TYPE_CHOICE,
+ PROOF_CHOICE,
+ INPUT_FORMAT_CHOICE,
+} from '../../utils/constant';
+import { getDate } from '../../utils/convertDateToString';
+import {
+ startDateMin,
+ dateMinValidation,
+} from '../../utils/Aide/validityDateRules';
+import { validateUrl } from '../../utils/Aide/formHelper';
+import CustomAddButton from '../common/CustomAddButton';
+import { getFunders } from '../../api/financeurs';
+import FinanceurMessages from '../../utils/Financeur/fr.json';
+
+import '../styles/DynamicForm.css';
+import TerritoriesDropDown from '../common/TerritoriesDropDown';
+
+const AideCreateForm = (save, record) => {
+ const notify = useNotify();
+ const form = useForm();
+
+ const [FUNDER_CHOICE, setFunderChoice] = useState<
+ { name: string; label: string }[]
+ >([]);
+ const [isMobilityChecked, setIsMobilityChecked] = React.useState(false);
+
+ const { data: funders } = useQuery(
+ 'funders',
+ async (): Promise => {
+ return await getFunders();
+ },
+ {
+ onError: () => {
+ notify(FinanceurMessages['funders.error'], 'error');
+ },
+ enabled: true,
+ retry: false,
+ staleTime: Infinity,
+ }
+ );
+
+ useEffect(() => {
+ setFunderChoice(
+ funders &&
+ funders.map((elt) => {
+ elt.label = elt.funderType
+ ? `${elt.name} (${elt.funderType})`
+ : elt.name;
+ return elt;
+ })
+ );
+ }, [funders]);
+
+ const handleShowMobilityInputs = () => {
+ form.change('subscriptionLink', undefined);
+ form.change('specificFields', undefined);
+ setIsMobilityChecked(!isMobilityChecked);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ if (value) {
+ const newFunder = { name: value, label: value };
+ FUNDER_CHOICE.push(newFunder);
+ return newFunder;
+ }
+ }}
+ validate={[required()]}
+ choices={FUNDER_CHOICE}
+ optionText={(choice) =>
+ choice ?
+ (choice.label ? choice.label : choice.name ) :
+ null
+ }
+ optionValue="name"
+ translateChoice={false}
+ />
+
+
+
+
+
+ {
+ if (value) {
+ const newProof = { id: value, name: value };
+ PROOF_CHOICE.push(newProof);
+ return newProof;
+ }
+ }}
+ fullWidth
+ choices={PROOF_CHOICE}
+ />
+
+
+
+
+
+
+
+
+ {!isMobilityChecked ? (
+
+
+
+ ) : (
+
+
+
+ }
+ >
+
+
+
+ {({
+ formData, // The whole form data
+ scopedFormData, // The data for this item of the ArrayInput
+ getSource, // A function to get the valid source inside an ArrayInput
+ ...rest
+ }) =>
+ scopedFormData?.inputFormat === 'listeChoix' ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null
+ }
+
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default AideCreateForm;
diff --git a/administration/src/components/Aide/AideEdit.tsx b/administration/src/components/Aide/AideEdit.tsx
new file mode 100644
index 0000000..b3d8d65
--- /dev/null
+++ b/administration/src/components/Aide/AideEdit.tsx
@@ -0,0 +1,59 @@
+import * as React from 'react';
+import { FC } from 'react';
+import {
+ Edit,
+ EditProps,
+ Record,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+import { errorFetching } from '../../utils/constant';
+import AideForm from './AideEditForm';
+import AidesMessages from '../../utils/Aide/fr.json';
+
+const AideEdit: FC = (props) => {
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onSuccess = ({ data }): void => {
+ notify(
+ AidesMessages['aides.edit.success'].replace('{aidTitle}', data.title),
+ 'success'
+ );
+ redirect('/aides');
+ refresh();
+ };
+
+ const onFailure = ({ message }): void => {
+ const result =
+ message !== errorFetching.messageApi
+ ? AidesMessages[message]
+ : errorFetching.messageToDisplay;
+
+ notify(result, 'error');
+ };
+
+ const transform = (data): Record => {
+ if (data.isMCMStaff) {
+ return { ...data, subscriptionLink: undefined };
+ }
+ return { ...data, specificFields: undefined };
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default AideEdit;
diff --git a/administration/src/components/Aide/AideEditForm.tsx b/administration/src/components/Aide/AideEditForm.tsx
new file mode 100644
index 0000000..db1552b
--- /dev/null
+++ b/administration/src/components/Aide/AideEditForm.tsx
@@ -0,0 +1,312 @@
+/* eslint-disable */
+import React, { useEffect, useState } from 'react';
+import { find } from 'lodash';
+import {
+ TextInput,
+ required,
+ DateInput,
+ SelectInput,
+ BooleanInput,
+ AutocompleteArrayInput,
+ ArrayInput,
+ SimpleFormIterator,
+ useRecordContext,
+ FormWithRedirect,
+ FormDataConsumer,
+ NumberInput,
+ useEditContext,
+ Toolbar,
+ EditProps,
+} from 'react-admin';
+import { CardContent, Box } from '@material-ui/core';
+import {
+ TRANSPORT_CHOICE,
+ INCENTIVE_TYPE_CHOICE,
+ PROOF_CHOICE,
+ INPUT_FORMAT_CHOICE,
+} from '../../utils/constant';
+import {
+ startDateMin,
+ dateMinValidation,
+} from '../../utils/Aide/validityDateRules';
+import { getDate } from '../../utils/convertDateToString';
+import { validateUrl } from '../../utils/Aide/formHelper';
+import CustomAddButton from '../common/CustomAddButton';
+import '../styles/DynamicForm.css';
+import TerritoriesDropDown from '../common/TerritoriesDropDown';
+
+const AideEditForm = (props: EditProps) => {
+ const { save, record } = useEditContext();
+ const recordContext = useRecordContext();
+ const [isMobilityChecked, setIsMobilityChecked] = useState(false);
+
+ // Check the isMobilityChecked at the willmount
+ useEffect(() => {
+ if (recordContext?.isMCMStaff) {
+ setIsMobilityChecked(true);
+ } else {
+ setIsMobilityChecked(false);
+ }
+ }, []);
+ // verify if each label Proof exist in options (PROOF_CHOICE) in this aides, else add option in Proof choice
+ if (recordContext?.attachments) {
+ recordContext.attachments.map((value) => {
+ if (!find(PROOF_CHOICE, ['id', value])) {
+ PROOF_CHOICE.push({ id: value, name: value });
+ }
+ });
+ }
+
+ // Toogle of isMobilityChecked
+ const handleShowMobilityInputs = () => {
+ setIsMobilityChecked(!isMobilityChecked);
+ };
+
+ return (
+ (
+
+
+
+
+
+
+
+ )}
+ />
+ );
+};
+
+export default AideEditForm;
diff --git a/administration/src/components/Aide/AideList.tsx b/administration/src/components/Aide/AideList.tsx
new file mode 100644
index 0000000..b1d8062
--- /dev/null
+++ b/administration/src/components/Aide/AideList.tsx
@@ -0,0 +1,35 @@
+/* eslint-disable */
+import React from 'react';
+import {
+ List,
+ Datagrid,
+ TextField,
+ EditButton,
+ DeleteButton,
+ FunctionField,
+} from 'react-admin';
+
+import { MAPPING_FUNDER_TYPE } from '../../utils/constant';
+
+const AideList = (props) => {
+ return (
+
+
+
+
+
+ `${record.funderName} (${
+ MAPPING_FUNDER_TYPE[record.incentiveType]
+ })`
+ }
+ />
+
+
+
+
+ );
+};
+
+export default AideList;
diff --git a/administration/src/components/Aide/index.tsx b/administration/src/components/Aide/index.tsx
new file mode 100644
index 0000000..06bb57c
--- /dev/null
+++ b/administration/src/components/Aide/index.tsx
@@ -0,0 +1,9 @@
+import AideCreate from './AideCreate';
+import AideList from './AideList';
+import AideEdit from './AideEdit';
+
+export default {
+ list: AideList,
+ create: AideCreate,
+ edit: AideEdit,
+};
diff --git a/administration/src/components/Collectivites/CollectiviteCreateForm.tsx b/administration/src/components/Collectivites/CollectiviteCreateForm.tsx
new file mode 100644
index 0000000..7d174f8
--- /dev/null
+++ b/administration/src/components/Collectivites/CollectiviteCreateForm.tsx
@@ -0,0 +1,43 @@
+/* eslint-disable */
+import { TextInput, required, NumberInput } from 'react-admin';
+import { CardContent, Box } from '@material-ui/core';
+
+const Spacer = () => ;
+
+const CollectiviteCreateForm = (save, record) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CollectiviteCreateForm;
diff --git a/administration/src/components/Collectivites/CollectiviteForm.tsx b/administration/src/components/Collectivites/CollectiviteForm.tsx
new file mode 100644
index 0000000..2a83550
--- /dev/null
+++ b/administration/src/components/Collectivites/CollectiviteForm.tsx
@@ -0,0 +1,50 @@
+/* eslint-disable */
+import CollectiviteCreateForm from './CollectiviteCreateForm';
+import {
+ Create,
+ SimpleForm,
+ useCreateContext,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+
+import CollectivitesMessages from '../../utils/Collectivite/fr.json';
+import { errorFetching } from '../../utils/constant';
+
+const CollectiviteForm = (props) => {
+ const { save, record } = useCreateContext();
+
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onSuccess = ({ data }) => {
+ notify(`Création de la collectivité "${data.name}" avec succès`, 'success');
+ redirect('/collectivites');
+ refresh();
+ };
+
+ const onFailure = ({ message }) => {
+ const result =
+ message !== errorFetching.messageApi
+ ? CollectivitesMessages[message]
+ : errorFetching.messageToDisplay;
+ notify(result, 'error');
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default CollectiviteForm;
diff --git a/administration/src/components/Collectivites/CollectiviteList.tsx b/administration/src/components/Collectivites/CollectiviteList.tsx
new file mode 100644
index 0000000..fef12d5
--- /dev/null
+++ b/administration/src/components/Collectivites/CollectiviteList.tsx
@@ -0,0 +1,19 @@
+/* eslint-disable */
+import { List, Datagrid, TextField } from 'react-admin';
+
+const CollectiviteList = (props) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default CollectiviteList;
diff --git a/administration/src/components/Communautes/CommunauteCreateForm.tsx b/administration/src/components/Communautes/CommunauteCreateForm.tsx
new file mode 100644
index 0000000..969e351
--- /dev/null
+++ b/administration/src/components/Communautes/CommunauteCreateForm.tsx
@@ -0,0 +1,33 @@
+/* eslint-disable */
+import { TextInput, required } from 'react-admin';
+import { CardContent, Box } from '@material-ui/core';
+
+import FinanceurDropDown from '../common/FinanceurDropDown';
+
+const CommuniteCreateForm = (save, record) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CommuniteCreateForm;
diff --git a/administration/src/components/Communautes/CommunauteForm.tsx b/administration/src/components/Communautes/CommunauteForm.tsx
new file mode 100644
index 0000000..3025015
--- /dev/null
+++ b/administration/src/components/Communautes/CommunauteForm.tsx
@@ -0,0 +1,55 @@
+/* eslint-disable */
+import {
+ Create,
+ SimpleForm,
+ useCreateContext,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+
+import { errorFetching } from '../../utils/constant';
+import CommunauteCreateForm from './CommunauteCreateForm';
+import CommunauteMessages from '../../utils/Communaute/fr.json';
+
+const CommunauteForm = (props) => {
+ const { save, record } = useCreateContext();
+
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onSuccess = ({ data }) => {
+ notify(`Création de la communauté "${data.name}" avec succès`, 'success');
+ redirect('/communautes');
+ refresh();
+ };
+
+ const onFailure = ({ message }) => {
+ const result =
+ message !== errorFetching.messageApi
+ ? CommunauteMessages[message]
+ : errorFetching.messageToDisplay;
+ notify(result, 'error');
+ };
+
+ return (
+ {
+ delete data.emailFormat;
+ delete data.hasManualAffiliation;
+ return data;
+ }}
+ >
+
+
+
+
+ );
+};
+
+export default CommunauteForm;
diff --git a/administration/src/components/Communautes/CommunauteList.tsx b/administration/src/components/Communautes/CommunauteList.tsx
new file mode 100644
index 0000000..8bf36fc
--- /dev/null
+++ b/administration/src/components/Communautes/CommunauteList.tsx
@@ -0,0 +1,16 @@
+import React, { FC } from 'react';
+import { List, Datagrid, TextField } from 'react-admin';
+
+const CommunauteList: FC = (props) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default CommunauteList;
diff --git a/administration/src/components/Communautes/index.tsx b/administration/src/components/Communautes/index.tsx
new file mode 100644
index 0000000..6e10a9e
--- /dev/null
+++ b/administration/src/components/Communautes/index.tsx
@@ -0,0 +1,7 @@
+import CommunauteForm from './CommunauteForm';
+import CommunauteList from './CommunauteList';
+
+export default {
+ list: CommunauteList,
+ create: CommunauteForm,
+};
diff --git a/administration/src/components/Dashboard/Dashboard.tsx b/administration/src/components/Dashboard/Dashboard.tsx
new file mode 100644
index 0000000..a82a852
--- /dev/null
+++ b/administration/src/components/Dashboard/Dashboard.tsx
@@ -0,0 +1,17 @@
+import * as React from 'react';
+import Card from '@material-ui/core/Card';
+import CardContent from '@material-ui/core/CardContent';
+import CardHeader from '@material-ui/core/CardHeader';
+
+const Dashboard: React.FC = () => (
+
+
+
+ Version : {process.env.REACT_APP_PACKAGE_VERSION || '1.0.0'}
+
+
+);
+export default Dashboard;
diff --git a/administration/src/components/Entreprises/EntrepriseCreateForm.tsx b/administration/src/components/Entreprises/EntrepriseCreateForm.tsx
new file mode 100644
index 0000000..1be3571
--- /dev/null
+++ b/administration/src/components/Entreprises/EntrepriseCreateForm.tsx
@@ -0,0 +1,197 @@
+/* eslint-disable */
+import {
+ TextInput,
+ required,
+ NumberInput,
+ BooleanInput,
+ AutocompleteArrayInput,
+ useNotify,
+ SelectInput,
+} from 'react-admin';
+import { useForm } from 'react-final-form';
+import { Card, CardContent, Divider, Box } from '@material-ui/core';
+import { InfoRounded } from '@material-ui/icons';
+
+import { isEmailFormatValid } from '../../utils/helpers';
+
+import '../styles/DynamicForm.css';
+import { useEffect, useState } from 'react';
+import { useQuery } from 'react-query';
+import { getClients } from '../../api/financeurs';
+import FinanceurMessages from '../../utils/Financeur/fr.json';
+
+const EMAIL_TAB = [{ id: '@exemple.com', name: '@exemple.com' }];
+
+type checkboxParams = {
+ source: string;
+ label: string;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const EntrepriseCreateForm = (save, record) => {
+ const notify = useNotify();
+ const form = useForm();
+ const [CLIENT_CHOICE, setClientChoice] = useState<
+ { clientId: string; id: string }[]
+ >([]);
+
+ const { data: clients } = useQuery(
+ 'clients',
+ async (): Promise => {
+ return await getClients();
+ },
+ {
+ onError: () => {
+ notify(FinanceurMessages['funders.error.clients.list'], 'error');
+ },
+ enabled: true,
+ retry: false,
+ staleTime: Infinity,
+ }
+ );
+ useEffect(() => {
+ setClientChoice(
+ clients &&
+ clients.map((elt) => {
+ elt.name = elt.clientId;
+ return elt;
+ })
+ );
+ }, [clients]);
+ const checkBoxOptions: checkboxParams[] = [
+ {
+ source: 'isHris',
+ label: 'SI RH',
+ },
+ {
+ source: 'hasManualAffiliation',
+ label: 'Validation affiliation par le gestionnaire',
+ },
+ ];
+
+ const uncheckOtherOptions = (checkedOption: string) => {
+ checkBoxOptions
+ .filter((el) => el.source !== checkedOption)
+ .forEach((option: checkboxParams) => form.change(option.source, false));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (isEmailFormatValid(value)) {
+ const newFormat = { id: value, name: value };
+ EMAIL_TAB.push(newFormat);
+ return newFormat;
+ }
+ EMAIL_TAB.push({ id: '', name: '' });
+ }}
+ fullWidth
+ choices={EMAIL_TAB}
+ validate={[required(), validateFormatEmail]}
+ />
+
+
+
+
+
+
+
+
+
+ {CLIENT_CHOICE && (
+
+
+
+ )}
+ {checkBoxOptions.map((checkBoxOption: checkboxParams) => (
+
+
+ uncheckOtherOptions(checkBoxOption.source)
+ }
+ defaultValue={false}
+ source={checkBoxOption.source}
+ label={checkBoxOption.label}
+ />
+
+ ))}
+
+
+
+
+ Vous ne pouvez pas activer les 2 options pour une
+ entreprise
+
+
+
+
+
+
+
+
+
+
+ );
+};
+const Spacer = () => ;
+
+const validateFormatEmail = (value) => {
+ return value.includes(undefined) || value.includes('@@ra-create')
+ ? 'Ne doit pas être nul'
+ : undefined;
+};
+
+export default EntrepriseCreateForm;
diff --git a/administration/src/components/Entreprises/EntrepriseForm.tsx b/administration/src/components/Entreprises/EntrepriseForm.tsx
new file mode 100644
index 0000000..d9846f8
--- /dev/null
+++ b/administration/src/components/Entreprises/EntrepriseForm.tsx
@@ -0,0 +1,54 @@
+/* eslint-disable react/jsx-props-no-spreading */
+/* eslint-disable */
+
+import {
+ Create,
+ SimpleForm,
+ useCreateContext,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+
+import EntrepriseCreateForm from './EntrepriseCreateForm';
+import EntreprisesMessages from '../../utils/Entreprise/fr.json';
+import { errorFetching } from '../../utils/constant';
+
+const EntrepriseForm = (props) => {
+ const { save, record } = useCreateContext();
+
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onFailure = ({ message }) => {
+ const result =
+ message !== errorFetching.messageApi
+ ? EntreprisesMessages[message]
+ : errorFetching.messageToDisplay;
+ notify(result, 'error');
+ };
+
+ const onSuccess = ({ data }) => {
+ notify(
+ `Création du compte entreprise "${data.name}" avec succès`,
+ 'success'
+ );
+ redirect('/entreprises');
+ refresh();
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+export default EntrepriseForm;
diff --git a/administration/src/components/Entreprises/EntrepriseList.tsx b/administration/src/components/Entreprises/EntrepriseList.tsx
new file mode 100644
index 0000000..7668fa7
--- /dev/null
+++ b/administration/src/components/Entreprises/EntrepriseList.tsx
@@ -0,0 +1,25 @@
+/* eslint-disable */
+import { List, Datagrid, TextField, BooleanField } from 'react-admin';
+
+const EntrepriseList = (props) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EntrepriseList;
diff --git a/administration/src/components/Entreprises/index.tsx b/administration/src/components/Entreprises/index.tsx
new file mode 100644
index 0000000..b6f7db7
--- /dev/null
+++ b/administration/src/components/Entreprises/index.tsx
@@ -0,0 +1,8 @@
+/* eslint-disable import/no-anonymous-default-export */
+import EntrepriseForm from './EntrepriseForm';
+import EntrepriseList from './EntrepriseList';
+
+export default {
+ create: EntrepriseForm,
+ list: EntrepriseList,
+};
diff --git a/administration/src/components/Keycloak/KeycloakProviderInit.tsx b/administration/src/components/Keycloak/KeycloakProviderInit.tsx
new file mode 100644
index 0000000..cd0b3df
--- /dev/null
+++ b/administration/src/components/Keycloak/KeycloakProviderInit.tsx
@@ -0,0 +1,109 @@
+/* eslint-disable */
+import React, { useEffect, useState } from 'react';
+import Keycloak, { KeycloakConfig, KeycloakInstance } from 'keycloak-js';
+
+import Loading from '../Loading/Loading';
+
+interface IKeycloakContext {
+ keycloak?: KeycloakInstance;
+}
+
+/**
+ * Create Context
+ */
+export const KeycloakContext = React.createContext<
+ IKeycloakContext | undefined
+>(undefined);
+
+/**
+ * Keycloak wrapping component
+ * @param props
+ * @constructor
+ */
+const KeycloakProviderInit: React.FC = ({ children }) => {
+ // Keycloak config generated during CI/CD pipeline
+ const [keycloakInstance, setKeycloakInstance] = useState();
+ const [isKCInit, setIsKCInit] = useState(false);
+
+ /**
+ * Fetching of keycloak config (keycloak.json) generated during CI/CD pipeline
+ */
+ const fetchKeycloakConfig = async () => {
+ const response = await fetch('/keycloak.json');
+ const KeycloakConfig = await response.json();
+ const data: KeycloakConfig = await KeycloakConfig.keycloakConfig;
+ if (Object.keys(data).length !== 0) {
+ const keycloak = Keycloak(data);
+ setKeycloakInstance(keycloak);
+ }
+ };
+
+ /**
+ * Initializes Keycloak instance and set the variable state if successfully initialized.
+ */
+ const initKeycloak = async () => {
+ keycloakInstance
+ ?.init({
+ onLoad: 'login-required',
+ })
+ .then(() => {
+ window.localStorage.setItem('token', keycloakInstance?.token!);
+ setIsKCInit(true);
+ })
+ .catch((err) => console.error(err));
+ };
+
+ useEffect(() => {
+ fetchKeycloakConfig();
+ }, []);
+
+ useEffect(() => {
+ initKeycloak();
+ }, [keycloakInstance]);
+
+ useEffect(() => {
+ /**
+ * Call onTokenExpired when the access token is expired.
+ * If a refresh token is available, the token can be refreshed via the OAuth RefreshToken
+ * In order to transmit it on each call to the API.
+ * Otherwise logout the user.
+ */
+ if (keycloakInstance) {
+ keycloakInstance!.onTokenExpired = () => {
+ keycloakInstance
+ ?.updateToken(5)
+ .then(() => {
+ window.localStorage.setItem('token', keycloakInstance?.token!);
+ })
+ .catch(() => {
+ console.error('Failed to refresh token | the session is expired');
+ keycloakInstance.logout();
+ });
+ };
+ }
+ }, [keycloakInstance]);
+
+ return (
+
+ {isKCInit ? children : }
+
+ );
+};
+
+/**
+ * useSession custom hook to get the provided values from KeycloakContext
+ */
+const useSession = () => {
+ const keycloakContext = React.useContext(KeycloakContext);
+ if (keycloakContext === undefined) {
+ throw new Error('useSession must be used within a KeycloakProvider');
+ }
+
+ return keycloakContext;
+};
+
+export { KeycloakProviderInit, useSession };
diff --git a/administration/src/components/Loading/Loading.css b/administration/src/components/Loading/Loading.css
new file mode 100644
index 0000000..6278c96
--- /dev/null
+++ b/administration/src/components/Loading/Loading.css
@@ -0,0 +1,5 @@
+.loading {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+}
diff --git a/administration/src/components/Loading/Loading.tsx b/administration/src/components/Loading/Loading.tsx
new file mode 100644
index 0000000..8e99613
--- /dev/null
+++ b/administration/src/components/Loading/Loading.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import loadingSpinner from '../../assets/svg/spinner.svg';
+
+import './Loading.css';
+
+const Loading = (): React.ReactElement => {
+ return ;
+};
+
+export default Loading;
diff --git a/administration/src/components/LoginForm/LogoutButton.tsx b/administration/src/components/LoginForm/LogoutButton.tsx
new file mode 100644
index 0000000..eaae409
--- /dev/null
+++ b/administration/src/components/LoginForm/LogoutButton.tsx
@@ -0,0 +1,23 @@
+/* eslint-disable */
+import * as React from 'react';
+import MenuItem from '@material-ui/core/MenuItem';
+import ExitIcon from '@material-ui/icons/PowerSettingsNew';
+
+import { useSession } from '../Keycloak/KeycloakProviderInit';
+
+const LogoutButton: React.FC = () => {
+ const { keycloak } = useSession();
+
+ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+ const handleClick = () => {
+ keycloak.logout();
+ };
+
+ return (
+
+ );
+};
+
+export default LogoutButton;
diff --git a/administration/src/components/Territories/TerritoryCreateForm.tsx b/administration/src/components/Territories/TerritoryCreateForm.tsx
new file mode 100644
index 0000000..b051e46
--- /dev/null
+++ b/administration/src/components/Territories/TerritoryCreateForm.tsx
@@ -0,0 +1,23 @@
+/* eslint-disable */
+import { TextInput, required } from 'react-admin';
+import { CardContent, Box } from '@material-ui/core';
+import { checkNamesLength } from '../../utils/checkNamesLength';
+
+const TerritoryCreateForm = (save, record) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default TerritoryCreateForm;
diff --git a/administration/src/components/Territories/TerritoryEdit.tsx b/administration/src/components/Territories/TerritoryEdit.tsx
new file mode 100644
index 0000000..a147ffc
--- /dev/null
+++ b/administration/src/components/Territories/TerritoryEdit.tsx
@@ -0,0 +1,60 @@
+/* eslint-disable */
+import { FC } from 'react';
+import {
+ Edit,
+ EditProps,
+ SaveButton,
+ SimpleForm,
+ Toolbar,
+ ToolbarProps,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+import TerritoryEditForm from './TerritoryEditForm';
+import { errorFetching } from '../../utils/constant';
+import TerritoryMessages from '../../utils/Territory/fr.json';
+
+const TerritoryEdit: FC = (props) => {
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onSuccess = (): void => {
+ notify(`Le terriotire a été modifié avec succes`, 'success');
+ redirect('/territoires');
+ refresh();
+ };
+
+ const onFailure = ({ message }): void => {
+ const result: string =
+ message !== errorFetching.messageApi
+ ? TerritoryMessages[message]
+ : errorFetching.messageToDisplay;
+ notify(result, 'error');
+ };
+
+ const EditToolbar = (props: ToolbarProps) => {
+ return (
+
+
+
+ );
+ };
+
+ return (
+
+ }>
+
+
+
+ );
+};
+
+export default TerritoryEdit;
diff --git a/administration/src/components/Territories/TerritoryEditForm.tsx b/administration/src/components/Territories/TerritoryEditForm.tsx
new file mode 100644
index 0000000..090d424
--- /dev/null
+++ b/administration/src/components/Territories/TerritoryEditForm.tsx
@@ -0,0 +1,26 @@
+/* eslint-disable */
+import { TextInput, required } from 'react-admin';
+import { CardContent, Box } from '@material-ui/core';
+import { checkNamesLength } from '../../utils/checkNamesLength';
+
+const TerritoryEditForm = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default TerritoryEditForm;
diff --git a/administration/src/components/Territories/TerritoryForm.tsx b/administration/src/components/Territories/TerritoryForm.tsx
new file mode 100644
index 0000000..505043c
--- /dev/null
+++ b/administration/src/components/Territories/TerritoryForm.tsx
@@ -0,0 +1,49 @@
+/* eslint-disable */
+import {
+ Create,
+ SimpleForm,
+ useCreateContext,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+
+import TerritoryCreateForm from './TerritoryCreateForm';
+import TerritoryMessages from '../../utils/Territory/fr.json';
+import { errorFetching } from '../../utils/constant';
+
+const TerritoryForm = (props) => {
+ const { save, record } = useCreateContext();
+
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onFailure = ({ message }): void => {
+ const result: string =
+ message !== errorFetching.messageApi
+ ? TerritoryMessages[message]
+ : errorFetching.messageToDisplay;
+ notify(result, 'error');
+ };
+
+ const onSuccess = ({ data }): void => {
+ notify(`Création du territoire ${data.name} avec succès`, 'success');
+ redirect('/territoires');
+ refresh();
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+export default TerritoryForm;
diff --git a/administration/src/components/Territories/TerritoryList.tsx b/administration/src/components/Territories/TerritoryList.tsx
new file mode 100644
index 0000000..8f8e93a
--- /dev/null
+++ b/administration/src/components/Territories/TerritoryList.tsx
@@ -0,0 +1,44 @@
+/* eslint-disable */
+import { ListProps } from '@material-ui/core';
+import { FC } from 'react';
+import {
+ List,
+ Datagrid,
+ TextField,
+ Filter,
+ SearchInput,
+ EditButton,
+} from 'react-admin';
+
+const TerritoryFilter: FC = (props) => (
+
+
+
+);
+
+const TerritoryList: FC = (props) => {
+ return (
+
}
+ sort={{ field: 'name', order: 'ASC' }}
+ bulkActionButtons={false}
+ >
+
+
+
+
+
+ );
+};
+
+export default TerritoryList;
diff --git a/administration/src/components/Territories/index.tsx b/administration/src/components/Territories/index.tsx
new file mode 100644
index 0000000..f2d96d6
--- /dev/null
+++ b/administration/src/components/Territories/index.tsx
@@ -0,0 +1,10 @@
+/* eslint-disable */
+import TerritoryForm from './TerritoryForm';
+import TerritoryList from './TerritoryList';
+import TerritoryEdit from './TerritoryEdit';
+
+export default {
+ create: TerritoryForm,
+ list: TerritoryList,
+ edit: TerritoryEdit,
+};
diff --git a/administration/src/components/common/CustomAddButton.tsx b/administration/src/components/common/CustomAddButton.tsx
new file mode 100644
index 0000000..6dd2c27
--- /dev/null
+++ b/administration/src/components/common/CustomAddButton.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Button } from '@material-ui/core';
+
+const CustomAddButton = (props: { label: string }): JSX.Element => {
+ const { label } = props;
+ return (
+
+ );
+};
+export default CustomAddButton;
diff --git a/administration/src/components/common/FinanceurDropDown.tsx b/administration/src/components/common/FinanceurDropDown.tsx
new file mode 100644
index 0000000..a4f51fc
--- /dev/null
+++ b/administration/src/components/common/FinanceurDropDown.tsx
@@ -0,0 +1,66 @@
+/* eslint-disable */
+import { required, useNotify, AutocompleteInput } from 'react-admin';
+import { useFormState } from 'react-final-form';
+import { useQuery } from 'react-query';
+import { getFunders } from '../../api/financeurs';
+
+import FinanceurMessages from '../../utils/Financeur/fr.json';
+
+interface FunderProps {
+ disabled?: boolean;
+}
+
+export interface Enterprise {
+ id?: string;
+ emailFormat?: string[];
+ funderType?: string;
+ name?: string;
+ hasManualAffiliation?: boolean;
+}
+
+const FinanceurDropDown = ({ disabled }: FunderProps) => {
+ const notify = useNotify();
+ const { values } = useFormState();
+
+ const { data: funders } = useQuery(
+ 'funders',
+ async (): Promise => {
+ return await getFunders();
+ },
+ {
+ onError: () => {
+ notify(FinanceurMessages['funders.error'], 'error');
+ },
+ enabled: true,
+ retry: false,
+ staleTime: Infinity,
+ }
+ );
+
+ return (
+ ({
+ id,
+ name: `${name} (${funderType})`,
+ emailFormat,
+ hasManualAffiliation,
+ })
+ )
+ }
+ onInputValueChange={(_, data) =>
+ (values.emailFormat = data?.selectedItem?.emailFormat) &&
+ (values.hasManualAffiliation = data?.selectedItem?.hasManualAffiliation)
+ }
+ />
+ );
+};
+
+export default FinanceurDropDown;
diff --git a/administration/src/components/common/TerritoriesDropDown.tsx b/administration/src/components/common/TerritoriesDropDown.tsx
new file mode 100644
index 0000000..d15225a
--- /dev/null
+++ b/administration/src/components/common/TerritoriesDropDown.tsx
@@ -0,0 +1,115 @@
+/* eslint-disable */
+import { useEffect, useState, FC } from 'react';
+import { required, useNotify, AutocompleteInput } from 'react-admin';
+import { useFormState } from 'react-final-form';
+import { useQuery } from 'react-query';
+import { getTerritories } from '../../api/territories';
+import { checkNamesLength } from '../../utils/checkNamesLength';
+import { removeWhiteSpace } from '../../utils/helpers';
+import TerritoryMessages from '../../utils/Territory/fr.json';
+
+interface TerritoryProps {
+ canCreate?: boolean;
+}
+
+interface TerritoryOption {
+ id?: string;
+ name: string;
+ label: string;
+}
+
+const TerritoriesDropDown: FC = ({ canCreate = true }) => {
+ const notify = useNotify();
+ const { values } = useFormState();
+
+ const [territoriesList, setTerritoriesList] = useState([]);
+ const [selectedOption, setSelectedOption] = useState(null);
+
+ const { data: territories } = useQuery(
+ 'territories',
+ async (): Promise => {
+ return await getTerritories();
+ },
+ {
+ onError: () => {
+ notify(TerritoryMessages['territory.error'], 'error');
+ },
+ enabled: true,
+ }
+ );
+
+ const checkExistingValue = (value: string): TerritoryOption | undefined =>
+ territoriesList.find(
+ (choice) => choice?.name?.toLowerCase() === value?.toLowerCase()
+ );
+
+ const handleInputChanged = (data: TerritoryOption): void => {
+ setSelectedOption(data);
+ };
+
+ const handleOptionChange = (choice: TerritoryOption): string =>
+ choice?.label ? choice.label : choice?.name;
+
+ const handleCreateOption = (value: string): TerritoryOption => {
+ if (value) {
+ value = removeWhiteSpace(value);
+ const foundValue = checkExistingValue(value);
+ if (!foundValue) {
+ const newTerritory = { name: value, label: value };
+ setTerritoriesList([...territoriesList, newTerritory]);
+ return newTerritory;
+ }
+ return foundValue;
+ }
+ };
+
+ let createProp = {};
+ if (canCreate) {
+ createProp['onCreate'] = handleCreateOption;
+ }
+
+ useEffect(() => {
+ setTerritoriesList(
+ territories &&
+ territories.map((elt: TerritoryOption) => {
+ elt.label = `${elt.name} (créé)`;
+ return elt;
+ })
+ );
+ }, [territories]);
+
+ useEffect(() => {
+ // ID GENERATED WHEN CREATING A NEW ITEM ON LIST CHOICES
+ if (selectedOption) {
+ if (selectedOption?.id === '@@ra-create') {
+ values.territoryName = values?.territory?.name; // TODO: REMOVING DEPRECATED territoryName.
+ const foundValue = checkExistingValue(values?.territory?.name);
+ if (foundValue) {
+ values.territory.id = foundValue?.id;
+ }
+ } else {
+ values.territory = {
+ id: selectedOption?.id,
+ name: selectedOption?.name,
+ };
+ values.territoryName = selectedOption?.name; // TODO: REMOVING DEPRECATED territoryName.
+ }
+ }
+ }, [selectedOption, values]);
+
+ return (
+
+ );
+};
+
+export default TerritoriesDropDown;
diff --git a/administration/src/components/customTheme/customTheme.tsx b/administration/src/components/customTheme/customTheme.tsx
new file mode 100644
index 0000000..f63d775
--- /dev/null
+++ b/administration/src/components/customTheme/customTheme.tsx
@@ -0,0 +1,18 @@
+import { createTheme } from '@material-ui/core/styles';
+import { defaultTheme } from 'react-admin';
+
+const customTheme = createTheme({
+ ...defaultTheme,
+ palette: {
+ primary: {
+ main: '#464cd0',
+ contrastText: '#FFFFFF',
+ },
+ secondary: {
+ main: '#01bf7d',
+ contrastText: '#FFFFFF',
+ },
+ },
+});
+
+export default customTheme;
diff --git a/administration/src/components/styles/DynamicForm.css b/administration/src/components/styles/DynamicForm.css
new file mode 100644
index 0000000..0ea219c
--- /dev/null
+++ b/administration/src/components/styles/DynamicForm.css
@@ -0,0 +1,60 @@
+.css-2b097c-container {
+ z-index: 1000;
+}
+
+.css-1hb7zxy-IndicatorsContainer {
+ display: none !important;
+}
+
+.css-yk16xz-control {
+ background-color: hsl(0deg 0% 96%) !important;
+}
+
+.css-g1d714-ValueContainer {
+ height: 48px !important;
+}
+
+.ra-input-mde {
+ margin: 8px 0px 4px 0px;
+}
+
+.simpleForm1 .MuiTypography-body1 {
+ visibility: hidden;
+}
+
+.simpleForm1 {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+.simpleForm1 > * {
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 10px;
+ padding: 1em !important;
+ margin: 5px;
+}
+
+.simpleForm1 > li:last-child {
+ min-width: 700px;
+ border: none;
+}
+
+.simpleForm1 li {
+ flex-direction: column;
+ max-width: 300px;
+ width: 100%;
+ height: fit-content;
+}
+
+.simpleForm2 li {
+ flex-direction: row;
+ justify-content: flex-start;
+}
+
+.simpleForm2 > li:first-child span {
+ width: auto;
+}
+
+.simpleForm1 p {
+ display: none;
+}
diff --git a/administration/src/components/utilisateurs/CommunauteCheckBox.tsx b/administration/src/components/utilisateurs/CommunauteCheckBox.tsx
new file mode 100644
index 0000000..69ba573
--- /dev/null
+++ b/administration/src/components/utilisateurs/CommunauteCheckBox.tsx
@@ -0,0 +1,64 @@
+/* eslint-disable */
+import { useNotify, CheckboxGroupInput } from 'react-admin';
+import { useFormState } from 'react-final-form';
+import { useQuery } from 'react-query';
+
+import CommunautesMessages from '../../utils/Communaute/fr.json';
+import { ROLES } from '../../utils/constant';
+import { getFunderCommunityList } from '../../api/communautes';
+
+const CommunauteCheckBox = () => {
+ const notify = useNotify();
+ const { values } = useFormState();
+
+ const enabled =
+ !!values &&
+ !!values.funderId &&
+ !!values.roles &&
+ (typeof values.roles === 'string'
+ ? values.roles.split(' ; ').some((role) => role === ROLES.gestionnaires)
+ : values.roles.some((role) => role === ROLES.gestionnaires));
+ const { data: communities } = useQuery(
+ `funders/${values.funderId}/communities`,
+ async (): Promise => {
+ return await getFunderCommunityList(values.funderId);
+ },
+ {
+ onError: () => {
+ notify(CommunautesMessages['communautés.error'], 'error');
+ },
+ enabled,
+ staleTime: 300000,
+ }
+ );
+
+ const validateCommunities = (communityIds) => {
+ const condition =
+ communityIds &&
+ communityIds.length > 0 &&
+ communities &&
+ communities.length > 0;
+
+ return condition ? undefined : 'Il faut cocher au moins une communauté';
+ };
+
+ if (enabled && communities && communities.length > 0)
+ return (
+
+ );
+ if (enabled && communities)
+ return (
+
+ Aucune communauté n'est créée pour ce financeur. Les droits seront
+ appliqués pour l'ensemble du périmètre financeur
+
+ );
+ return null;
+};
+
+export default CommunauteCheckBox;
diff --git a/administration/src/components/utilisateurs/RolesRadioButtons.tsx b/administration/src/components/utilisateurs/RolesRadioButtons.tsx
new file mode 100644
index 0000000..807c0ca
--- /dev/null
+++ b/administration/src/components/utilisateurs/RolesRadioButtons.tsx
@@ -0,0 +1,55 @@
+/* eslint-disable */
+import { useNotify, CheckboxGroupInput } from 'react-admin';
+import { RadioButtonChecked, RadioButtonUnchecked } from '@material-ui/icons';
+import { capitalize } from '@material-ui/core';
+import { useQuery } from 'react-query';
+
+import UtilisateursMessages from '../../utils/Utilisateur/fr.json';
+import { getRoles } from '../../api/roles';
+
+const RolesRadioButtons = () => {
+ const notify = useNotify();
+
+ const { data: roles } = useQuery(
+ 'roles',
+ async (): Promise => {
+ return await getRoles();
+ },
+ {
+ onError: () => {
+ notify(UtilisateursMessages['users.roles.error'], 'error');
+ },
+ enabled: true,
+ retry: false,
+ staleTime: Infinity,
+ }
+ );
+
+ const validateRoles = (roles) => {
+ return roles && roles.length > 0
+ ? undefined
+ : 'Il faut cocher au moins un rôle';
+ };
+
+ return (
+ (typeof v === 'string' ? v.split(' ; ') : v)}
+ choices={
+ roles &&
+ roles.map((role) => ({
+ id: role,
+ name: capitalize(role.replace(/s$/, '')),
+ }))
+ }
+ options={{
+ checkedIcon: ,
+ icon: ,
+ }}
+ validate={[validateRoles]}
+ />
+ );
+};
+
+export default RolesRadioButtons;
diff --git a/administration/src/components/utilisateurs/UtilisateurCreateForm.tsx b/administration/src/components/utilisateurs/UtilisateurCreateForm.tsx
new file mode 100644
index 0000000..7d0bacc
--- /dev/null
+++ b/administration/src/components/utilisateurs/UtilisateurCreateForm.tsx
@@ -0,0 +1,92 @@
+/* eslint-disable */
+import { useEffect } from 'react';
+import { TextInput, required, email, BooleanInput } from 'react-admin';
+import { CardContent, Box } from '@material-ui/core';
+import { useFormState } from 'react-final-form';
+
+import FinanceurDropDown from '../common/FinanceurDropDown';
+import CompteMessages from '../../utils/Compte/fr.json';
+import CommunauteCheckBox from './CommunauteCheckBox';
+import RolesRadioButtons from './RolesRadioButtons';
+import { checkNamesLength } from '../../utils/checkNamesLength';
+
+const UtilisateurCreateForm = (save, record) => {
+ const { values } = useFormState();
+
+ useEffect(() => {
+ !values.hasManualAffiliation
+ ? delete values?.canReceiveAffiliationMail
+ : null;
+ }, [values]);
+
+ const Spacer = () => ;
+
+ const checkPatternEmail = (email) => {
+ if (
+ email &&
+ values.emailFormat &&
+ values.emailFormat.some((elt: string) => email.endsWith(elt)) !== true
+ ) {
+ return CompteMessages['email.error.emailFormat'];
+ }
+ return undefined;
+ };
+
+ const validateEmail = email();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {values?.hasManualAffiliation && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default UtilisateurCreateForm;
diff --git a/administration/src/components/utilisateurs/UtilisateurEdit.tsx b/administration/src/components/utilisateurs/UtilisateurEdit.tsx
new file mode 100644
index 0000000..ceb702b
--- /dev/null
+++ b/administration/src/components/utilisateurs/UtilisateurEdit.tsx
@@ -0,0 +1,49 @@
+/* eslint-disable */
+import { FC } from 'react';
+import {
+ Edit,
+ EditProps,
+ SimpleForm,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+import UtilisateurEditForm from './UtilisateurEditForm';
+import { errorFetching } from '../../utils/constant';
+
+const UtilisateurEdit: FC = (props) => {
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onSuccess = () => {
+ notify(`Le profil de l'utilisateur est modifié`, 'success');
+ redirect('/utilisateurs');
+ refresh();
+ };
+
+ const onFailure = () => {
+ notify(errorFetching.messageToDisplay, 'error');
+ };
+
+ return (
+ {
+ delete data.emailFormat;
+ delete data.hasManualAffiliation;
+ return data;
+ }}
+ {...props}
+ >
+
+
+
+
+ );
+};
+
+export default UtilisateurEdit;
diff --git a/administration/src/components/utilisateurs/UtilisateurEditForm.tsx b/administration/src/components/utilisateurs/UtilisateurEditForm.tsx
new file mode 100644
index 0000000..c9f5144
--- /dev/null
+++ b/administration/src/components/utilisateurs/UtilisateurEditForm.tsx
@@ -0,0 +1,71 @@
+/* eslint-disable */
+import { TextInput, required, BooleanInput } from 'react-admin';
+import { CardContent, Box } from '@material-ui/core';
+import { useFormState } from 'react-final-form';
+
+import FinanceurDropDown from '../common/FinanceurDropDown';
+import CommunauteCheckBox from './CommunauteCheckBox';
+import RolesRadioButtons from './RolesRadioButtons';
+import { checkNamesLength } from '../../utils/checkNamesLength';
+
+import '../styles/DynamicForm.css';
+
+const UtilisateurEditForm = () => {
+ const Spacer = () => ;
+
+ const { values } = useFormState();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {values?.hasManualAffiliation && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default UtilisateurEditForm;
diff --git a/administration/src/components/utilisateurs/UtilisateurForm.tsx b/administration/src/components/utilisateurs/UtilisateurForm.tsx
new file mode 100644
index 0000000..e9d7eb9
--- /dev/null
+++ b/administration/src/components/utilisateurs/UtilisateurForm.tsx
@@ -0,0 +1,57 @@
+/* eslint-disable */
+import {
+ Create,
+ SimpleForm,
+ useCreateContext,
+ useNotify,
+ useRedirect,
+ useRefresh,
+} from 'react-admin';
+
+import UtilisateurCreateForm from './UtilisateurCreateForm';
+import UtilisateurMessages from '../../utils/Utilisateur/fr.json';
+import CompteMessages from '../../utils/Compte/fr.json';
+import { errorFetching } from '../../utils/constant';
+
+const UtilisateurForm = (props) => {
+ const { save, record } = useCreateContext();
+
+ const notify = useNotify();
+ const refresh = useRefresh();
+ const redirect = useRedirect();
+
+ const onFailure = ({ message }) => {
+ const result =
+ message !== errorFetching.messageApi
+ ? UtilisateurMessages[message]
+ ? UtilisateurMessages[message]
+ : CompteMessages[message]
+ : errorFetching.messageToDisplay;
+ notify(result, 'error');
+ };
+
+ const onSuccess = ({ data }) => {
+ notify(`Création de l'utilisateur ${data.email} avec succès`, 'success');
+ redirect('/utilisateurs');
+ refresh();
+ };
+
+ return (
+ {
+ delete data.emailFormat;
+ delete data.hasManualAffiliation;
+ return data;
+ }}
+ >
+
+
+
+
+ );
+};
+export default UtilisateurForm;
diff --git a/administration/src/components/utilisateurs/UtilisateurList.tsx b/administration/src/components/utilisateurs/UtilisateurList.tsx
new file mode 100644
index 0000000..440f8b3
--- /dev/null
+++ b/administration/src/components/utilisateurs/UtilisateurList.tsx
@@ -0,0 +1,56 @@
+/* eslint-disable */
+import { FC } from 'react';
+import {
+ List,
+ Datagrid,
+ TextField,
+ Filter,
+ SearchInput,
+ EditButton,
+ DeleteButton,
+ BooleanField,
+} from 'react-admin';
+
+const UsersFilter = (props) => (
+
+
+
+);
+
+const UtilisateurList: FC = (props) => {
+ return (
+
}
+ sort={{ field: 'lastName', order: 'ASC' }}
+ exporter={false}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default UtilisateurList;
diff --git a/administration/src/components/utilisateurs/index.tsx b/administration/src/components/utilisateurs/index.tsx
new file mode 100644
index 0000000..7925dbc
--- /dev/null
+++ b/administration/src/components/utilisateurs/index.tsx
@@ -0,0 +1,10 @@
+/* eslint-disable */
+import UtilisateurForm from './UtilisateurForm';
+import UtilisateurList from './UtilisateurList';
+import UtilisateurEdit from './UtilisateurEdit';
+
+export default {
+ create: UtilisateurForm,
+ list: UtilisateurList,
+ edit: UtilisateurEdit,
+};
diff --git a/administration/src/index.css b/administration/src/index.css
new file mode 100644
index 0000000..ec2585e
--- /dev/null
+++ b/administration/src/index.css
@@ -0,0 +1,13 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
diff --git a/administration/src/index.tsx b/administration/src/index.tsx
new file mode 100644
index 0000000..395b749
--- /dev/null
+++ b/administration/src/index.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import './index.css';
+import App from './App';
+
+ReactDOM.render(, document.getElementById('root'));
diff --git a/administration/src/modules/Auth/authProvider.ts b/administration/src/modules/Auth/authProvider.ts
new file mode 100644
index 0000000..7162610
--- /dev/null
+++ b/administration/src/modules/Auth/authProvider.ts
@@ -0,0 +1,6 @@
+/* eslint-disable */
+const AuthProvider = async () => {
+ return Promise.resolve();
+};
+
+export default AuthProvider;
diff --git a/administration/src/react-app-env.d.ts b/administration/src/react-app-env.d.ts
new file mode 100644
index 0000000..6431bc5
--- /dev/null
+++ b/administration/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/administration/src/setupTests.ts b/administration/src/setupTests.ts
new file mode 100644
index 0000000..8f2609b
--- /dev/null
+++ b/administration/src/setupTests.ts
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
diff --git a/administration/src/users.tsx b/administration/src/users.tsx
new file mode 100644
index 0000000..b3801d3
--- /dev/null
+++ b/administration/src/users.tsx
@@ -0,0 +1,29 @@
+/* eslint-disable */
+import * as React from 'react';
+import { Theme, useMediaQuery } from '@material-ui/core';
+import { SimpleList, List, Datagrid, EmailField, TextField } from 'react-admin';
+
+const UserList = (props) => {
+ const isSmall = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'));
+
+ return (
+
+ {isSmall ? (
+ record.name}
+ secondaryText={(record) => record.username}
+ tertiaryText={(record) => record.email}
+ />
+ ) : (
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default UserList;
diff --git a/administration/src/utils/Aide/formHelper.ts b/administration/src/utils/Aide/formHelper.ts
new file mode 100644
index 0000000..28e3ad4
--- /dev/null
+++ b/administration/src/utils/Aide/formHelper.ts
@@ -0,0 +1,7 @@
+/* eslint-disable */
+import { regex } from 'react-admin';
+
+export const validateUrl = regex(
+ /^(?:http(s)?:\/\/)[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:%/?#[\]@!\$&'\(\)\*\+,;=.]+$/,
+ 'Entrez une url valide'
+);
diff --git a/administration/src/utils/Aide/fr.json b/administration/src/utils/Aide/fr.json
new file mode 100644
index 0000000..adb0658
--- /dev/null
+++ b/administration/src/utils/Aide/fr.json
@@ -0,0 +1,13 @@
+{
+ "incentives.error.required": "Attention, ce champ est obligatoire",
+ "incentives.error.title.minLength": "Le nom de l'aide doit faire plus de 3 caractères",
+ "incentives.error.validityDate.format": "La durée de validité doit être au format JJ/MM/AAAA",
+ "incentives.error.validityDate.minDate": "La durée de validité doit être supérieure à la date d'aujourd'hui",
+ "incentives.error.fundername.enterprise.notExist": "L'Entrepise renseignée n'existe pas",
+ "incentives.error.title.alreadyUsedForFunder": "Ce titre d'aide est déjà utilisé pour ce financeur",
+ "aides.edit.success": "L'aide \"{aidTitle}\" a été modifiée avec succès",
+ "aides.edit.title": "Modification d'une aide à la mobilité",
+ "aides.create.success": "L'aide \"{aidTitle}\" a été créée avec succès",
+ "aides.create.title": "Création d'une aide à la mobilité",
+ "territory.name.error.unique": "Attention, le nom de territoire est déjà utilisé. Veuillez le vérifier sur votre liste."
+}
diff --git a/administration/src/utils/Aide/fr.json.d.ts b/administration/src/utils/Aide/fr.json.d.ts
new file mode 100644
index 0000000..e643e33
--- /dev/null
+++ b/administration/src/utils/Aide/fr.json.d.ts
@@ -0,0 +1,14 @@
+interface Fr {
+ 'incentives.error.required': string;
+ 'incentives.error.title.minLength': string;
+ 'incentives.error.validityDate.format': string;
+ 'incentives.error.validityDate.minDate': string;
+ 'incentives.error.fundername.enterprise.notExist': string;
+ 'incentives.error.title.alreadyUsedForFunder': string;
+ 'aides.edit.success': string;
+ 'aides.edit.title': string;
+ 'aides.create.success': string;
+ 'aides.create.title': string;
+}
+declare const value: Fr;
+export = value;
diff --git a/administration/src/utils/Aide/validityDateRules.ts b/administration/src/utils/Aide/validityDateRules.ts
new file mode 100644
index 0000000..f3b32e8
--- /dev/null
+++ b/administration/src/utils/Aide/validityDateRules.ts
@@ -0,0 +1,19 @@
+/* eslint-disable */
+import { convertDateToString } from '../convertDateToString';
+import AidesMessages from './fr.json';
+
+// display only date to select to input validityDate
+export const startDateMin = () => {
+ var someDate = new Date();
+ return convertDateToString(
+ new Date(someDate.setDate(someDate.getDate() + 1))
+ );
+};
+
+// add validation to input validityDate
+export const dateMinValidation = (value) => {
+ if (value < startDateMin()) {
+ return AidesMessages['incentives.error.validityDate.minDate'];
+ }
+ return undefined;
+};
diff --git a/administration/src/utils/Collectivite/fr.json b/administration/src/utils/Collectivite/fr.json
new file mode 100644
index 0000000..e4cd29a
--- /dev/null
+++ b/administration/src/utils/Collectivite/fr.json
@@ -0,0 +1,5 @@
+{
+ "collectivities.error.required": "Attention, ce champ est obligatoire",
+ "collectivities.error.name.required": "Merci de renseigner le nom de la collectivité",
+ "collectivités.error.name.unique": "Attention, ce nom de collectivité est déjà utilisé"
+}
diff --git a/administration/src/utils/Collectivite/fr.json.d.ts b/administration/src/utils/Collectivite/fr.json.d.ts
new file mode 100644
index 0000000..ffe626e
--- /dev/null
+++ b/administration/src/utils/Collectivite/fr.json.d.ts
@@ -0,0 +1,12 @@
+interface Fr {
+ 'collectivities.error.required': string;
+ 'collectivities.error.name.required': string;
+ 'collectivités.error.name.unique': string;
+ 'collectivities.error.firstName': string;
+ 'collectivities.error.lastName': string;
+ 'collectivities.error.email': string;
+ 'collectivities.error.email.format': string;
+ 'collectivities.error.email.unique': string;
+}
+declare const value: Fr;
+export = value;
diff --git a/administration/src/utils/Communaute/fr.json b/administration/src/utils/Communaute/fr.json
new file mode 100644
index 0000000..cc4425f
--- /dev/null
+++ b/administration/src/utils/Communaute/fr.json
@@ -0,0 +1,5 @@
+{
+ "communautés.error": "Problème durant la récupération des communautés",
+ "communities.error.name.required": "Merci de renseigner le nom de la communauté",
+ "communities.error.name.unique": "Attention, ce nom de communauté est déjà utilisé"
+}
diff --git a/administration/src/utils/Compte/fr.json b/administration/src/utils/Compte/fr.json
new file mode 100644
index 0000000..8c1a7f2
--- /dev/null
+++ b/administration/src/utils/Compte/fr.json
@@ -0,0 +1,5 @@
+{
+ "email.error.unique": "Attention, cette adresse mail est déjà utilisée par une autre personne",
+ "email.error.format": "Attention, le format de votre email est incorrect",
+ "email.error.emailFormat": "Attention, le format de votre email n'est pas adéquat avec le format d'adresse de votre employeur"
+}
diff --git a/administration/src/utils/Entreprise/fr.json b/administration/src/utils/Entreprise/fr.json
new file mode 100644
index 0000000..cacb5f2
--- /dev/null
+++ b/administration/src/utils/Entreprise/fr.json
@@ -0,0 +1,6 @@
+{
+ "enterprises.error.required": "Attention, ce champ est obligatoire",
+ "enterprises.error.name.required": "Merci de renseigner le nom d'entreprise",
+ "enterprises.error.name.unique": "Attention, ce nom d'entreprise est déjà utilisé",
+ "enterprises.error.emailFormat.type": "Attention, un format d'email est invalide (@exemple.com)"
+}
diff --git a/administration/src/utils/Entreprise/fr.json.d.ts b/administration/src/utils/Entreprise/fr.json.d.ts
new file mode 100644
index 0000000..2e9222f
--- /dev/null
+++ b/administration/src/utils/Entreprise/fr.json.d.ts
@@ -0,0 +1,15 @@
+interface Fr {
+ 'enterprises.error.required': string;
+ 'enterprises.error.name.required': string;
+ 'enterprises.error.name.unique': string;
+ 'enterprises.error.email.required': string;
+ 'enterprises.error.firstName': string;
+ 'enterprises.error.lastName': string;
+ 'enterprises.error.email': string;
+ 'enterprises.error.email.format': string;
+ 'enterprises.error.emailFormat.type': string;
+ 'enterprises.error.email.unique': string;
+}
+
+declare const value: Fr;
+export = value;
diff --git a/administration/src/utils/Financeur/fr.json b/administration/src/utils/Financeur/fr.json
new file mode 100644
index 0000000..32c653e
--- /dev/null
+++ b/administration/src/utils/Financeur/fr.json
@@ -0,0 +1,4 @@
+{
+ "funders.error": "Problème durant la récupération des financeurs",
+ "funders.error.clients.list": "Problème durant la récupération des clients"
+}
diff --git a/administration/src/utils/Territory/fr.json b/administration/src/utils/Territory/fr.json
new file mode 100644
index 0000000..0dad841
--- /dev/null
+++ b/administration/src/utils/Territory/fr.json
@@ -0,0 +1,4 @@
+{
+ "territory.name.error.unique": "Attention, ce nom de territoire est déjà utilisé. Veuillez le vérifier sur votre liste.",
+ "territory.error": "Problème durant la récupération des territoires"
+}
diff --git a/administration/src/utils/Utilisateur/fr.json b/administration/src/utils/Utilisateur/fr.json
new file mode 100644
index 0000000..489b6e4
--- /dev/null
+++ b/administration/src/utils/Utilisateur/fr.json
@@ -0,0 +1,7 @@
+{
+ "citizens.error.required": "Attention, ce champ est obligatoire",
+ "users.roles.error": "Problème durant la récupération des roles",
+ "users.error.roles.mismatch": "Les roles selectionnés ne sont pas adéquats",
+ "users.error.communities.mismatch": "Les communautés selectionnées ne sont pas adéquates",
+ "email.error.emailFormat": "L'email ne respecte pas les formats de l'entreprise"
+}
diff --git a/administration/src/utils/checkNamesLength.ts b/administration/src/utils/checkNamesLength.ts
new file mode 100644
index 0000000..44f9b11
--- /dev/null
+++ b/administration/src/utils/checkNamesLength.ts
@@ -0,0 +1,6 @@
+/* eslint-disable */
+export const checkNamesLength = (name) => {
+ return name && name.length >= 2
+ ? undefined
+ : 'Ce champ doit faire au moins 2 caractères';
+};
diff --git a/administration/src/utils/constant.ts b/administration/src/utils/constant.ts
new file mode 100644
index 0000000..dc0d398
--- /dev/null
+++ b/administration/src/utils/constant.ts
@@ -0,0 +1,73 @@
+/* eslint-disable */
+
+export const API_VERSION = 'v1';
+
+export async function URL_API() {
+ const response = await (await fetch('/keycloak.json')).json();
+ let API_FQDN = response.apiConfig;
+ return API_FQDN + API_VERSION;
+}
+
+export const TRANSPORT_CHOICE = [
+ { id: 'transportsCommun', name: 'Transports en commun' },
+ { id: 'velo', name: 'Vélo' },
+ { id: 'voiture', name: 'Voiture' },
+ { id: 'libreService', name: '2 ou 3 roues en libre-service' },
+ { id: 'electrique', name: '2 ou 3 roues électrique' },
+ { id: 'autopartage', name: 'Autopartage' },
+ { id: 'covoiturage', name: 'Covoiturage' },
+];
+
+export const NATIONAL_INCENTIVE = 'AideNationale';
+export const TERRITORY_INCENTIVE = 'AideTerritoire';
+export const EMPLOYER_INCENTIVE = 'AideEmployeur';
+
+export const INCENTIVE_TYPE_CHOICE = [
+ { id: NATIONAL_INCENTIVE, name: 'Aide nationale' },
+ { id: TERRITORY_INCENTIVE, name: 'Aide de mon territoire' },
+ { id: EMPLOYER_INCENTIVE, name: 'Aide de mon employeur' },
+];
+
+export const MAPPING_FUNDER_TYPE = {
+ [NATIONAL_INCENTIVE]: 'nationale',
+ [TERRITORY_INCENTIVE]: 'collectivité',
+ [EMPLOYER_INCENTIVE]: 'entreprise',
+};
+
+export const PROOF_CHOICE = [
+ { id: 'identite', name: "Pièce d'Identité" },
+ {
+ id: 'justificatifDomicile',
+ name: 'Justificatif de domicile de moins de 3 mois',
+ },
+ { id: 'certificatMedical', name: 'Certificat médical' },
+ { id: 'rib', name: 'RIB' },
+ { id: 'attestationHonneur', name: "Attestation sur l'Honneur" },
+ { id: 'factureAchat', name: "Facture d'achat" },
+ { id: 'certificatImmatriculation', name: "Certificat d'immatriculation" },
+ { id: 'justificatifEmancipation', name: "Justificatif d'émancipation" },
+ { id: 'impositionRevenu', name: "Dernier avis d'imposition sur le revenu" },
+ {
+ id: 'situationPoleEmploi',
+ name: 'Dernier relevé de situation Pôle Emploi',
+ },
+ { id: 'certificatScolarite', name: 'Certificat de Scolarité' },
+];
+
+export const INPUT_FORMAT_CHOICE = [
+ { id: 'Texte', name: 'Texte' },
+ { id: 'Date', name: 'Date' },
+ { id: 'Numerique', name: 'Numérique' },
+ { id: 'listeChoix', name: 'Sélection parmi une liste de choix' },
+];
+
+export const errorFetching = {
+ messageApi: 'Failed to fetch',
+ messageToDisplay:
+ "Il semble qu'il y ait un problème, votre requête n'a pas pu aboutir. Merci de réessayer ultérieurement.",
+};
+
+export const ROLES = {
+ gestionnaires: 'gestionnaires',
+ superviseurs: 'superviseurs',
+};
diff --git a/administration/src/utils/convertDateToString.ts b/administration/src/utils/convertDateToString.ts
new file mode 100644
index 0000000..34f1056
--- /dev/null
+++ b/administration/src/utils/convertDateToString.ts
@@ -0,0 +1,40 @@
+/* eslint-disable */
+/**
+ * Convert Date object to String
+ *
+ * @param {Date} value value to convert
+ * @returns {String} A standardized date (yyyy-MM-dd), to be passed to an
+ */
+export const convertDateToString = (value: Date) => {
+ if (!(value instanceof Date) || isNaN(value.getDate())) return '';
+ const pad = '00';
+ const yyyy = value.getFullYear().toString();
+ const MM = (value.getMonth() + 1).toString();
+ const dd = value.getDate().toString();
+ return `${yyyy}-${(pad + MM).slice(-2)}-${(pad + dd).slice(-2)}`;
+};
+
+export const getDate = (value: string | Date) => {
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ // null, undefined and empty string values should not go through dateFormatter
+ // otherwise, it returns undefined and will make the input an uncontrolled one.
+ if (value == null || value === '') {
+ return undefined;
+ }
+
+ if (value instanceof Date) {
+ return convertDateToString(value);
+ }
+
+ // valid dates should not be converted
+ if (dateRegex.test(value)) {
+ return value;
+ }
+ return convertDateToString(new Date(value));
+};
+
+export const startDateMin = () => {
+ return convertDateToString(
+ new Date(new Date().setDate(new Date().getDate() + 1))
+ );
+};
diff --git a/administration/src/utils/helpers.ts b/administration/src/utils/helpers.ts
new file mode 100644
index 0000000..c89933d
--- /dev/null
+++ b/administration/src/utils/helpers.ts
@@ -0,0 +1,20 @@
+/* eslint-disable */
+export const isEmailFormatValid = (email) => {
+ const regex = new RegExp(
+ /^@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
+ );
+ return email.match(regex);
+};
+
+/**
+ * Returns the string with white spaces removed
+ */
+export const removeWhiteSpace = (word: string): string => {
+ /**
+ * Regex for removing white spaces.
+ * Exemple : " Removing white spaces " returns "Removing white spaces".
+ */
+ const removeSpacesRegex: RegExp = new RegExp('^\\s+|\\s+$|\\s+(?=\\s)', 'g');
+ const newWord: string = word.replace(removeSpacesRegex, '');
+ return newWord;
+};
diff --git a/administration/src/utils/httpHeaders.ts b/administration/src/utils/httpHeaders.ts
new file mode 100644
index 0000000..50b39cf
--- /dev/null
+++ b/administration/src/utils/httpHeaders.ts
@@ -0,0 +1,14 @@
+/* eslint-disable */
+export const getAuthHeader = (): any => {
+ try {
+ let authHeader = null;
+ const token = localStorage.getItem('token');
+
+ if (token != null) {
+ authHeader = { Authorization: `Bearer ${token}` };
+ }
+ return authHeader;
+ } catch (error) {
+ throw new Error(error);
+ }
+};
diff --git a/administration/tsconfig.json b/administration/tsconfig.json
new file mode 100644
index 0000000..1785ea1
--- /dev/null
+++ b/administration/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": false,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["./src/**/*"]
+}
diff --git a/analytics/.gitignore b/analytics/.gitignore
new file mode 100644
index 0000000..4cf73b6
--- /dev/null
+++ b/analytics/.gitignore
@@ -0,0 +1,2 @@
+# MCM temporary build assets
+init.sql
\ No newline at end of file
diff --git a/analytics/.gitlab-ci.yml b/analytics/.gitlab-ci.yml
new file mode 100644
index 0000000..310455e
--- /dev/null
+++ b/analytics/.gitlab-ci.yml
@@ -0,0 +1,19 @@
+include:
+ - local: "analytics/.gitlab-ci/preview.yml"
+ rules:
+ - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule"
+ - local: "analytics/.gitlab-ci/testing.yml"
+ rules:
+ - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger"
+
+.analytics-base:
+ variables:
+ MODULE_NAME: analytics
+ MODULE_PATH: ${MODULE_NAME}
+ MATOMO_IMAGE_NAME: ${NEXUS_DOCKER_REPOSITORY_URL}/bitnami/matomo:4.8.0
+ NEXUS_IMAGE_MARIADB: ${NEXUS_DOCKER_REPOSITORY_URL}/mariadb:10.6
+ only:
+ changes:
+ - "*"
+ - "commons/**/*"
+ - "analytics/**/*"
diff --git a/analytics/.gitlab-ci/preview.yml b/analytics/.gitlab-ci/preview.yml
new file mode 100644
index 0000000..b53146e
--- /dev/null
+++ b/analytics/.gitlab-ci/preview.yml
@@ -0,0 +1,12 @@
+analytics_preview_deploy:
+ extends:
+ - .preview-deploy-job
+ - .analytics-base
+ - .manual
+ environment:
+ on_stop: analytics_preview_cleanup
+
+analytics_preview_cleanup:
+ extends:
+ - .commons_preview_cleanup
+ - .analytics-base
diff --git a/analytics/.gitlab-ci/testing.yml b/analytics/.gitlab-ci/testing.yml
new file mode 100644
index 0000000..31acfb6
--- /dev/null
+++ b/analytics/.gitlab-ci/testing.yml
@@ -0,0 +1,4 @@
+analytics_testing_deploy:
+ extends:
+ - .testing-deploy-job
+ - .analytics-base
diff --git a/analytics/Chart.yaml b/analytics/Chart.yaml
new file mode 100644
index 0000000..c3dcb0d
--- /dev/null
+++ b/analytics/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: 1.0.0
+description: A Helm chart for Kubernetes
+name: ${MODULE_PATH}
+type: application
+version: 1.0.0
diff --git a/analytics/README.md b/analytics/README.md
new file mode 100644
index 0000000..09ad9f0
--- /dev/null
+++ b/analytics/README.md
@@ -0,0 +1,33 @@
+# Description
+
+Le service analytics se base sur la brique logicielle **[Matomo](https://matomo.org/matomo-analytics-the-google-analytics-alternative-that-protects-your-data-variation/)**
+
+Elle nous permet de collecter les données utilisateur sur certaines pages tout en étant RGPD-compliant et ainsi de remonter des métriques d'audiences à des administrateurs fonctionnels.
+
+# Installation en local
+
+Pas d'installation prévue en local pour ce service
+
+## URL / Port
+
+Pas d'installation prévue en local pour ce service
+
+# Précisions pipelines
+
+## Preview
+
+Pas de précisions nécéssaires pour ce service
+
+## Testing
+
+Sur cet environnement, la bdd maria est hébergée sur Azure. Le paramétrage de la bdd est donc à faire en amont.
+
+# Relation avec les autres services
+
+Comme présenté dans le [schéma d'architecture détaillée](docs/assets/MOB-CME_Archi_technique_detaillee.png), les informations désignées pour le tracking sont envoyées à analytics en continu suite à la fréquentation et l'utilisation des services _website_ de la plateforme.
+
+Seulement certaines actions & pages sont trackées au niveau de website et KC.
+
+# Tests Unitaires
+
+Pas de tests unitaires nécéssaires pour ce service
diff --git a/analytics/analytics-testing-values.yaml b/analytics/analytics-testing-values.yaml
new file mode 100644
index 0000000..772906c
--- /dev/null
+++ b/analytics/analytics-testing-values.yaml
@@ -0,0 +1,151 @@
+services:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ creationTimestamp: null
+ labels:
+ io.kompose.service: analytics
+ name: analytics
+ spec:
+ ports:
+ - name: "8082"
+ port: 8082
+ targetPort: 8080
+ selector:
+ io.kompose.service: analytics
+ type: ClusterIP
+ status:
+ loadBalancer: {}
+
+persistentVolumeClaim:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ creationTimestamp: null
+ labels:
+ io.kompose.service: matomo-data
+ name: matomo-data
+ spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 100Mi
+ status: {}
+
+deployments:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ labels:
+ io.kompose.service: analytics
+ name: analytics
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ io.kompose.service: analytics
+ strategy:
+ type: Recreate
+ template:
+ metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ labels:
+ io.kompose.network/maria-matomo-nw: "true"
+ io.kompose.network/web-nw: "true"
+ io.kompose.service: analytics
+ spec:
+ containers:
+ env:
+ MATOMO_DATABASE_HOST: ${TESTING_MARIADB_SERVICE_NAME}
+ MATOMO_DATABASE_NAME: matomo_db
+ MATOMO_DATABASE_PASSWORD: ${TESTING_ANALYTICS_DB_DEV_PASSWORD}
+ MATOMO_DATABASE_PORT_NUMBER: 3306
+ MATOMO_DATABASE_USER: ${TESTING_ANALYTICS_DB_DEV_USER}
+ MATOMO_EMAIL: ${TESTING_ANALYTICS_SUPER_EMAIL}
+ MATOMO_PASSWORD: ${TESTING_ANALYTICS_SUPER_PASSWORD}
+ MATOMO_USERNAME: ${TESTING_ANALYTICS_SUPER_USER}
+ MATOMO_WEBSITE_HOST: ${WEBSITE_FQDN}
+ MATOMO_WEBSITE_NAME: ${WEBSITE_FQDN}
+ image: ${MATOMO_IMAGE_NAME}
+ name: analytics
+ ports:
+ - containerPort: 8080
+ resources: {}
+ volumeMounts:
+ - mountPath: /bitnami/matomo
+ name: matomo-data
+ imagePullSecrets:
+ - name: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ restartPolicy: Always
+ securityContext:
+ fsGroup: 1000
+ volumes:
+ - name: matomo-data
+ persistentVolumeClaim:
+ claimName: matomo-data
+ status: {}
+
+networkPolicies:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: web-nw
+ spec:
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ com.capgemini.mcm.ingress: "true"
+ podSelector:
+ matchLabels:
+ io.kompose.network/web-nw: "true"
+
+ingressRoutes:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: analytics
+ spec:
+ entryPoints:
+ - web
+ routes:
+ - kind: Rule
+ match: Host(`${MATOMO_FQDN}`)
+ middlewares:
+ - name: analytics-headers
+ services:
+ - kind: Service
+ name: analytics
+ port: 8082
+
+middlewares:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: analytics-headers
+ spec:
+ headers:
+ customRequestHeaders:
+ X-Forwarded-Proto: https
diff --git a/analytics/kompose.yml b/analytics/kompose.yml
new file mode 100644
index 0000000..54be8c8
--- /dev/null
+++ b/analytics/kompose.yml
@@ -0,0 +1,56 @@
+version: "3"
+
+services:
+ mariadb:
+ image: ${NEXUS_IMAGE_MARIADB}
+ command: --max_allowed_packet=67108864 # Set max_allowed_packet to 64MB (default value is 16MB and Matomo need of 64MB min for performed)
+ environment:
+ - MYSQL_USER=${ANALYTICS_DB_DEV_USER}
+ - MYSQL_PASSWORD=${ANALYTICS_DB_DEV_PASSWORD}
+ - MYSQL_DATABASE=matomo_db
+ - MYSQL_ROOT_PASSWORD=${ANALYTICS_DB_ROOT_PASSWORD}
+ volumes:
+ - mariadb_data:/var/lib/mysql
+ networks:
+ - maria-matomo-nw
+ ports:
+ - "3006:3306"
+ labels:
+ - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}"
+ - "kompose.service.type=clusterip"
+ - "traefik.enable=false"
+ # - "kompose.volume.size=500Mi"
+
+ analytics:
+ image: ${MATOMO_IMAGE_NAME}
+ environment:
+ - MATOMO_USERNAME=${ANALYTICS_SUPER_USER}
+ - MATOMO_PASSWORD=${ANALYTICS_SUPER_PASSWORD}
+ - MATOMO_EMAIL=${ANALYTICS_SUPER_EMAIL}
+ - MATOMO_DATABASE_PORT_NUMBER=3006
+ - MATOMO_DATABASE_HOST=mariadb
+ - MATOMO_DATABASE_USER=${ANALYTICS_DB_DEV_USER}
+ - MATOMO_DATABASE_PASSWORD=${ANALYTICS_DB_DEV_PASSWORD}
+ - MATOMO_DATABASE_NAME=matomo_db
+ - MATOMO_WEBSITE_NAME=${WEBSITE_FQDN}
+ - MATOMO_WEBSITE_HOST=${WEBSITE_FQDN}
+ volumes:
+ - matomo_data:/bitnami/matomo
+ depends_on:
+ - mariadb
+ networks:
+ - maria-matomo-nw
+ - web-nw
+ ports:
+ - "8082:8080"
+ labels:
+ - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}"
+ - "kompose.service.type=clusterip"
+
+volumes:
+ mariadb_data:
+ matomo_data:
+
+networks:
+ web-nw:
+ maria-matomo-nw:
diff --git a/analytics/overlays/analytics-certificate.yml b/analytics/overlays/analytics-certificate.yml
new file mode 100644
index 0000000..7777315
--- /dev/null
+++ b/analytics/overlays/analytics-certificate.yml
@@ -0,0 +1,12 @@
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: analytics-cert
+spec:
+ dnsNames:
+ - "*.${landscape_subdomain}"
+ issuerRef:
+ group: cert-manager.io
+ kind: ClusterIssuer
+ name: ${CLUSTER_ISSUER}
+ secretName: ${SECRET_NAME}
diff --git a/analytics/overlays/analytics-ingressroute.yml b/analytics/overlays/analytics-ingressroute.yml
new file mode 100644
index 0000000..5829e7e
--- /dev/null
+++ b/analytics/overlays/analytics-ingressroute.yml
@@ -0,0 +1,26 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: IngressRoute
+metadata:
+ name: analytics
+ annotations:
+ kubernetes.io/ingress.class: traefik
+spec:
+ entryPoints:
+ - web
+ # - websecure
+ routes:
+ - match: Host(`${MATOMO_FQDN}`)
+ kind: Rule
+ middlewares:
+ - name: analytics-headers
+ services:
+ - kind: Service
+ name: analytics
+ port: 8082
+ # tls:
+ # secretName: ${SECRET_NAME} # analytics-tls # cert-dev
+ # domains:
+ # - main: ${BASE_DOMAIN}
+ # sans:
+ # - "*.preview.${BASE_DOMAIN}"
+ # - "*.testing.${BASE_DOMAIN}"
diff --git a/analytics/overlays/analytics-middleware.yml b/analytics/overlays/analytics-middleware.yml
new file mode 100644
index 0000000..5bcb66e
--- /dev/null
+++ b/analytics/overlays/analytics-middleware.yml
@@ -0,0 +1,8 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: Middleware
+metadata:
+ name: analytics-headers
+spec:
+ headers:
+ customRequestHeaders:
+ X-Forwarded-Proto: "https"
diff --git a/analytics/overlays/analytics_configmap.yml b/analytics/overlays/analytics_configmap.yml
new file mode 100644
index 0000000..0281745
--- /dev/null
+++ b/analytics/overlays/analytics_configmap.yml
@@ -0,0 +1,18 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: mariadb
+spec:
+ template:
+ spec:
+ containers:
+ - name: mariadb
+ volumeMounts:
+ - name: dump-db
+ mountPath: /docker-entrypoint-initdb.d/mcm-matomo-dump.sql
+ securityContext:
+ fsGroup: 1000
+ volumes:
+ - name: dump-db
+ configMap:
+ name: analytics-dump-db
diff --git a/analytics/overlays/analytics_deployment_set_fsgroup.yml b/analytics/overlays/analytics_deployment_set_fsgroup.yml
new file mode 100644
index 0000000..10b471b
--- /dev/null
+++ b/analytics/overlays/analytics_deployment_set_fsgroup.yml
@@ -0,0 +1,9 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: analytics
+spec:
+ template:
+ spec:
+ securityContext:
+ fsGroup: 1000
diff --git a/analytics/overlays/kustomization.yaml b/analytics/overlays/kustomization.yaml
new file mode 100644
index 0000000..9066fb4
--- /dev/null
+++ b/analytics/overlays/kustomization.yaml
@@ -0,0 +1,19 @@
+commonAnnotations:
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ kubernetes.io/ingress.class: traefik
+
+resources:
+ - analytics-ingressroute.yml
+ - analytics-middleware.yml
+ # - analytics-certificate.yml
+
+patchesStrategicMerge:
+ - analytics_deployment_set_fsgroup.yml
+ - web_nw_networkpolicy_namespaceselector.yml
+# - analytics_configmap.yml
+
+# configMapGenerator:
+# - name: analytics-dump-db
+# files:
+# - config/mcm-matomo-dump.sql
diff --git a/analytics/overlays/web_nw_networkpolicy_namespaceselector.yml b/analytics/overlays/web_nw_networkpolicy_namespaceselector.yml
new file mode 100644
index 0000000..53e6193
--- /dev/null
+++ b/analytics/overlays/web_nw_networkpolicy_namespaceselector.yml
@@ -0,0 +1,10 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: web-nw
+spec:
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ com.capgemini.mcm.ingress: "true"
diff --git a/antivirus/.gitlab-ci.yml b/antivirus/.gitlab-ci.yml
new file mode 100644
index 0000000..12f5cad
--- /dev/null
+++ b/antivirus/.gitlab-ci.yml
@@ -0,0 +1,18 @@
+include:
+ - local: "antivirus/.gitlab-ci/preview.yml"
+ rules:
+ - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule"
+ - local: "antivirus/.gitlab-ci/testing.yml"
+ rules:
+ - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger"
+
+.antivirus-base:
+ variables:
+ MODULE_NAME: antivirus
+ MODULE_PATH: ${MODULE_NAME}
+ ANTIVIRUS_IMAGE_NAME: ${NEXUS_DOCKER_REGISTRY_URL}/clamav/clamav:stable
+ only:
+ changes:
+ - "*"
+ - "commons/**/*"
+ - "antivirus/**/*"
diff --git a/antivirus/.gitlab-ci/preview.yml b/antivirus/.gitlab-ci/preview.yml
new file mode 100644
index 0000000..59ba008
--- /dev/null
+++ b/antivirus/.gitlab-ci/preview.yml
@@ -0,0 +1,13 @@
+antivirus_preview_deploy:
+ extends:
+ - .preview-deploy-job
+ - .antivirus-base
+ - .only-master
+ environment:
+ on_stop: antivirus_preview_cleanup
+
+antivirus_preview_cleanup:
+ extends:
+ - .commons_preview_cleanup
+ - .antivirus-base
+ - .only-master
diff --git a/antivirus/.gitlab-ci/testing.yml b/antivirus/.gitlab-ci/testing.yml
new file mode 100644
index 0000000..0df42ab
--- /dev/null
+++ b/antivirus/.gitlab-ci/testing.yml
@@ -0,0 +1,4 @@
+antivirus_testing_deploy:
+ extends:
+ - .testing-deploy-job
+ - .antivirus-base
diff --git a/antivirus/Chart.yaml b/antivirus/Chart.yaml
new file mode 100644
index 0000000..c3dcb0d
--- /dev/null
+++ b/antivirus/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: 1.0.0
+description: A Helm chart for Kubernetes
+name: ${MODULE_PATH}
+type: application
+version: 1.0.0
diff --git a/antivirus/README.md b/antivirus/README.md
new file mode 100644
index 0000000..06bfa0e
--- /dev/null
+++ b/antivirus/README.md
@@ -0,0 +1,41 @@
+# Description
+
+Le service antivirus se base sur la brique logicielle **[Clamav](https://www.clamav.net/)**
+
+Elle permet de scanner les fichiers que moB peut recevoir lors de la souscription à une aide et ainsi de ne pas autoriser l'upload de fichiers vérolés dans le service de stockage.
+
+C'est la fonctionnalité INSTREAM de Clamav qui est utilisée pour scanner les fichiers.
+
+# Installation en local
+```sh
+docker run -d --name clamav -p 3310:3310 clamav/clamav:stable
+```
+## URL / Port
+- URL : localhost
+- Port : 3310
+
+# Précisions pipelines
+
+## Preview
+
+Pas de précisions nécéssaires pour ce service
+
+## Testing
+
+Pas de précisions nécéssaires pour ce service
+
+# Relation avec les autres services
+
+Comme présenté dans le [schéma d'architecture détaillée](docs/assets/MOB-CME_Archi_technique_detaillee.png), l'api effectue une requête TCP vers l'antivirus pour chaque fichier uploadé pour les souscriptions.
+
+L'api effectue une requête TCP via une fonction de scanStream permettant d'analyser le stream binaire des fichiers envoyés. Ainsi, ils ne sont pas stockés (vérolé ou non) dans l'antivirus.
+
+L'antivirus renvoie alors une réponse à l'api en précisant si le fichier est vérolé ou non.
+
+**Bilan des relations:**
+
+- Requête TCP de l'api vers l'antivirus
+
+# Tests Unitaires
+
+Pas de tests unitaires nécéssaires pour ce service
diff --git a/antivirus/antivirus-testing-values.yaml b/antivirus/antivirus-testing-values.yaml
new file mode 100644
index 0000000..8c95542
--- /dev/null
+++ b/antivirus/antivirus-testing-values.yaml
@@ -0,0 +1,65 @@
+services:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kompose.volume.size: 200Mi
+ creationTimestamp: null
+ labels:
+ io.kompose.service: clamav
+ name: clamav
+ spec:
+ ports:
+ - name: "3310"
+ port: 3310
+ targetPort: 3310
+ selector:
+ io.kompose.service: clamav
+ type: ClusterIP
+ status:
+ loadBalancer: {}
+
+deployments:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kompose.volume.size: 200Mi
+ creationTimestamp: null
+ labels:
+ io.kompose.service: clamav
+ name: clamav
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ io.kompose.service: clamav
+ strategy: {}
+ template:
+ metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ kompose.volume.size: 200Mi
+ kompose.service.type: clusterip
+ creationTimestamp: null
+ labels:
+ io.kompose.service: clamav
+ spec:
+ containers:
+ image: ${ANTIVIRUS_IMAGE_NAME}
+ name: clamav
+ ports:
+ - containerPort: 3310
+ resources:
+ requests:
+ memory: 4Gi
+ imagePullSecrets:
+ - name: ${PROXY_IMAGE_PULL_SECRET_NAME}
+ restartPolicy: Always
+ status: {}
\ No newline at end of file
diff --git a/antivirus/kompose.yml b/antivirus/kompose.yml
new file mode 100644
index 0000000..2783332
--- /dev/null
+++ b/antivirus/kompose.yml
@@ -0,0 +1,10 @@
+version: "3"
+
+services:
+ clamav:
+ image: ${ANTIVIRUS_IMAGE_NAME}
+ ports:
+ - "3310"
+ labels:
+ - "kompose.image-pull-secret=${PROXY_IMAGE_PULL_SECRET_NAME}"
+ - "kompose.service.type=clusterip"
diff --git a/api/.dockerignore b/api/.dockerignore
new file mode 100644
index 0000000..eb357c0
--- /dev/null
+++ b/api/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+npm-debug.log
+/dist
+# Cache used by TypeScript's incremental build
+*.tsbuildinfo
+// fix
\ No newline at end of file
diff --git a/api/.eslintignore b/api/.eslintignore
new file mode 100644
index 0000000..7b22c4a
--- /dev/null
+++ b/api/.eslintignore
@@ -0,0 +1,7 @@
+mongo/
+node_modules/
+dist/
+coverage/
+.eslintrc.js
+databaseConfig/*.js
+openapi-maas.js
diff --git a/api/.eslintrc.js b/api/.eslintrc.js
new file mode 100644
index 0000000..b4ae679
--- /dev/null
+++ b/api/.eslintrc.js
@@ -0,0 +1,103 @@
+module.exports = {
+ extends: '@loopback/eslint-config',
+ env: {
+ node: true,
+ mocha: true,
+ es6: true,
+ },
+ plugins: ['mocha'],
+ parserOptions: {
+ ecmaVersion: 2018,
+ sourceType: 'module', // fix
+ },
+ overrides: [
+ {
+ files: ['**/*.js', '**/*.ts'],
+ rules: {
+ '@typescript-eslint/no-unused-vars': 'off',
+ '@typescript-eslint/no-misused-promises': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/prefer-optional-chain': 'off',
+ '@typescript-eslint/prefer-nullish-coalescing': 'off',
+ },
+ },
+ ],
+ rules: {
+ 'comma-dangle': ['error', 'always-multiline'],
+ 'no-cond-assign': 'error',
+ 'no-console': 'off',
+ 'no-unused-expressions': 'error',
+ 'no-const-assign': 'error',
+ 'array-bracket-spacing': ['error', 'never'],
+ 'block-spacing': ['error', 'always'],
+ 'brace-style': ['error', '1tbs', {allowSingleLine: true}],
+ camelcase: 'off',
+ 'no-unused-expressions': 'off',
+ '@typescript-eslint/naming-convention': 'off',
+ '@typescript-eslint/no-shadow': 'off',
+ '@typescript-eslint/no-inferrable-types': 'off',
+ 'comma-spacing': ['error', {before: false, after: true}],
+ 'comma-style': ['error', 'last'],
+ 'computed-property-spacing': ['error', 'never'],
+ 'eol-last': ['error', 'unix'],
+ 'func-names': 0,
+ 'func-call-spacing': ['error', 'never'],
+ 'function-paren-newline': 'off',
+ 'key-spacing': ['error', {beforeColon: false, afterColon: true, mode: 'strict'}],
+ 'max-len': [
+ 'error',
+ 110,
+ 8,
+ {
+ ignoreComments: true,
+ ignoreUrls: true,
+ ignorePattern: '^\\s*var\\s.+=\\s*require\\s*\\(',
+ },
+ ],
+ 'mocha/handle-done-callback': 'error',
+ 'mocha/no-exclusive-tests': 'error',
+ 'mocha/no-identical-title': 'error',
+ 'mocha/no-nested-tests': 'error',
+ 'no-array-constructor': 2,
+ 'no-extra-semi': 'error',
+ 'no-multi-spaces': 'error',
+ 'no-multiple-empty-lines': ['error', {max: 1}],
+ 'no-redeclare': ['error'],
+ 'no-trailing-spaces': 2,
+ 'no-undef': 'error',
+ 'no-var': 'error',
+ 'object-curly-spacing': ['error', 'never'],
+ 'one-var': [
+ 'error',
+ {
+ initialized: 'never',
+ uninitialized: 'always',
+ },
+ ],
+ 'operator-linebreak': 'off',
+ 'padded-blocks': ['error', 'never'],
+ 'prefer-const': 'error',
+ 'semi-spacing': ['error', {before: false, after: true}],
+ semi: ['error', 'always'],
+ 'space-before-blocks': ['error', 'always'],
+ 'space-before-function-paren': 'off',
+ 'space-in-parens': ['error', 'never'],
+ 'space-infix-ops': ['error', {int32Hint: false}],
+ 'spaced-comment': [
+ 'error',
+ 'always',
+ {
+ line: {
+ markers: ['/'],
+ exceptions: ['-'],
+ },
+ block: {
+ balanced: true,
+ markers: ['!'],
+ exceptions: ['*'],
+ },
+ },
+ ],
+ strict: ['error', 'global'],
+ },
+};
diff --git a/api/.gitignore b/api/.gitignore
new file mode 100644
index 0000000..33ca215
--- /dev/null
+++ b/api/.gitignore
@@ -0,0 +1,68 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Typescript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.bat
+.env.mcm
+
+# Transpiled JavaScript files from Typescript
+/dist
+
+# Cache used by TypeScript's incremental build
+*.tsbuildinfo
+
+.scannerwork
\ No newline at end of file
diff --git a/api/.gitlab-ci.yml b/api/.gitlab-ci.yml
new file mode 100644
index 0000000..1f3651f
--- /dev/null
+++ b/api/.gitlab-ci.yml
@@ -0,0 +1,79 @@
+include:
+ - local: 'api/.gitlab-ci/preview.yml'
+ rules:
+ - if: $CI_COMMIT_BRANCH !~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger" && $CI_PIPELINE_SOURCE != "schedule"
+ - local: 'api/.gitlab-ci/testing.yml'
+ rules:
+ - if: $CI_COMMIT_BRANCH =~ /rc-.*/ && $CI_PIPELINE_SOURCE != "trigger"
+ - local: 'api/.gitlab-ci/helm.yml'
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "trigger"
+
+# Initialisation of specifique api variable
+.api-base:
+ variables:
+ MODULE_NAME: api
+ MODULE_PATH: ${MODULE_NAME}
+ MONGO_SERVICE_NAME: mongo
+ NEXUS_IMAGE_MONGO: ${NEXUS_DOCKER_REPOSITORY_URL}/mongo:5.0.7
+ API_IMAGE_NAME: ${REGISTRY_BASE_NAME}/${MODULE_NAME}:${IMAGE_TAG_NAME}
+ MONGO_IMAGE_NAME: ${REGISTRY_BASE_NAME}/mongo:${IMAGE_TAG_NAME}
+ only:
+ changes:
+ - '*'
+ - 'commons/**/*'
+ - 'api/**/*'
+
+api_build:
+ extends:
+ - .build-job
+ - .api-base
+ script:
+ - |
+ yarn install
+ cache:
+ key: ${MODULE_NAME}-${CI_COMMIT_REF_SLUG}
+ paths:
+ - ${MODULE_PATH}/node_modules/
+ - ${MODULE_PATH}/yarn.lock
+ artifacts:
+ paths:
+ - ${MODULE_PATH}/node_modules/
+ - ${MODULE_PATH}/yarn.lock
+ expire_in: 5 days
+
+.api_documentation_script:
+ script:
+ - |
+ yarn add curl
+ echo "this is api fqdn = https://${API_FQDN}"
+ export OPENAPI_MAAS=https://${API_FQDN}/openapi.json
+ echo "$(curl --insecure $OPENAPI_MAAS)" > openapi.json
+ chmod u+x api/openapi-maas.js
+ node ./api/openapi-maas.js
+
+.api-documentation-job:
+ extends:
+ - .api-base
+ - .except-all
+ - .manual
+ - .only-branches
+ - .no-dependencies
+ stage: utils
+ image: ${NEXUS_DOCKER_REPOSITORY_URL}/node:16.14.2-stretch
+ script:
+ - !reference [.api_documentation_script, script]
+ artifacts:
+ when: always
+ paths:
+ - openapi-maas.json
+ expire_in: 5 days
+
+
+# Static Application Security Testing for known vulnerabilities
+api_sast:
+ extends:
+ - .sast-job
+ - .build-n-sast-job-tags
+ - .api-base
+ needs: ['api_build']
\ No newline at end of file
diff --git a/api/.gitlab-ci/helm.yml b/api/.gitlab-ci/helm.yml
new file mode 100644
index 0000000..dd3fb3a
--- /dev/null
+++ b/api/.gitlab-ci/helm.yml
@@ -0,0 +1,4 @@
+api_image_push:
+ extends:
+ - .helm-push-image-job
+ - .api-base
\ No newline at end of file
diff --git a/api/.gitlab-ci/preview.yml b/api/.gitlab-ci/preview.yml
new file mode 100644
index 0000000..61f8067
--- /dev/null
+++ b/api/.gitlab-ci/preview.yml
@@ -0,0 +1,58 @@
+api_image_build:
+ extends:
+ - .preview-image-job
+ - .api-base
+ needs: ['api_build']
+
+# Unit test of api with the yarn cache
+.api_test_script: &api_test_script |
+ yarn test --reporter mocha-junit-reporter --reporter-options mochaFile=./junit.xml
+ yarn coverage
+
+api_test:
+ image: ${TEST_IMAGE_NAME}
+ extends:
+ - .test-job
+ - .api-base
+ script:
+ - *api_test_script
+ artifacts:
+ when: always
+ paths:
+ - ${MODULE_PATH}/coverage/lcov.info
+ expire_in: 5 days
+ needs: ['api_build']
+
+# Quality of code test in sonarqube with verify-job from common
+api_verify:
+ extends:
+ - .verify-job
+ - .api-base
+ variables:
+ SONAR_SOURCES: .
+ SONAR_EXCLUSIONS: 'sonar.exclusions=**/node_modules/**, dist/**, databaseConfig/**, public/**, coverage/**, **/__tests__/**, **.yml, **.json, **.md, eslintrc.js'
+ SONAR_CPD_EXCLUSIONS: '**/__tests__/**, src/datasources/**, src/models/**, src/repositories/**'
+ needs: ['sonarqube-verify-image-build', 'api_test']
+
+api_preview_deploy:
+ extends:
+ - .preview-deploy-job
+ - .api-base
+ script:
+ - |
+ deploy
+ wait_pod mongo
+ config_volume mongo
+ needs: ['api_image_build']
+ environment:
+ on_stop: api_preview_cleanup
+
+api_preview_documentation:
+ extends:
+ - .preview-env-vars
+ - .api-documentation-job
+
+api_preview_cleanup:
+ extends:
+ - .commons_preview_cleanup
+ - .api-base
diff --git a/api/.gitlab-ci/testing.yml b/api/.gitlab-ci/testing.yml
new file mode 100644
index 0000000..d8722c5
--- /dev/null
+++ b/api/.gitlab-ci/testing.yml
@@ -0,0 +1,17 @@
+api_testing_image_build:
+ extends:
+ - .testing-image-job
+ - .api-base
+ needs: ['api_build']
+
+api_testing_deploy:
+ extends:
+ - .testing-deploy-job
+ - .api-base
+ needs: ['api_testing_image_build']
+
+#UTILS
+api_testing_documentation:
+ extends:
+ - .testing-env-vars
+ - .api-documentation-job
diff --git a/api/.husky/pre-commit b/api/.husky/pre-commit
new file mode 100644
index 0000000..4c2ca6a
--- /dev/null
+++ b/api/.husky/pre-commit
@@ -0,0 +1,46 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+declare API_STAGED_FILES=$(git diff --name-only --cached | grep 'api/')
+declare WEBSITE_STAGED_FILES=$(git diff --name-only --cached | grep 'website/')
+declare ADMINISTRATION_STAGED_FILES=$(git diff --name-only --cached | grep 'administration/')
+cd api && export $(grep -v '^#' .secrets | xargs) && export $(grep '^export CI_PROJECT_ID' .env.mcm | xargs) && cd ..;
+
+if [[ $API_STAGED_FILES ]];
+then
+ echo "API_STAGED_FILES :"
+ echo "$API_STAGED_FILES"
+ echo "Pre commit api"
+ cd api && yarn lint:fix && cd ..;
+ if [ $? -ne 0 ]
+ then
+ echo "API pre-commit hook failed"
+ exit 1
+ fi
+fi
+
+if [[ $WEBSITE_STAGED_FILES ]];
+then
+ echo "WEBSITE_STAGED_FILES :"
+ echo "$WEBSITE_STAGED_FILES"
+ echo "Pre commit website"
+ cd website && yarn lint:fix && cd ..;
+ if [ $? -ne 0 ]
+ then
+ echo "Website pre-commit hook failed"
+ exit 1
+ fi
+fi
+
+if [[ $ADMINISTRATION_STAGED_FILES ]];
+then
+ echo "ADMINISTRATION_STAGED_FILES :"
+ echo "$ADMINISTRATION_STAGED_FILES"
+ echo "Pre commit administration"
+ cd administration && yarn lint:fix && cd ..;
+ if [ $? -ne 0 ]
+ then
+ echo "Administration pre-commit hook failed"
+ exit 1
+ fi
+fi
\ No newline at end of file
diff --git a/api/.mocharc.json b/api/.mocharc.json
new file mode 100644
index 0000000..7b523c3
--- /dev/null
+++ b/api/.mocharc.json
@@ -0,0 +1,5 @@
+{
+ "exit": true,
+ "recursive": true,
+ "require": "source-map-support/register"
+}
diff --git a/api/.prettierignore b/api/.prettierignore
new file mode 100644
index 0000000..46b020a
--- /dev/null
+++ b/api/.prettierignore
@@ -0,0 +1,3 @@
+dist
+*.json
+mongo/databaseConfig/*.js
diff --git a/api/.prettierrc b/api/.prettierrc
new file mode 100644
index 0000000..95a8e60
--- /dev/null
+++ b/api/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "bracketSpacing": false,
+ "singleQuote": true,
+ "printWidth": 90,
+ "trailingComma": "all",
+ "arrowParens": "avoid"
+}
diff --git a/api/.yo-rc.json b/api/.yo-rc.json
new file mode 100644
index 0000000..54319a3
--- /dev/null
+++ b/api/.yo-rc.json
@@ -0,0 +1,6 @@
+{
+ "@loopback/cli": {
+ "packageManager": "npm",
+ "version": "2.21.0"
+ }
+}
diff --git a/api/Chart.yaml b/api/Chart.yaml
new file mode 100644
index 0000000..c3dcb0d
--- /dev/null
+++ b/api/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: 1.0.0
+description: A Helm chart for Kubernetes
+name: ${MODULE_PATH}
+type: application
+version: 1.0.0
diff --git a/api/Dockerfile b/api/Dockerfile
new file mode 100644
index 0000000..4c537d1
--- /dev/null
+++ b/api/Dockerfile
@@ -0,0 +1,33 @@
+FROM node:16.14.2-alpine
+
+ENV PHANTOMJS_VERSION=2.1.1
+ENV PHANTOMJS_PATH=/usr/local/bin/phantomjs
+RUN apk update && apk add --no-cache fontconfig ghostscript-fonts curl curl-dev && \
+ cd /tmp && curl -Ls https://github.com/dustinblackman/phantomized/releases/download/${PHANTOMJS_VERSION}/dockerized-phantomjs.tar.gz | tar xz && \
+ cp -R lib lib64 / && \
+ cp -R usr/lib/x86_64-linux-gnu /usr/lib && \
+ cp -R usr/share /usr/share && \
+ cp -R etc/fonts /etc && \
+ curl -k -Ls https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-${PHANTOMJS_VERSION}-linux-x86_64.tar.bz2 | tar -jxf - && \
+ cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs
+
+# Set to a non-root built-in user `node`
+USER node
+
+# Create app directory (with user `node`)
+RUN mkdir -p /home/node/app
+
+WORKDIR /home/node/app
+
+# Install app dependencies
+COPY --chown=node package.json ./
+
+RUN yarn install
+
+COPY --chown=node . .
+
+RUN rm .npmrc | true
+RUN yarn run build
+
+EXPOSE 3000
+CMD [ "yarn", "start:watch" ]
diff --git a/api/README.md b/api/README.md
new file mode 100644
index 0000000..94ad5d7
--- /dev/null
+++ b/api/README.md
@@ -0,0 +1,136 @@
+# Description
+
+Le service api se base sur le framework **[Loopback 4](https://loopback.io/doc/en/lb4/)**
+
+Ce service contient la logique métier backend. Il est essentiel au reste de l'applicatif et permet de relier tous les services entre eux.
+Il s'appuie sur une base de données orientée documents **[MongoDB](https://www.mongodb.com/docs/v5.0/)**.
+
+# Installation en local
+
+## MongoDB
+
+```sh
+docker run -d --name mcm-mongo -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER} -e MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD} mongo
+```
+
+## LB4
+
+Si vous voulez avoir plus de contrôle sur les variables de l'api, créez un fichier d'environnement à la racine (ex: .env) avec les variables mentionnées ci-dessous. Il faudra alors l'exécuter avant de lancer l'api.
+
+```sh
+yarn install && yarn start:watch
+```
+
+ou
+
+`yarn install && yarn start`
+
+## Variables
+
+| Variables | Description | Obligatoire |
+| ------------------------------ | ---------------------------------------------------------------------------- | ----------- |
+| API_KEY | Api Key en header des requêtes pour l'api | Non |
+| AFFILIATION_JWS_KEY | Clé jws utilisée pour signer le token d'affiliation citoyen à une entreprise | Non |
+| WEBSITE_FQDN | Url du website | Non |
+| LANDSCAPE | Nom de l'environnement (preview, testing ..) | Non |
+| BASE_DOMAIN | Base domaine url | Non |
+| IDP_FQDN | Url de l'idp | Non |
+| API_FQDN | Url de l'api | Non |
+| CLIENT_SECRET_KEY_KEYCLOAK_API | Secret key du client API | Non |
+| PORT | Port de l'api | Non |
+| HOST | Host de l'api | Non |
+| CLAMAV_HOST | Host de l'antivirus | Non |
+| CLAMAV_PORT | Port de l'antivirus | Non |
+| MAILHOG_HOST | Host de mailhog | Non |
+| MAILHOG_EMAIL_FROM | Email from 'mon compte mobilite' | Non |
+| SENDGRID_HOST | Host sendgrid | Non |
+| SENDGRID_USER | User sendgrid | Non |
+| SENDGRID_API_KEY | Api key sendgrid | Non |
+| SENDGRID_EMAIL_FROM | Email from 'mon compte mobilite' | Non |
+| BUS_HOST | Host Rabbitmq | Non |
+| BUS_MCM_CONSUME_USER | Username pour la réception des messages | Non |
+| BUS_MCM_CONSUME_PASSWORD | Password pour la réception des messages | Non |
+| BUS_MCM_HEADERS | Header d'échange de publication | Non |
+| BUS_MCM_MESSAGE_TYPE | Message type d'échange de la publication | Non |
+| BUS_CONSUMER_QUEUE | Nom de la queue pour le consumer | Non |
+| S3_SERVICE_USER | User compte de service | Non |
+| S3_SERVICE_PASSWORD | Password compte de service | Non |
+| S3_HOST | Host minio | Non |
+| S3_PORT | Port minio | Non |
+| IDP_DB_HOST | Host bdd pgsql | Non |
+| IDP_DB_PORT | Port bdd pgsql | Non |
+| IDP_DB_SERVICE_USER | Username de service pgsql (readaccess) | Non |
+| IDP_DB_SERVICE_PASSWORD | Password de service pgsql (readaccess) | Non |
+| IDP_DB_DATABASE | Nom bdd pgsql | Non |
+| PGSQL_FLEX_SSL_CERT | Certificat de connexion à la bdd pgsql en base 64 | Non |
+| MONGO_SERVICE_USER | User service bdd mongo | Non |
+| MONGO_SERVICE_PASSWORD | Password service bdd mongo | Non |
+| MONGO_HOST | Host mongo | Non |
+| MONGO_DATABASE | Nom de la bdd mongo | Non |
+| MONGO_PORT | Port mongo | Non |
+| MONGO_AUTH_SOURCE | Nom de la source d'authentification bdd mongo | Non |
+| SENDGRID_EMAIL_CONTACT | Email contact | Non |
+| LOG_LEVEL | Niveau de logs pour l'api | Non |
+
+## URL / Port
+
+- URL : localhost
+- Port : 3000
+
+# Précisions pipelines
+
+L'image de l'api est basée sur celle de nginx.
+
+La variable PACKAGE_VERSION (voir commons) permet de repérer la dernière version publiée.
+
+## Preview
+
+Deux scripts sont lancés au déploiement de la bdd mongo permettant de créer les collections et les index nécessaires et de créer l'utilisateur de service ayant seulement l'accès en lecture et écriture sur la bdd. (mongo/databaseConfig/createServiceUser.js & setup.js)
+
+## Testing
+
+Sur cet environnement, la bdd mongo est hébergée sur Azure. Le paramétrage de la bdd et des collections sont donc à faire en amont.
+
+# Relation avec les autres services
+
+Comme présenté dans le schéma global de l'architecture ci-dessous
+
+![technicalArchitecture](../docs/assets/MOB-CME_Archi_technique_detaillee.png)
+
+L'api est reliée à tous les services que nous avons mis en place.
+
+A son démarrage :
+
+- Elle peut exécuter des scripts de migrations de la bdd mongo.
+- Elle lance un child process étant responsable de l'écoute et de la consommation des événements amqp sur la queue de dépôt
+- Elle se connecte aux bdd configurées (mongo & pgsql)
+
+Comme mentionné, notre api a accès à la bdd pgsql de l'[idp](idp) en lecture seule permettant de requêter des informations plus rapidement & facilement que par appel API de l'IDP.
+
+L'api est reliée à :
+
+- _idp_ pour renforcer la sécurité et vérifier l'accès aux endpoints.
+- _administration_ pour permettre la contribution de certains contenus.
+- website pour la récupération des données permettant le bon fonctionnement de moB.
+- _s3_ pour le download/upload des justificatifs
+- simulation-maas pour la simulation de l'envoie des metadonnées. En effet, nos développements sont orientés open API et nos partenaires peuvent donc s'orienter sur une approche Full API de moB.
+- _antivirus_ pour scanner les justificatifs qui nous sont envoyés.
+- _bus_ pour l'échange de messages entre les SIRH et nous.
+
+* Requêtes HTTP avec l'administration
+* Requêtes HTTP avec l'idp
+* Requêtes HTTP avec website
+* Requêtes HTTP avec s3
+* Requêtes HTTP avec simulation-maas
+* Requêtes TCP avec antivirus
+* Requêtes AMQP avec le bus
+
+# Tests Unitaires
+
+Les TU sont fait avec le framework de test Mocha.
+
+Pour lancer les tests unitaires
+
+```sh
+yarn test
+```
diff --git a/api/api-docker-compose.yml b/api/api-docker-compose.yml
new file mode 100644
index 0000000..21a3df8
--- /dev/null
+++ b/api/api-docker-compose.yml
@@ -0,0 +1,31 @@
+version: '3.4'
+
+services:
+ api:
+ build:
+ context: .
+ network: host
+ container_name: api
+ environment:
+ IDP_URL: ${IDP_URL}
+ S3_URL: ${S3_URL}
+ IDP_DB_HOST: ${IDP_DB_HOST}
+ MONGO_HOST: ${MONGO_HOST}
+ BUS_HOST: ${BUS_HOST}
+ CLIENT_SECRET_KEY_KEYCLOAK_API: ${CLIENT_SECRET_KEY_KEYCLOAK_API}
+ ANTIVIRUS: ${ANTIVIRUS}
+ API_KEY: ${API_KEY}
+ PORT: ${PORT}
+ network_mode: host
+ volumes:
+ - ./src:/home/node/app/src
+ - node_modules:/home/node/app/node_modules
+ ports:
+ - 3000:3000
+
+volumes:
+ node_modules:
+
+networks:
+ dev_web-nw:
+ external: true
diff --git a/api/api-dockerfile.yml b/api/api-dockerfile.yml
new file mode 100644
index 0000000..53181ce
--- /dev/null
+++ b/api/api-dockerfile.yml
@@ -0,0 +1,57 @@
+ARG NODE_IMAGE_NAME
+FROM ${NODE_IMAGE_NAME}
+
+RUN apk add curl
+
+ENV PHANTOMJS_VERSION=2.1.1
+ENV PHANTOMJS_PATH=/usr/local/bin/phantomjs
+RUN apk update && apk add --no-cache fontconfig ghostscript-fonts curl curl-dev && \
+ cd /tmp && curl -Ls https://github.com/dustinblackman/phantomized/releases/download/${PHANTOMJS_VERSION}/dockerized-phantomjs.tar.gz | tar xz && \
+ cp -R lib lib64 / && \
+ cp -R usr/lib/x86_64-linux-gnu /usr/lib && \
+ cp -R usr/share /usr/share && \
+ cp -R etc/fonts /etc && \
+ curl -k -Ls https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-${PHANTOMJS_VERSION}-linux-x86_64.tar.bz2 | tar -jxf - && \
+ cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs
+
+# Set to a non-root built-in user `node`
+USER node
+
+# Create app directory (with user `node`)
+RUN mkdir -p /home/node/app
+
+WORKDIR /home/node/app
+
+# Install and build are supposed to be yet done
+ARG CI_PROJECT_ID
+ENV CI_PROJECT_ID=${CI_PROJECT_ID}
+ARG GITLAB_REGISTRY_NPM_TOKEN
+ENV GITLAB_REGISTRY_NPM_TOKEN=${GITLAB_REGISTRY_NPM_TOKEN}
+ARG NEXUS_NPM_PROXY_TOKEN
+ENV NEXUS_NPM_PROXY_TOKEN=${NEXUS_NPM_PROXY_TOKEN}
+ARG PACKAGE_VERSION
+ENV PACKAGE_VERSION=${PACKAGE_VERSION}
+
+# Install app dependencies
+# A wildcard is used to ensure both package.json AND package-lock.json are copied
+# where available (npm@5+)
+COPY --chown=node package.json ./
+COPY --chown=node yarn.lock ./
+COPY --chown=node .npmrc ./
+
+RUN yarn install
+#COPY --chown=node node_modules ./node_modules
+
+# Bundle app source code
+#COPY --chown=node src/ src
+#COPY --chown=node tsconfig.json ./
+COPY --chown=node . .
+
+RUN npm version ${PACKAGE_VERSION}
+RUN yarn run build
+
+# Bind to all network interfaces so that it can be mapped to the host OS
+ENV HOST=0.0.0.0 PORT=3000
+
+EXPOSE ${PORT}
+CMD [ "node", "." ]
diff --git a/api/api-testing-values.yaml b/api/api-testing-values.yaml
new file mode 100644
index 0000000..2305fe6
--- /dev/null
+++ b/api/api-testing-values.yaml
@@ -0,0 +1,174 @@
+services:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ labels:
+ io.kompose.service: api
+ name: api
+ spec:
+ ports:
+ - name: '3000'
+ port: 3000
+ targetPort: 3000
+ selector:
+ io.kompose.service: api
+ type: ClusterIP
+ status:
+ loadBalancer: {}
+
+networkPolicies:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: web-nw
+ spec:
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ com.capgemini.mcm.ingress: 'true'
+ podSelector:
+ matchLabels:
+ io.kompose.network/web-nw: 'true'
+
+deployments:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ labels:
+ io.kompose.service: api
+ name: api
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ io.kompose.service: api
+ strategy: {}
+ template:
+ metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kompose.image-pull-secret: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ kompose.service.type: clusterip
+ kubernetes.io/ingress.class: traefik
+ labels:
+ io.kompose.network/storage-nw: 'true'
+ io.kompose.network/web-nw: 'true'
+ io.kompose.service: api
+ spec:
+ containers:
+ env:
+ AFFILIATION_JWS_KEY: ${TESTING_AFFILIATION_JWS_KEY}
+ API_FQDN: ${API_FQDN}
+ API_KEY: ${TESTING_API_KEY}
+ BUS_HOST: bus.bus-${LANDSCAPE}.svc.cluster.local
+ BUS_MCM_HEADERS: ${BUS_MCM_HEADERS}
+ BUS_MCM_MESSAGE_TYPE: ${BUS_MCM_MESSAGE_TYPE}
+ BUS_CONSUMER_QUEUE: mob.subscriptions.status
+ BUS_MCM_CONSUME_PASSWORD: ${TESTING_BUS_MCM_CONSUME_PASSWORD}
+ BUS_MCM_CONSUME_USER: ${TESTING_BUS_MCM_CONSUME_USER}
+ CLIENT_SECRET_KEY_KEYCLOAK_API: ${TESTING_IDP_API_CLIENT_SECRET}
+ CLAMAV_HOST: clamav.antivirus-${LANDSCAPE}.svc.cluster.local
+ CLAMAV_PORT: '3310'
+ IDP_FQDN: ${IDP_FQDN}
+ IDP_DB_HOST: ${TESTING_PGSQL_FLEX_ADDRESS}
+ IDP_DB_PORT: 5432
+ IDP_DB_AUTH_SOURCE: ${TESTING_PGSQL_NAME}
+ IDP_DB_DATABASE: ${TESTING_PGSQL_NAME}
+ IDP_DB_SERVICE_USER: ${TESTING_PGSQL_SERVICE_USER}
+ IDP_DB_SERVICE_PASSWORD: ${TESTING_PGSQL_SERVICE_PASSWORD}
+ LANDSCAPE: ${LANDSCAPE}
+ MONGO_AUTH_SOURCE: ${TESTING_MONGO_DB_NAME}
+ MONGO_HOST: ${TESTING_MONGO_HOST}
+ MONGO_PORT: 27017
+ MONGO_SERVICE_USER: ${TESTING_MONGO_SERVICE_USER}
+ MONGO_SERVICE_PASSWORD: ${TESTING_MONGO_SERVICE_PASSWORD}
+ MONGO_DATABASE: ${TESTING_MONGO_DB_NAME}
+ S3_HOST: s3.s3-${LANDSCAPE}.svc.cluster.local
+ S3_PORT: '9000'
+ S3_SERVICE_PASSWORD: ${TESTING_S3_SERVICE_PASSWORD}
+ S3_SERVICE_USER: ${TESTING_S3_SERVICE_USER}
+ MAILHOG_EMAIL_FROM: ${MAILHOG_EMAIL_FROM}
+ MAILHOG_HOST: mailhog.mailhog-${LANDSCAPE}.svc.cluster.local
+ WEBSITE_FQDN: ${WEBSITE_FQDN}
+ PGSQL_FLEX_SSL_CERT: ${PGSQL_FLEX_SSL_CERT}
+ image: ${API_IMAGE_NAME}
+ name: api
+ ports:
+ - containerPort: 3000
+ resources: {}
+ imagePullSecrets:
+ - name: ${GITLAB_IMAGE_PULL_SECRET_NAME}
+ restartPolicy: Always
+ status: {}
+
+middlewares:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: api-headers-middleware
+ spec:
+ headers:
+ customRequestHeaders:
+ X-Forwarded-Port: '443'
+ X-Forwarded-Proto: https
+
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: api-inflightreq-middleware
+ spec:
+ inFlightReq:
+ amount: 100
+
+ # - metadata:
+ # annotations:
+ # app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ # app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ # kubernetes.io/ingress.class: traefik
+ # name: api-ratelimit-middleware
+ # spec:
+ # rateLimit:
+ # average: 30
+ # burst: 50
+ # period: 1s
+ # sourceCriterion:
+ # ipStrategy:
+ # depth: 2
+
+ingressRoutes:
+ - metadata:
+ annotations:
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ kubernetes.io/ingress.class: traefik
+ name: api
+ spec:
+ entryPoints:
+ - web
+ routes:
+ - kind: Rule
+ match: Host(`${API_FQDN}`)
+ middlewares:
+ - name: api-headers-middleware
+ # - name: api-ratelimit-middleware
+ - name: api-inflightreq-middleware
+ services:
+ - kind: Service
+ name: api
+ port: 3000
diff --git a/api/explorer/index.html.ejs b/api/explorer/index.html.ejs
new file mode 100644
index 0000000..0d27e9b
--- /dev/null
+++ b/api/explorer/index.html.ejs
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ <%- indexTemplateTitle %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/kompose.yml b/api/kompose.yml
new file mode 100644
index 0000000..37777c8
--- /dev/null
+++ b/api/kompose.yml
@@ -0,0 +1,90 @@
+version: '3'
+
+services:
+ mongo:
+ image: ${MONGO_IMAGE_NAME}
+ build:
+ context: .
+ dockerfile: ./mongo-dockerfile.yml
+ args:
+ BASE_IMAGE_MONGO: ${NEXUS_IMAGE_MONGO}
+ environment:
+ - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER}
+ - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
+ - MONGO_INITDB_DATABASE=${MONGO_DB_NAME}
+ - MONGO_INITDB_NON_ROOT_USERNAME=${MONGO_SERVICE_USER}
+ - MONGO_INITDB_NON_ROOT_PASSWORD=${MONGO_SERVICE_PASSWORD}
+ volumes:
+ - mongo-data:/data/db
+ networks:
+ - storage-nw
+ ports:
+ - '27017'
+ labels:
+ - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}'
+ - 'kompose.service.type=clusterip'
+ - 'traefik.enable=false'
+
+ api:
+ depends_on:
+ - mongo
+ image: ${API_IMAGE_NAME}
+ build:
+ context: .
+ dockerfile: ./api-dockerfile.yml
+ args:
+ NODE_IMAGE_NAME: ${BUILD_IMAGE_NAME}
+ CI_PROJECT_ID: ${CI_PROJECT_ID}
+ MCM_GITLAB_DEPLOY_NPM_TOKEN: ${GITLAB_REGISTRY_NPM_TOKEN}
+ NEXUS_NPM_PROXY_TOKEN: ${NEXUS_NPM_PROXY_TOKEN}
+ PACKAGE_VERSION: ${PACKAGE_VERSION}
+ environment:
+ - MONGO_AUTH_SOURCE=${MONGO_DB_NAME}
+ - MONGO_HOST=mongo
+ - MONGO_PORT=27017
+ - MONGO_SERVICE_USER
+ - MONGO_SERVICE_PASSWORD
+ - MONGO_DATABASE=${MONGO_DB_NAME}
+ - IDP_DB_HOST=postgres-keycloak.idp-${CI_COMMIT_REF_SLUG}-${LANDSCAPE}.svc.cluster.local
+ - IDP_DB_PORT=5432
+ - IDP_DB_AUTH_SOURCE=idp_db
+ - IDP_DB_SERVICE_USER=${PGSQL_SERVICE_USER}
+ - IDP_DB_SERVICE_PASSWORD=${PGSQL_SERVICE_PASSWORD}
+ - IDP_DB_DATABASE=idp_db
+ - CLIENT_SECRET_KEY_KEYCLOAK_API=${IDP_API_CLIENT_SECRET}
+ - IDP_FQDN
+ - API_FQDN
+ - S3_SERVICE_USER
+ - S3_SERVICE_PASSWORD
+ - S3_HOST=s3.s3-master-preview.svc.cluster.local
+ - S3_PORT=9000
+ - AFFILIATION_JWS_KEY
+ - WEBSITE_FQDN
+ - CLAMAV_HOST=clamav.antivirus-master-preview.svc.cluster.local
+ - CLAMAV_PORT=3310
+ - LANDSCAPE
+ - BASE_DOMAIN
+ - MAILHOG_EMAIL_FROM
+ - MAILHOG_HOST=mailhog.mailhog-${CI_COMMIT_REF_SLUG}-${LANDSCAPE}.svc.cluster.local
+ - BUS_HOST=bus.bus-${CI_COMMIT_REF_SLUG}-${LANDSCAPE}.svc.cluster.local
+ - BUS_CONSUMER_QUEUE
+ - BUS_MCM_CONSUME_USER
+ - BUS_MCM_CONSUME_PASSWORD
+ - BUS_MCM_HEADERS
+ - BUS_MCM_MESSAGE_TYPE
+ - API_KEY
+ networks:
+ - web-nw
+ - storage-nw
+ ports:
+ - '3000'
+ labels:
+ - 'kompose.image-pull-secret=${GITLAB_IMAGE_PULL_SECRET_NAME}'
+ - 'kompose.service.type=clusterip'
+
+volumes:
+ mongo-data:
+
+networks:
+ web-nw:
+ storage-nw:
diff --git a/api/mongo-dockerfile.yml b/api/mongo-dockerfile.yml
new file mode 100644
index 0000000..56d798f
--- /dev/null
+++ b/api/mongo-dockerfile.yml
@@ -0,0 +1,5 @@
+# Check out https://hub.docker.com/_/mongo to select a new base image
+ARG BASE_IMAGE_MONGO
+FROM ${BASE_IMAGE_MONGO}
+
+COPY ./mongo/databaseConfig/*.js /docker-entrypoint-initdb.d/
diff --git a/api/mongo/databaseConfig/createServiceUser.js b/api/mongo/databaseConfig/createServiceUser.js
new file mode 100644
index 0000000..0b1b1d2
--- /dev/null
+++ b/api/mongo/databaseConfig/createServiceUser.js
@@ -0,0 +1,12 @@
+/* eslint-disable */
+const username = _getEnv('MONGO_INITDB_NON_ROOT_USERNAME');
+const password = _getEnv('MONGO_INITDB_NON_ROOT_PASSWORD');
+const dbname = _getEnv('MONGO_INITDB_DATABASE');
+
+db.createUser(
+ {
+ user: username,
+ pwd: password,
+ roles: [{ role: "readWrite", db: dbname }]
+ }
+);
\ No newline at end of file
diff --git a/api/mongo/databaseConfig/setup.js b/api/mongo/databaseConfig/setup.js
new file mode 100644
index 0000000..9decf09
--- /dev/null
+++ b/api/mongo/databaseConfig/setup.js
@@ -0,0 +1,27 @@
+/* eslint-disable */
+const res = [
+ db.createCollection('Incentive'),
+ db.Incentive.createIndex(
+ {
+ '$**': 'text',
+ },
+ {
+ unique: false,
+ name: 'fullText',
+ default_language: 'french',
+ },
+ ),
+ db.Incentive.createIndex({funderId: 1}),
+ db.createCollection('Subscription'),
+ db.Subscription.createIndex({lastName: 1}),
+ db.Subscription.createIndex({funderId: 1}),
+ db.Subscription.createIndex({incentiveId: 1}),
+ db.Subscription.createIndex({incentiveType: 1}),
+ db.Subscription.createIndex({status: 1}),
+ db.createCollection('Citizen'),
+ db.Citizen.createIndex({'identity.lastName.value': 1}),
+ db.createCollection('CronJob'),
+ db.CronJob.createIndex({ "type": 1 }, { unique: true }),
+ db.Territory.createIndex({ "name": 1 }, { unique: true })
+];
+printjson(res);
diff --git a/api/openapi-maas.js b/api/openapi-maas.js
new file mode 100644
index 0000000..9217305
--- /dev/null
+++ b/api/openapi-maas.js
@@ -0,0 +1,91 @@
+const fs = require('fs');
+
+const OPENAPI_FILEPATH = `./openapi.json`;
+const OPENAPI_MAAS_FILENAME = `openapi-maas.json`;
+const TAG_MAAS = 'MaaS';
+
+function findAllByKey(obj, keyToFind) {
+ return Object.entries(obj).reduce(
+ (acc, [key, value]) =>
+ key === keyToFind
+ ? acc.concat(value)
+ : typeof value === 'object'
+ ? acc.concat(findAllByKey(value, keyToFind))
+ : acc,
+ [],
+ );
+}
+
+fs.readFile(OPENAPI_FILEPATH, 'utf8', (err, data) => {
+ if (err) {
+ console.error(err);
+ return;
+ }
+
+ const parsedData = JSON.parse(data);
+ const maasPaths = Object.fromEntries(
+ Object.entries(parsedData.paths)
+ .map(([key, value]) => [
+ key,
+ Object.fromEntries(
+ Object.entries(value)
+ .map(([key, value]) => [key, value])
+ .filter(([key, value]) => {
+ return value.tags.includes(TAG_MAAS);
+ })
+ .map(([key, value]) => {
+ value.tags = [TAG_MAAS];
+ return [key, value];
+ }),
+ ),
+ ])
+ .filter(([key, value]) => Object.keys(value).length !== 0),
+ );
+
+ let finished = false;
+ let schemasList = findAllByKey(maasPaths, '$ref');
+ let schemaNames = [...new Set(schemasList)].map(
+ value => value.split('#/components/schemas/')[1],
+ );
+ let schemasCount = schemaNames.length;
+ let maasSchemas = {};
+
+ while (!finished) {
+ maasSchemas = Object.fromEntries(
+ Object.entries(parsedData.components.schemas).filter(([key]) =>
+ schemaNames.includes(key),
+ ),
+ );
+
+ let nestedSchemas = findAllByKey(maasSchemas, '$ref');
+ nestedSchemas = [...new Set(nestedSchemas)].map(
+ value => value.split('#/components/schemas/')[1],
+ );
+
+ schemaNames.push(...nestedSchemas);
+ schemaNames = [...new Set(schemaNames)];
+ if (schemaNames.length === schemasCount) {
+ finished = true;
+ } else {
+ schemasCount = schemaNames.length;
+ }
+ }
+
+ const openapiMaas = {
+ openapi: parsedData.openapi,
+ info: parsedData.info,
+ paths: maasPaths,
+ servers: parsedData.servers,
+ components: {
+ securitySchemes: parsedData.components.securitySchemes,
+ schemas: maasSchemas,
+ },
+ };
+
+ fs.writeFile(OPENAPI_MAAS_FILENAME, JSON.stringify(openapiMaas), err => {
+ if (err) console.log(err);
+ else {
+ console.log('File written successfully\n');
+ }
+ });
+});
diff --git a/api/overlays/api-certificate.yml b/api/overlays/api-certificate.yml
new file mode 100644
index 0000000..933f69f
--- /dev/null
+++ b/api/overlays/api-certificate.yml
@@ -0,0 +1,12 @@
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ name: api-cert
+spec:
+ dnsNames:
+ - '*.${landscape_subdomain}'
+ issuerRef:
+ group: cert-manager.io
+ kind: ClusterIssuer
+ name: ${CLUSTER_ISSUER}
+ secretName: ${SECRET_NAME}
diff --git a/api/overlays/api-headers-middleware.yml b/api/overlays/api-headers-middleware.yml
new file mode 100644
index 0000000..c7d05ce
--- /dev/null
+++ b/api/overlays/api-headers-middleware.yml
@@ -0,0 +1,9 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: Middleware
+metadata:
+ name: api-headers-middleware
+spec:
+ headers:
+ customRequestHeaders:
+ X-Forwarded-Proto: 'https'
+ X-Forwarded-Port: '443'
diff --git a/api/overlays/api-inflightreq-middleware.yml b/api/overlays/api-inflightreq-middleware.yml
new file mode 100644
index 0000000..5854881
--- /dev/null
+++ b/api/overlays/api-inflightreq-middleware.yml
@@ -0,0 +1,7 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: Middleware
+metadata:
+ name: api-inflightreq-middleware
+spec:
+ inFlightReq:
+ amount: 100
diff --git a/api/overlays/api-ingressroute.yml b/api/overlays/api-ingressroute.yml
new file mode 100644
index 0000000..b6c90ba
--- /dev/null
+++ b/api/overlays/api-ingressroute.yml
@@ -0,0 +1,26 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: IngressRoute
+metadata:
+ name: api
+spec:
+ entryPoints:
+ - web
+ # - websecure
+ routes:
+ - match: Host(`${API_FQDN}`)
+ middlewares:
+ - name: api-headers-middleware
+ # - name: api-ratelimit-middleware
+ - name: api-inflightreq-middleware
+ kind: Rule
+ services:
+ - kind: Service
+ name: api
+ port: 3000
+ # tls:
+ # secretName: ${SECRET_NAME} # api-tls # cert-dev
+ # domains:
+ # - main: ${BASE_DOMAIN}
+ # sans:
+ # - '*.preview.${BASE_DOMAIN}'
+ # - '*.testing.${BASE_DOMAIN}'
diff --git a/api/overlays/api-ipwhitelist-middleware.yml b/api/overlays/api-ipwhitelist-middleware.yml
new file mode 100644
index 0000000..abeda1a
--- /dev/null
+++ b/api/overlays/api-ipwhitelist-middleware.yml
@@ -0,0 +1,10 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: Middleware
+metadata:
+ name: test-ipwhitelist
+spec:
+ ipWhiteList:
+ sourceRange:
+ - 127.0.0.1/32
+ # TO DO limite accès to FRONT ip
+ # and limite accès to
diff --git a/api/overlays/api-ratelimit-middleware.yml b/api/overlays/api-ratelimit-middleware.yml
new file mode 100644
index 0000000..e9ce427
--- /dev/null
+++ b/api/overlays/api-ratelimit-middleware.yml
@@ -0,0 +1,12 @@
+apiVersion: traefik.containo.us/v1alpha1
+kind: Middleware
+metadata:
+ name: api-ratelimit-middleware
+spec:
+ rateLimit:
+ period: 1s
+ average: 30
+ burst: 50
+ sourceCriterion:
+ ipStrategy:
+ depth: 2
diff --git a/api/overlays/kustomization.yaml b/api/overlays/kustomization.yaml
new file mode 100644
index 0000000..8c07eee
--- /dev/null
+++ b/api/overlays/kustomization.yaml
@@ -0,0 +1,14 @@
+commonAnnotations:
+ app.gitlab.com/env: ${CI_ENVIRONMENT_SLUG}
+ app.gitlab.com/app: ${CI_PROJECT_PATH_SLUG}
+ kubernetes.io/ingress.class: traefik
+
+resources:
+ - api-ingressroute.yml
+ # - api-certificate.yml
+ - api-headers-middleware.yml
+ # - api-ratelimit-middleware.yml
+ - api-inflightreq-middleware.yml
+
+patchesStrategicMerge:
+ - web_nw_networkpolicy_namespaceselector.yml
diff --git a/api/overlays/web_nw_networkpolicy_namespaceselector.yml b/api/overlays/web_nw_networkpolicy_namespaceselector.yml
new file mode 100644
index 0000000..53e6193
--- /dev/null
+++ b/api/overlays/web_nw_networkpolicy_namespaceselector.yml
@@ -0,0 +1,10 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: web-nw
+spec:
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ com.capgemini.mcm.ingress: "true"
diff --git a/api/package.json b/api/package.json
new file mode 100644
index 0000000..9cca1d8
--- /dev/null
+++ b/api/package.json
@@ -0,0 +1,115 @@
+{
+ "name": "@mcm/mcm-api",
+ "version": "1.10.1",
+ "description": "",
+ "keywords": [
+ "loopback-application",
+ "loopback"
+ ],
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "engines": {
+ "node": ">=10.16"
+ },
+ "scripts": {
+ "build": "lb-tsc",
+ "postbuild": "npm run copy-ejs-files",
+ "build:watch": "lb-tsc --watch",
+ "lint": "npm run eslint && npm run prettier:check",
+ "lint:fix": "npm run eslint:fix && npm run prettier:fix",
+ "prettier:cli": "lb-prettier \"**/*.ts\" \"**/*.js\"",
+ "prettier:check": "npm run prettier:cli -- -l",
+ "prettier:fix": "npm run prettier:cli -- --write",
+ "eslint": "lb-eslint --report-unused-disable-directives .",
+ "eslint:fix": "npm run eslint -- --fix",
+ "pretest": "npm run rebuild",
+ "test": "nyc lb-mocha --allow-console-logs \"dist/__tests__\"",
+ "posttest": "npm run lint",
+ "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest",
+ "docker:build": "docker build -t api .",
+ "docker:run": "docker run -p 3000:3000 -d api",
+ "premigrate": "npm run build",
+ "migrate": "node ./dist/migrate",
+ "preopenapi-spec": "npm run build",
+ "openapi-spec": "node ./dist/openapi-spec",
+ "prestart": "npm run rebuild",
+ "start": "node -r source-map-support/register .",
+ "clean": "lb-clean dist *.tsbuildinfo .eslintcache",
+ "start:watch": "tsc-watch --target es2017 --outDir ./dist --onSuccess \"node .\"",
+ "rebuild": "npm run clean && npm run build",
+ "verify": "sonarqube-verify",
+ "coverage": "nyc report --reporter=lcovonly",
+ "copy-ejs-files": "copyfiles -u 1 src/**/**.ejs dist/",
+ "prepare": "cd .. && husky install api/.husky"
+ },
+ "repository": {
+ "type": "git",
+ "url": ""
+ },
+ "author": "Mon Compte Mobilité",
+ "license": "",
+ "files": [
+ "README.md",
+ "dist",
+ "src",
+ "!*/__tests__"
+ ],
+ "dependencies": {
+ "@aws-sdk/client-s3": "3.24.0",
+ "@loopback/authentication": "9.0.4",
+ "@loopback/authorization": "0.12.4",
+ "@loopback/boot": "5.0.4",
+ "@loopback/build": "9.0.4",
+ "@loopback/core": "4.0.4",
+ "@loopback/cron": "0.9.4",
+ "@loopback/repository": "5.0.4",
+ "@loopback/rest": "12.0.4",
+ "@loopback/rest-explorer": "5.0.4",
+ "@loopback/service-proxy": "5.0.4",
+ "@types/amqplib": "0.8.2",
+ "@types/clamscan": "2.0.2",
+ "@types/ejs": "3.1.1",
+ "@types/express": "4.17.13",
+ "@types/jsonwebtoken": "8.5.8",
+ "@types/mocha": "10.0.0",
+ "@types/multer": "1.4.7",
+ "@types/node": "16.11.36",
+ "@types/nodemailer": "6.4.4",
+ "amqplib": "0.8.0",
+ "clamscan": "2.1.2",
+ "copyfiles": "2.4.1",
+ "date-fns": "2.28.0",
+ "ejs": "3.1.8",
+ "exceljs": "4.3.0",
+ "html-pdf": "3.0.1",
+ "jsonschema": "1.4.1",
+ "jsonwebtoken": "8.5.1",
+ "jwk-to-pem": "2.0.5",
+ "keycloak-admin": "1.14.22",
+ "loopback-connector-mongodb": "6.2.0",
+ "loopback-connector-postgresql": "5.5.1",
+ "loopback4-migration": "1.3.0",
+ "multer": "1.4.5-lts.1",
+ "nodemailer": "6.7.5",
+ "strong-error-handler": "4.0.0",
+ "tsc-watch": "4.6.2",
+ "tslib": "2.4.0",
+ "winston": "3.7.2"
+ },
+ "devDependencies": {
+ "@loopback/eslint-config": "13.0.4",
+ "@loopback/testlab": "5.0.4",
+ "@types/html-pdf": "3.0.0",
+ "@types/jwk-to-pem": "2.0.1",
+ "date-fns-tz": "1.3.4",
+ "eslint": "8.25.0",
+ "husky": "7.0.4",
+ "mocha": "10.1.0",
+ "mocha-junit-reporter": "2.0.2",
+ "nyc": "15.1.0",
+ "regenerator-runtime": "0.13.9",
+ "sonarqube-verify": "1.0.2",
+ "source-map-support": "0.5.21",
+ "typescript": "4.8.4"
+ }
+}
diff --git a/api/public/images/logo-with-baseline.svg b/api/public/images/logo-with-baseline.svg
new file mode 100644
index 0000000..b2ada22
--- /dev/null
+++ b/api/public/images/logo-with-baseline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/api/public/images/mob-footer.svg b/api/public/images/mob-footer.svg
new file mode 100644
index 0000000..364a607
--- /dev/null
+++ b/api/public/images/mob-footer.svg
@@ -0,0 +1 @@
+
diff --git a/api/public/img/mob-favicon.svg b/api/public/img/mob-favicon.svg
new file mode 100644
index 0000000..ce42712
--- /dev/null
+++ b/api/public/img/mob-favicon.svg
@@ -0,0 +1 @@
+
diff --git a/api/public/index.html b/api/public/index.html
new file mode 100644
index 0000000..8ac88e9
--- /dev/null
+++ b/api/public/index.html
@@ -0,0 +1,88 @@
+
+
+
+
+ API | Mon Compte Mobilité
+
+
+
+
+
+
+
+
+
+
+
+
moB
+
Version 1
+
+
+
+
+
+
+
+
+
diff --git a/api/sonar-project.properties b/api/sonar-project.properties
new file mode 100644
index 0000000..931830a
--- /dev/null
+++ b/api/sonar-project.properties
@@ -0,0 +1,6 @@
+sonar.sources=src
+sonar.sourceEncoding=UTF-8
+
+sonar.exclusions=sonar.exclusions=**/node_modules/**,dist/**,mongo/databaseConfig/**,public/**,coverage/**,**/__tests__/**,**.yml,**.json,**.md,eslintrc.js,openapi-maas.js
+sonar.cpd.exclusions=**/__tests__/**,src/datasources/**,src/models/**,src/repositories/**
+sonar.javascript.lcov.reportPaths=coverage/lcov.info
diff --git a/api/src/__tests__/README.md b/api/src/__tests__/README.md
new file mode 100644
index 0000000..a88f8a5
--- /dev/null
+++ b/api/src/__tests__/README.md
@@ -0,0 +1,3 @@
+# Tests
+
+Please place your tests in this folder.
diff --git a/api/src/__tests__/controllers/citizen.controller.test.ts b/api/src/__tests__/controllers/citizen.controller.test.ts
new file mode 100644
index 0000000..e534aef
--- /dev/null
+++ b/api/src/__tests__/controllers/citizen.controller.test.ts
@@ -0,0 +1,739 @@
+import * as Excel from 'exceljs';
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+import {AnyObject} from '@loopback/repository';
+
+import {
+ CitizenRepository,
+ EnterpriseRepository,
+ CommunityRepository,
+ UserRepository,
+ IncentiveRepository,
+ SubscriptionRepository,
+} from '../../repositories';
+import {CitizenController} from '../../controllers';
+import {
+ CitizenService,
+ FunderService,
+ KeycloakService,
+ JwtService,
+ MailService,
+ SubscriptionService,
+} from '../../services';
+import {ValidationError} from '../../validationError';
+import {
+ AFFILIATION_STATUS,
+ CITIZEN_STATUS,
+ ResourceName,
+ StatusCode,
+ SUBSCRIPTION_STATUS,
+ INCENTIVE_TYPE,
+ IUser,
+ GENDER,
+} from '../../utils';
+import {
+ Enterprise,
+ Citizen,
+ User,
+ CitizenUpdate,
+ Subscription,
+ Affiliation,
+} from '../../models';
+
+describe('CitizenController (unit)', () => {
+ let citizenRepository: StubbedInstanceWithSinonAccessor,
+ communityRepository: StubbedInstanceWithSinonAccessor,
+ mailService: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ kcService: StubbedInstanceWithSinonAccessor,
+ funderService: StubbedInstanceWithSinonAccessor,
+ citizenService: StubbedInstanceWithSinonAccessor,
+ subscriptionService: StubbedInstanceWithSinonAccessor,
+ userRepository: StubbedInstanceWithSinonAccessor,
+ currentUserProfile: IUser,
+ jwtService: StubbedInstanceWithSinonAccessor,
+ subscriptionsRepository: StubbedInstanceWithSinonAccessor,
+ incentivesRepository: StubbedInstanceWithSinonAccessor,
+ controller: CitizenController;
+
+ const salarie = Object.assign(new Citizen(), {
+ id: 'randomInputId',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ birthdate: '1991-11-17',
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: 'enterpriseEmail@gmail.com',
+ }),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ });
+
+ const enterprise: Enterprise = new Enterprise({
+ name: 'enterprise',
+ emailFormat: ['@gmail.com', 'rr'],
+ });
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ givenStubbedService();
+ controller = new CitizenController(
+ mailService,
+ citizenRepository,
+ communityRepository,
+ kcService,
+ funderService,
+ enterpriseRepository,
+ citizenService,
+ subscriptionService,
+ jwtService,
+ userRepository,
+ currentUserProfile,
+ subscriptionsRepository,
+ incentivesRepository,
+ );
+ });
+
+ describe('CitizenController', () => {
+ it('CitizenController create salarie : successful', async () => {
+ citizenService.stubs.createCitizen.resolves({
+ id: 'randomInputId',
+ });
+
+ const result = await controller.create(salarie);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+ });
+
+ it('CitizenController findSalarie: error', async () => {
+ try {
+ citizenService.stubs.findEmployees.resolves({
+ employees: [],
+ employeesCount: 0,
+ });
+ await controller.findSalaries(AFFILIATION_STATUS.AFFILIATED);
+ } catch (err) {
+ expect(err).to.equal(true);
+ }
+ });
+
+ it('CitizenController findSalarie: successful', async () => {
+ citizenService.stubs.findEmployees.resolves({
+ employees: [citizen],
+ employeesCount: 1,
+ });
+
+ const result = await controller.findSalaries(AFFILIATION_STATUS.AFFILIATED);
+ expect(result).to.deepEqual({employees: [citizen], employeesCount: 1});
+ });
+
+ it('CitizenController findById : successful', async () => {
+ citizenRepository.stubs.findById.resolves(salarie);
+ const result = await controller.findById('randomInputId');
+
+ expect(result).to.deepEqual(salarie);
+ });
+
+ it('CitizenController findCitizenId : successful', async () => {
+ userRepository.stubs.findById.resolves(user);
+ citizenRepository.stubs.findById.resolves(mockCitizen);
+ const result = await controller.findCitizenId('randomInputId');
+
+ expect(result).to.deepEqual({lastName: 'Kenny', firstName: 'Gerard'});
+ });
+
+ it('CitizenController validateAffiliation : successful', async () => {
+ const token = {token: 'montoken'};
+ const citizen: any = {
+ id: 'randomId',
+ affiliation: {
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ },
+ };
+ citizenRepository.stubs.findOne.resolves(citizen);
+ citizenService.stubs.checkAffiliation.resolves(citizen);
+ citizenRepository.stubs.updateById.resolves();
+
+ const result = await controller.validateAffiliation(citizen.id, token);
+
+ expect(citizen.affiliation.affiliationStatus).to.equal(
+ AFFILIATION_STATUS.AFFILIATED,
+ );
+ sinon.assert.calledOnceWithExactly(citizenRepository.stubs.updateById, citizen.id, {
+ affiliation: citizen.affiliation,
+ });
+
+ expect(citizenService.stubs.sendValidatedAffiliation.called).true();
+ expect(result).to.Null;
+ });
+
+ it('CitizenController validateAffiliation : successful token', async () => {
+ const salarie: IUser = {
+ id: '',
+ clientName: 'testName-client',
+ token: '',
+ emailVerified: true,
+ roles: ['gestionnaires'],
+ [securityId]: 'testId',
+ };
+ const controller = new CitizenController(
+ mailService,
+ citizenRepository,
+ communityRepository,
+ kcService,
+ funderService,
+ enterpriseRepository,
+ citizenService,
+ subscriptionService,
+ jwtService,
+ userRepository,
+ salarie,
+ subscriptionsRepository,
+ incentivesRepository,
+ );
+
+ const token = {token: 'montoken'};
+ const citizen: any = {
+ id: 'randomId',
+ affiliation: {
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ },
+ };
+ const citizenId: string = citizen.id;
+ citizenService.stubs.checkAffiliation.resolves(citizen);
+ citizenRepository.stubs.updateById.resolves();
+ const result = await controller.validateAffiliation(citizenId, token);
+
+ expect(citizen.affiliation.affiliationStatus).to.equal(
+ AFFILIATION_STATUS.AFFILIATED,
+ );
+ sinon.assert.calledOnceWithExactly(citizenRepository.stubs.updateById, citizen.id, {
+ affiliation: citizen.affiliation,
+ });
+ expect(result).to.Null;
+ });
+
+ it('CitizenController validateAffiliation no token : error', async () => {
+ const token: any = undefined;
+ const citizenId: string = '';
+ const citizen: any = {
+ id: 'randomId',
+ affiliation: {
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ },
+ };
+ try {
+ citizenRepository.stubs.findOne.resolves(citizen);
+ await controller.validateAffiliation(citizenId, token);
+ sinon.assert.fail();
+ expect(citizenRepository.stubs.updateById.called).true();
+ expect(citizenService.stubs.checkAffiliation.called).true();
+ } catch (err) {
+ null;
+ }
+ });
+
+ it('CitizenController disaffiliation updateById : error', async () => {
+ try {
+ citizenRepository.stubs.findById.resolves(
+ new Citizen({
+ id: 'citizenId',
+ affiliation: Object.assign({
+ enterpriseEmail: 'user@example.com',
+ enterpriseId: 'id',
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ }),
+ }),
+ );
+ citizenRepository.stubs.updateById.rejects(new Error('Error'));
+ await controller.disaffiliation('citizenId');
+ } catch (error) {
+ expect(citizenService.stubs.sendDisaffiliationMail.notCalled).true();
+ expect(error).to.deepEqual(new Error('Error'));
+ }
+ });
+
+ it('CitoyensController getCitizenWithDemandes : successful', () => {
+ subscriptionService.stubs.getCitizensWithSubscription.resolves(mockSubscriptions);
+ const citizensList = controller
+ .getCitizensWithSubscriptions('', 0)
+ .then(res => res)
+ .catch(err => err);
+ expect(citizensList).to.deepEqual(mockCitizens);
+ });
+
+ it('CitizenController disaffiliation : success', async () => {
+ citizenRepository.stubs.findById.resolves(
+ new Citizen({
+ id: 'citizenId',
+ affiliation: Object.assign({
+ enterpriseEmail: 'user@example.com',
+ enterpriseId: 'id',
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ }),
+ }),
+ );
+ citizenRepository.stubs.updateById.resolves();
+ citizenService.stubs.sendDisaffiliationMail.resolves();
+ await controller.disaffiliation('citizenId');
+ expect(citizenRepository.stubs.findById.called).true();
+ expect(citizenRepository.stubs.updateById.called).true();
+ expect(citizenService.stubs.sendDisaffiliationMail.called).true();
+ });
+
+ it('CitizenController rejected affiliation : success and send rejection mail', async () => {
+ citizenRepository.stubs.findById.resolves(
+ new Citizen({
+ id: 'citizenId',
+ affiliation: Object.assign({
+ enterpriseEmail: 'user@example.com',
+ enterpriseId: 'id',
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ }),
+ }),
+ );
+ citizenRepository.stubs.updateById.resolves();
+ citizenService.stubs.sendRejectedAffiliation.resolves();
+ await controller.disaffiliation('citizenId');
+ expect(citizenRepository.stubs.findById.called).true();
+ expect(citizenRepository.stubs.updateById.called).true();
+ expect(citizenService.stubs.sendRejectedAffiliation.called).true();
+ });
+
+ it('CitizenController updateById : successful', async () => {
+ const newUserData: CitizenUpdate = {
+ city: 'Paris',
+ postcode: '75010',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ affiliation: Object.assign({
+ enterpriseId: '',
+ enterpriseEmail: '',
+ }),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ };
+
+ citizenRepository.stubs.updateById.resolves();
+ const result = await controller.updateById('randomInputId', newUserData);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+ });
+
+ it('CitizenController updateById user with affiliation data : successful', async () => {
+ const newUserData: CitizenUpdate = {
+ city: 'Paris',
+ postcode: '75010',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ affiliation: Object.assign({
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: 'enterpriseEmail@gmail.com',
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ }),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ };
+
+ enterpriseRepository.stubs.findById.resolves(enterprise);
+ citizenRepository.stubs.findById.resolves(citizen);
+ citizenRepository.stubs.updateById.resolves();
+ citizenService.stubs.sendAffiliationMail.resolves();
+
+ const result = await controller.updateById('randomInputId', newUserData);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ enterpriseRepository.stubs.findById.restore();
+ citizenService.stubs.sendAffiliationMail.restore();
+ });
+
+ it('CitizenController updateById user with enterprise email only : successful', async () => {
+ const newUserData: CitizenUpdate = {
+ city: 'Paris',
+ postcode: '75010',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ affiliation: Object.assign({
+ enterpriseId: '',
+ enterpriseEmail: 'enterpriseEmail@gmail.com',
+ }),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ };
+
+ citizenRepository.stubs.updateById.resolves();
+
+ const result = await controller.updateById('randomInputId', newUserData);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+ });
+
+ it('CitizenController updateById user with enterprise id only : successful', async () => {
+ const newUserData: CitizenUpdate = {
+ city: 'Paris',
+ postcode: '75010',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ affiliation: Object.assign({
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: '',
+ }),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ };
+
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+ citizenRepository.stubs.updateById.resolves();
+
+ const result = await controller.updateById('randomInputId', newUserData);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+ });
+
+ it('CitoyensController generateUserRGPDExcelFile : successful', async () => {
+ const workbook = new Excel.Workbook();
+ citizenService.stubs.generateExcelRGPD.resolves(await workbook.xlsx.writeBuffer());
+ citizenRepository.stubs.findById.resolves(mockCitizen);
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+ incentivesRepository.stubs.find.resolves([]);
+ subscriptionsRepository.stubs.find.resolves([]);
+ const response: any = {
+ status: function () {
+ return this;
+ },
+ contentType: function () {
+ return this;
+ },
+ send: (buffer: Buffer) => buffer,
+ };
+ try {
+ const result = await controller.generateUserRGPDExcelFile(
+ 'randomInputId',
+ response,
+ );
+ expect(result).to.be.instanceOf(Buffer);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('CitizenController deleteCitizenAccount : successful', async () => {
+ // Stub method
+ citizenRepository.stubs.findById.withArgs('randomInputId').resolves(mockCitizen);
+ citizenRepository.stubs.deleteById.resolves();
+ kcService.stubs.deleteUserKc.resolves({
+ id: 'randomInputId',
+ });
+ subscriptionsRepository.stubs.find.resolves([subscription]);
+ subscriptionsRepository.stubs.updateById.resolves();
+ citizenService.stubs.sendDeletionMail.resolves();
+ const result = await controller.deleteCitizenAccount('randomInputId');
+ // // Checks
+ expect(citizenRepository.stubs.findById.called).true();
+ expect(citizenRepository.stubs.deleteById.called).true();
+ expect(kcService.stubs.deleteUserKc.called).true();
+ expect(citizenService.stubs.sendDeletionMail.called).true();
+ expect(result).to.Null;
+ });
+ });
+
+ it('CitizenController findConsentsById : successful', async () => {
+ const expected = [
+ {
+ clientId: 'simulation-maas-client',
+ name: 'simulation maas client',
+ },
+ {
+ clientId: 'mulhouse-maas-client',
+ name: 'mulhouse maas client',
+ },
+ ];
+ kcService.stubs.listConsents.resolves(consents);
+ citizenService.stubs.getClientList.resolves(clients);
+
+ const result = await controller.findConsentsById('randomId');
+
+ expect(result).to.deepEqual(expected);
+
+ kcService.stubs.listConsents.restore();
+ citizenService.stubs.getClientList.restore();
+ });
+
+ it('CitizenController deleteConsentById : successful', async () => {
+ kcService.stubs.deleteConsent.resolves();
+
+ await controller.deleteConsentById('randomId', 'randomClientId');
+
+ expect(kcService.stubs.deleteConsent.onCall(1));
+
+ kcService.stubs.deleteConsent.restore();
+ });
+
+ it('CitizenController createCitizenFc salarie : successful', async () => {
+ citizenService.stubs.createCitizen.resolves({
+ id: 'randomInputId',
+ });
+
+ const result = await controller.createCitizenFc('randomInputId', salarie);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+ });
+
+ function givenStubbedRepository() {
+ citizenRepository = createStubInstance(CitizenRepository);
+ funderService = createStubInstance(FunderService);
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ userRepository = createStubInstance(UserRepository);
+ incentivesRepository = createStubInstance(IncentiveRepository);
+ subscriptionsRepository = createStubInstance(SubscriptionRepository);
+
+ currentUserProfile = {
+ id: 'idUser',
+ clientName: 'testName-client',
+ token: '',
+ funderName: 'funderName',
+ emailVerified: true,
+ roles: ['gestionnaires'],
+ [securityId]: 'testId',
+ };
+ }
+
+ function givenStubbedService() {
+ kcService = createStubInstance(KeycloakService);
+ citizenService = createStubInstance(CitizenService);
+ subscriptionService = createStubInstance(SubscriptionService);
+ }
+});
+
+const consents = [
+ {
+ clientId: 'simulation-maas-client',
+ },
+ {
+ clientId: 'mulhouse-maas-client',
+ },
+];
+
+const clients = [
+ {
+ name: 'simulation maas client',
+ clientId: 'simulation-maas-client',
+ },
+ {
+ name: 'mulhouse maas client',
+ clientId: 'mulhouse-maas-client',
+ },
+ {
+ name: 'paris maas client',
+ clientId: 'paris-maas-client',
+ },
+];
+
+const user = new User({
+ id: 'idUser',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'someFunderId',
+ roles: ['gestionnaires'],
+ communityIds: ['id1', 'id2'],
+});
+
+const citizen = new Citizen({
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'Xina',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Zhong',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+});
+
+const mockCitizen = new Citizen({
+ id: 'randomInputId',
+ email: 'kennyg@gmail.com',
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'Gerard',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Kenny',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ birthDate: Object.assign({
+ value: '1994-02-18T00:00:00.000Z',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ city: 'Mulhouse',
+ postcode: '75000',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'someFunderId',
+ enterpriseEmail: 'walid.housni@adevinta.com',
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ }),
+});
+
+const mockEnterprise = new Enterprise({
+ id: 'randomInputIdEntreprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'nameEntreprise',
+ siretNumber: 50,
+ budgetAmount: 102,
+ employeesCount: 100,
+ hasManualAffiliation: true,
+});
+
+const mockCitizens: Promise = new Promise(() => {
+ return [
+ {
+ citizensData: [
+ {
+ citizenId: '260a6356-3261-4335-bca8-4c1f8257613d',
+ lastName: 'leYellow',
+ firstName: 'Bob',
+ },
+ ],
+ totalCitizens: 1,
+ },
+ ];
+});
+
+const mockSubscriptions: Record[] = [
+ {
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ incentiveTransportList: ['vélo'],
+ consent: true,
+ specificFields: [
+ {
+ title: 'newField1',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'newField1',
+ },
+ {
+ inputChoice: 'newField11',
+ },
+ ],
+ },
+ },
+ {
+ title: 'newField2',
+ inputFormat: 'Texte',
+ },
+ ],
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ },
+ {
+ id: 'randomInputId1',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ incentiveTransportList: ['vélo'],
+ consent: true,
+ specificFields: [
+ {
+ title: 'newField1',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'newField1',
+ },
+ {
+ inputChoice: 'newField11',
+ },
+ ],
+ },
+ },
+ {
+ title: 'newField2',
+ inputFormat: 'Texte',
+ },
+ ],
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ },
+];
+
+const subscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'randomInputId',
+ funderName: 'Capgemini',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'Covoiturez à Mulhouse',
+ incentiveTransportList: ['covoiturage'],
+ citizenId: 'randomInputId',
+ lastName: 'térieur',
+ firstName: 'alain',
+ email: 'email.salarie@yopmail.com',
+ city: "L'eau Sangèles",
+ postcode: '99000',
+ birthdate: '1970-01-01T00:00:00.000Z',
+ communityId: 'randomInputId',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ attachments: undefined,
+ createdAt: new Date('2022-04-26T15:17:23.531Z'),
+ updatedAt: new Date('2022-04-26T15:17:30.672Z'),
+ funderId: 'randomInputId',
+ subscriptionValidation: undefined,
+ subscriptionRejection: undefined,
+ specificFields: undefined,
+ isCitizenDeleted: false,
+ enterpriseEmail: 'salarie.mcm.pro@yopmail.com',
+});
diff --git a/api/src/__tests__/controllers/collectivity.controller.test.ts b/api/src/__tests__/controllers/collectivity.controller.test.ts
new file mode 100644
index 0000000..fb16b51
--- /dev/null
+++ b/api/src/__tests__/controllers/collectivity.controller.test.ts
@@ -0,0 +1,127 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {CollectivityRepository} from '../../repositories';
+import {CollectivityController} from '../../controllers';
+import {KeycloakService} from '../../services';
+import {ValidationError} from '../../validationError';
+
+describe('CollectivityController (unit)', () => {
+ let repository: StubbedInstanceWithSinonAccessor,
+ kcService: StubbedInstanceWithSinonAccessor,
+ controller: CollectivityController;
+ const input = {
+ id: 'randomInputId',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ name: 'name',
+ citizensCount: 10,
+ mobilityBudget: 12,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ };
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ givenStubbedService();
+ controller = new CollectivityController(repository, kcService);
+ });
+
+ describe('CollectivityController', () => {
+ it('CollectivityController create : fails because of creategroupkc error', async () => {
+ const errorKc = new ValidationError(`funders.error.topgroup`, '/funder');
+ try {
+ kcService.stubs.createGroupKc.rejects(errorKc);
+
+ await controller.create(input);
+ } catch (err) {
+ expect(err.message).to.equal(errorKc.message);
+ }
+
+ kcService.stubs.createGroupKc.restore();
+ });
+
+ it('CollectivityController create : fails because of createUserkc error', async () => {
+ const errorKc = new ValidationError(`email.error.unique`, '/email');
+ try {
+ kcService.stubs.createGroupKc.resolves({id: 'randomId'});
+ kcService.stubs.createUserKc.rejects(errorKc);
+ kcService.stubs.deleteGroupKc.resolves();
+
+ await controller.create(input);
+ } catch (err) {
+ expect(err.message).to.equal(errorKc.message);
+ }
+
+ kcService.stubs.createGroupKc.restore();
+ kcService.stubs.createUserKc.restore();
+ kcService.stubs.deleteGroupKc.restore();
+ });
+
+ it('CollectivityController create : fails because of creategCollectivity error', async () => {
+ const errorRepository = 'can not add user to database';
+ try {
+ kcService.stubs.createGroupKc.resolves({id: 'randomId'});
+ kcService.stubs.createUserKc.resolves({id: 'randomId'});
+ repository.stubs.create.rejects(errorRepository);
+ kcService.stubs.deleteGroupKc.resolves();
+ kcService.stubs.deleteUserKc.resolves();
+
+ await controller.create(input);
+ } catch (err) {
+ expect(err.name).to.equal(errorRepository);
+ }
+
+ kcService.stubs.createGroupKc.restore();
+ kcService.stubs.createUserKc.restore();
+ repository.stubs.create.restore();
+ kcService.stubs.deleteGroupKc.restore();
+ kcService.stubs.deleteUserKc.restore();
+ });
+
+ it('CollectivityController create : successful', async () => {
+ kcService.stubs.createGroupKc.resolves({id: 'randomId'});
+ kcService.stubs.createUserKc.resolves({id: 'randomId'});
+ repository.stubs.create.resolves(input);
+
+ const result = await controller.create(input);
+ expect(result).to.deepEqual(input);
+
+ kcService.stubs.createGroupKc.restore();
+ kcService.stubs.createUserKc.restore();
+ repository.stubs.create.restore();
+ });
+
+ it('CollectivityController count : successful', async () => {
+ const countRes = {
+ count: 10,
+ };
+
+ repository.stubs.count.resolves(countRes);
+ const result = await controller.count();
+
+ expect(result).to.deepEqual(countRes);
+ });
+
+ it('CollectivityController find : successful', async () => {
+ repository.stubs.find.resolves([input]);
+ const result = await controller.find();
+
+ expect(result).to.deepEqual([input]);
+ });
+ });
+
+ function givenStubbedRepository() {
+ repository = createStubInstance(CollectivityRepository);
+ }
+
+ function givenStubbedService() {
+ kcService = createStubInstance(KeycloakService);
+ }
+});
diff --git a/api/src/__tests__/controllers/contact.controller.test.ts b/api/src/__tests__/controllers/contact.controller.test.ts
new file mode 100644
index 0000000..a711214
--- /dev/null
+++ b/api/src/__tests__/controllers/contact.controller.test.ts
@@ -0,0 +1,41 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {ContactController} from '../../controllers/contact.controller';
+import {ContactService, MailService} from '../../services';
+
+import {Contact} from '../../models';
+import {USERTYPE} from '../../utils';
+
+describe('Contact Controller ', () => {
+ let contactService: StubbedInstanceWithSinonAccessor,
+ controller: ContactController,
+ mailService: StubbedInstanceWithSinonAccessor;
+ beforeEach(() => {
+ givenStubbedService();
+ controller = new ContactController(contactService, mailService);
+ });
+
+ it('post(v1/contact)', async () => {
+ contactService.stubs.sendMailClient.resolves(mockContact);
+ const result = await controller.create(mockContact);
+
+ expect(result).to.deepEqual(mockContact);
+ });
+
+ function givenStubbedService() {
+ contactService = createStubInstance(ContactService);
+ }
+});
+
+const mockContact = new Contact({
+ firstName: 'firstName',
+ lastName: 'lastName',
+ email: 'test@test.com',
+ userType: USERTYPE.CITIZEN,
+ postcode: '55555',
+ message: 'Message test',
+ tos: true,
+});
diff --git a/api/src/__tests__/controllers/dashboard.controller.test.ts b/api/src/__tests__/controllers/dashboard.controller.test.ts
new file mode 100644
index 0000000..107bfc0
--- /dev/null
+++ b/api/src/__tests__/controllers/dashboard.controller.test.ts
@@ -0,0 +1,265 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {AnyObject} from '@loopback/repository';
+import {securityId} from '@loopback/security';
+
+import {DashboardController} from '../../controllers';
+import {SubscriptionRepository, UserRepository} from '../../repositories';
+import {Subscription, User} from '../../models';
+import {IUser, SUBSCRIPTION_STATUS} from '../../utils';
+
+describe('Dashboard Controller', () => {
+ let subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ userRepository: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ userRepository = createStubInstance(UserRepository);
+ });
+
+ it('get(/v1/dashboards/citizens) success', done => {
+ const controller = new DashboardController(
+ subscriptionRepository,
+ userRepository,
+ currentUser,
+ );
+
+ userRepository.stubs.findOne.resolves(mockUser);
+ subscriptionRepository.stubs.find.resolves([
+ mockSubscriptions1,
+ mockSubscriptions2,
+ mockSubscriptions3,
+ ]);
+
+ const citizenDashboardService = controller
+ .findCitizen('2020', 'all')
+ .then(res => res)
+ .catch(err => err);
+
+ expect(citizenDashboardService).to.deepEqual(mockSubscriptionDashboardResult);
+ done();
+ });
+
+ it('get(/v1/dashboards/citizens) error', done => {
+ const controller = new DashboardController(
+ subscriptionRepository,
+ userRepository,
+ currentUser,
+ );
+
+ subscriptionRepository.stubs.find.rejects(mockSubscriptionDashboardError);
+
+ const citizenDashboardService = controller
+ .findCitizen('2020', 'all')
+ .then(res => res)
+ .catch(err => err);
+
+ expect(citizenDashboardService).to.deepEqual(mockSubscriptionDashboardError);
+ done();
+ });
+
+ it('get(/v1/dashboards/subscriptions) no semester success', done => {
+ const controller = new DashboardController(
+ subscriptionRepository,
+ userRepository,
+ currentUser,
+ );
+
+ userRepository.stubs.findOne.resolves(mockUser);
+ subscriptionRepository.stubs.find.resolves([
+ mockSubscriptions1,
+ mockSubscriptions2,
+ mockSubscriptions3,
+ ]);
+
+ const subscriptionDashboardResult = controller
+ .find('2019', 'all')
+ .then(res => res)
+ .catch(err => err);
+
+ expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult);
+ done();
+ });
+
+ it('get(/v1/dashboards/subscriptions) semester 1 success', done => {
+ const controller = new DashboardController(
+ subscriptionRepository,
+ userRepository,
+ currentUser,
+ );
+
+ userRepository.stubs.findOne.resolves(mockUser);
+ subscriptionRepository.stubs.find.resolves([
+ mockSubscriptions1,
+ mockSubscriptions2,
+ mockSubscriptions3,
+ ]);
+
+ const subscriptionDashboardResult = controller
+ .find('2019', '1')
+ .then(res => res)
+ .catch(err => err);
+
+ expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult);
+ done();
+ });
+
+ it('get(/v1/dashboards/subscriptions) semester 2 success', done => {
+ const controller = new DashboardController(
+ subscriptionRepository,
+ userRepository,
+ currentUser,
+ );
+
+ userRepository.stubs.findOne.resolves(mockUser);
+ subscriptionRepository.stubs.find.resolves([
+ mockSubscriptions1,
+ mockSubscriptions2,
+ mockSubscriptions3,
+ ]);
+
+ const subscriptionDashboardResult = controller
+ .find('2019', '2')
+ .then(res => res)
+ .catch(err => err);
+
+ expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult);
+ done();
+ });
+
+ it('get(/v1/dashboards/subscriptions) Brouillon success', done => {
+ const controller = new DashboardController(
+ subscriptionRepository,
+ userRepository,
+ currentUser,
+ );
+
+ userRepository.stubs.findOne.resolves(mockUser);
+ subscriptionRepository.stubs.find.resolves([
+ mockSubscriptions1,
+ mockSubscriptions2,
+ mockSubscriptions3,
+ ]);
+
+ const subscriptionDashboardResult = controller
+ .find('2019', '2')
+ .then(res => res)
+ .catch(err => err);
+
+ expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardResult);
+ done();
+ });
+
+ it('get(/v1/dashboards/subscriptions) error', done => {
+ const controller = new DashboardController(
+ subscriptionRepository,
+ userRepository,
+ currentUser,
+ );
+
+ userRepository.stubs.findOne.resolves(mockUser);
+ subscriptionRepository.stubs.find.resolves([
+ mockSubscriptions1,
+ mockSubscriptions2,
+ mockSubscriptions3,
+ ]);
+
+ const subscriptionDashboardResult = controller.find('2019', '2');
+
+ expect(subscriptionDashboardResult).to.deepEqual(mockSubscriptionDashboardError);
+ done();
+ });
+});
+
+const mockSubscriptionDashboardResult: Promise = new Promise(() => {
+ return [
+ {
+ status: '0',
+ count: '3',
+ },
+ {
+ status: '1',
+ count: '2',
+ },
+ ];
+});
+
+const mockSubscriptionDashboardError: Promise = new Promise(() => {
+ throw new Error('error');
+});
+
+const mockSubscriptions1 = new Subscription({
+ id: 'randomInputId1',
+ incentiveId: 'incentiveId1',
+ funderName: 'funderName',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: '7654321',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ communityId: 'id1',
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const mockSubscriptions2 = new Subscription({
+ id: 'randomInputId2',
+ incentiveId: 'incentiveId1',
+ funderName: 'funderName',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: '1234567',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ communityId: 'id1',
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const mockSubscriptions3 = new Subscription({
+ id: 'randomInputId3',
+ incentiveId: 'incentiveId2',
+ funderName: 'funderName',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: '1234567',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ communityId: 'id1',
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const currentUser: IUser = {
+ id: 'idEnterprise',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/entreprises/Capgemini'],
+ roles: ['gestionnaires'],
+ [securityId]: 'idEnterprise',
+};
+
+const mockUser = new User({
+ id: 'idUser',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['gestionnaires'],
+ communityIds: ['id1', 'id2'],
+});
diff --git a/api/src/__tests__/controllers/enterprise.controller.test.ts b/api/src/__tests__/controllers/enterprise.controller.test.ts
new file mode 100644
index 0000000..7623812
--- /dev/null
+++ b/api/src/__tests__/controllers/enterprise.controller.test.ts
@@ -0,0 +1,210 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {EnterpriseRepository, UserEntityRepository} from '../../repositories';
+import {EnterpriseController} from '../../controllers';
+import {KeycloakService} from '../../services';
+import {ValidationError} from '../../validationError';
+import {Enterprise, UserEntity, UserEntityRelations} from '../../models';
+
+describe('EnterpriseController (unit)', () => {
+ let repository: StubbedInstanceWithSinonAccessor,
+ userEntityRepository: StubbedInstanceWithSinonAccessor,
+ kcService: StubbedInstanceWithSinonAccessor,
+ controller: EnterpriseController;
+ const input = {
+ id: 'randomInputId',
+ firstName: 'testName',
+ lastName: 'testLastName',
+ email: 'test@outlook.com',
+ emailFormat: ['@outlook.com', '@outlook.fr', '@outlook.xxx'],
+ name: 'test',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ isHris: false,
+ hasManualAffiliation: false,
+ clientId: '',
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ };
+
+ const inputClient = {
+ id: 'randomInputId',
+ firstName: 'testName',
+ lastName: 'testLastName',
+ email: 'test@outlook.com',
+ emailFormat: ['@outlook.com', '@outlook.fr', '@outlook.xxx'],
+ name: 'test',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ isHris: false,
+ hasManualAffiliation: false,
+ clientId: 'clientId',
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ };
+
+ const mockEnterpriseWithTwoOptions = new Enterprise({
+ id: 'randomInputId',
+ name: 'test',
+ emailFormat: ['@outlook.com', '@outlook.fr', '@outlook.xxx'],
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ isHris: true,
+ hasManualAffiliation: true,
+ clientId: 'clientId',
+ });
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ givenStubbedService();
+ controller = new EnterpriseController(repository, userEntityRepository, kcService);
+ });
+
+ describe('EnterpriseController', () => {
+ it('EnterpriseController failing case (createGroupKc) error', async () => {
+ const errorKc = new ValidationError(`funders.error.topgroup`, '/funder');
+ try {
+ kcService.stubs.createGroupKc.rejects(errorKc);
+
+ await controller.create(input);
+ } catch (error) {
+ expect(error.message).to.equal(errorKc.message);
+ }
+ kcService.stubs.createGroupKc.restore();
+ });
+
+ it('EnterpriseController create : fails because of createUserkc error', async () => {
+ const errorKc = new ValidationError(`email.error.unique`, '/email');
+ try {
+ kcService.stubs.createGroupKc.resolves({id: 'randomId'});
+ kcService.stubs.createUserKc.rejects(errorKc);
+ kcService.stubs.deleteGroupKc.resolves();
+ userEntityRepository.stubs.getServiceUser.resolves({
+ id: 'id',
+ } as UserEntity & UserEntityRelations);
+ await controller.create(input);
+ } catch (error) {
+ expect(error.message).to.equal(errorKc.message);
+ }
+
+ kcService.stubs.createGroupKc.restore();
+ kcService.stubs.createUserKc.restore();
+ kcService.stubs.deleteGroupKc.restore();
+ });
+ it('EnterpriseController create : fails because of createEnterprise error', async () => {
+ const errorRepository = 'can not add user to database';
+ try {
+ kcService.stubs.createGroupKc.resolves({id: 'randomId'});
+ kcService.stubs.createUserKc.resolves({id: 'randomId'});
+ repository.stubs.create.rejects(errorRepository);
+ kcService.stubs.deleteGroupKc.resolves();
+ kcService.stubs.deleteUserKc.resolves();
+
+ await controller.create(input);
+ } catch (err) {
+ expect(err.name).to.equal(errorRepository);
+ }
+
+ kcService.stubs.createGroupKc.restore();
+ kcService.stubs.createUserKc.restore();
+ repository.stubs.create.restore();
+ kcService.stubs.deleteGroupKc.restore();
+ kcService.stubs.deleteUserKc.restore();
+ });
+
+ it('EnterpriseController create : Client id not error', async () => {
+ const errorRepository = 'can not add user to database';
+ try {
+ kcService.stubs.createGroupKc.resolves({id: 'randomId'});
+ kcService.stubs.createUserKc.resolves({id: 'randomId'});
+ repository.stubs.create.rejects(errorRepository);
+ kcService.stubs.deleteGroupKc.resolves();
+ kcService.stubs.deleteUserKc.resolves();
+ userEntityRepository.stubs.getServiceUser.rejects();
+ await controller.create(inputClient);
+ } catch (err) {
+ expect(err.message).to.equal('Error');
+ }
+
+ kcService.stubs.createGroupKc.restore();
+ kcService.stubs.createUserKc.restore();
+ repository.stubs.create.restore();
+ kcService.stubs.deleteGroupKc.restore();
+ kcService.stubs.deleteUserKc.restore();
+ userEntityRepository.stubs.getServiceUser.restore();
+ });
+
+ it('EnterpriseController create : fails because of two options checked error', async () => {
+ const twoOptionsError = new ValidationError(
+ `enterprise.options.invalid`,
+ '/enterpriseInvalidOptions',
+ );
+ try {
+ await controller.create(mockEnterpriseWithTwoOptions);
+ } catch (error) {
+ expect(error.message).to.equal(twoOptionsError.message);
+ }
+ });
+
+ it('EnterpriseController create : successful', async () => {
+ kcService.stubs.createGroupKc.resolves({id: 'randomId'});
+ kcService.stubs.createUserKc.resolves({id: 'randomId'});
+ repository.stubs.create.resolves(input);
+
+ const result = await controller.create(input);
+ expect(result).to.deepEqual(input);
+
+ kcService.stubs.createGroupKc.restore();
+ kcService.stubs.createUserKc.restore();
+ repository.stubs.create.restore();
+ });
+
+ it('EnterpriseController count: Successful', async () => {
+ const countRes = {
+ count: 10,
+ };
+
+ repository.stubs.count.resolves(countRes);
+ const result = await controller.count();
+
+ expect(result).to.deepEqual(countRes);
+ });
+
+ it('EnterpriseController find: successful', async () => {
+ repository.stubs.find.resolves([input]);
+ const result = await controller.find();
+
+ expect(result).to.deepEqual([input]);
+ });
+
+ it('EnterpriseController find emailFormatList: successful', async () => {
+ repository.stubs.find.resolves([
+ new Enterprise({id: 'id', name: 'name', emailFormat: ['@format.com']}),
+ ]);
+ const result = await controller.findEmailFormat();
+ expect(result).to.deepEqual([
+ new Enterprise({id: 'id', name: 'name', emailFormat: ['@format.com']}),
+ ]);
+ });
+ });
+
+ function givenStubbedRepository() {
+ repository = createStubInstance(EnterpriseRepository);
+ userEntityRepository = createStubInstance(UserEntityRepository);
+ }
+
+ function givenStubbedService() {
+ kcService = createStubInstance(KeycloakService);
+ }
+});
diff --git a/api/src/__tests__/controllers/external/subscriptionV1.controller.test.ts b/api/src/__tests__/controllers/external/subscriptionV1.controller.test.ts
new file mode 100644
index 0000000..a9b1f2b
--- /dev/null
+++ b/api/src/__tests__/controllers/external/subscriptionV1.controller.test.ts
@@ -0,0 +1,634 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {Readable} from 'stream';
+const amqp = require('amqplib');
+
+import {
+ SubscriptionRepository,
+ IncentiveRepository,
+ CitizenRepository,
+ MetadataRepository,
+ CommunityRepository,
+ EnterpriseRepository,
+ CollectivityRepository,
+} from '../../../repositories';
+import {SubscriptionV1Controller} from '../../../controllers/external';
+import {
+ Subscription,
+ Citizen,
+ Incentive,
+ Metadata,
+ CreateSubscription,
+ EncryptionKey,
+ Collectivity,
+ PrivateKeyAccess,
+} from '../../../models';
+import {
+ MailService,
+ RabbitmqService,
+ S3Service,
+ SubscriptionService,
+} from '../../../services';
+import * as invoiceUtils from '../../../utils/invoice';
+import {AFFILIATION_STATUS, IUser, SUBSCRIPTION_STATUS} from '../../../utils';
+import {Enterprise} from '../../../models/enterprise';
+import {Community} from '../../../models/community';
+
+describe('SubscriptionController (unit)', () => {
+ let subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ collectivityRepository: StubbedInstanceWithSinonAccessor,
+ incentiveRepository: StubbedInstanceWithSinonAccessor,
+ citizenRepository: StubbedInstanceWithSinonAccessor,
+ metadataRepository: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ communityRepository: StubbedInstanceWithSinonAccessor,
+ rabbitmqService: StubbedInstanceWithSinonAccessor,
+ s3Service: StubbedInstanceWithSinonAccessor,
+ mailService: StubbedInstanceWithSinonAccessor,
+ subscptionService: StubbedInstanceWithSinonAccessor,
+ controller: SubscriptionV1Controller;
+ const currentUser: IUser = {
+ id: 'citizenId',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/citizens'],
+ roles: ['offline_access', 'uma_authorization'],
+ [securityId]: 'citizenId',
+ };
+ const input = new CreateSubscription({
+ incentiveId: 'incentiveId',
+ consent: true,
+ });
+
+ const inputRepo = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'citizenId',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+ const inputRepoDraft = new Subscription({
+ id: 'randomInputId',
+ citizenId: 'citizenId',
+ email: 'email@gmail.com',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ incentiveType: 'AideEmployeur',
+ });
+ const inputRepoDraft1 = new Subscription({
+ id: 'randomInputId',
+ citizenId: 'citizenId',
+ email: 'email@gmail.com',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ communityId: 'randomInputId',
+ incentiveType: 'AideEmployeur',
+ specificFields: ['test', 'test2'],
+ });
+ const inputRepoDraft2 = new Subscription({
+ id: 'randomInputId',
+ citizenId: 'citizenId',
+ email: 'email@gmail.com',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ communityId: 'randomInputId',
+ incentiveType: 'AideEmployeur1',
+ specificFields: ['test', 'test2'],
+ });
+ const hrisFalse = new Enterprise({
+ id: 'randomInputId',
+ isHris: false,
+ });
+
+ const hrisTrue = new Enterprise({
+ id: 'randomInputId',
+ isHris: true,
+ });
+
+ const name: Community = new Community({
+ id: 'randomInputId',
+ name: 'RabbitCo',
+ });
+
+ const citoyen: Citizen = new Citizen({
+ id: 'test',
+ affiliation: Object.assign({
+ enterpriseId: 'test',
+ enterpriseEmail: 'test@gmail.com',
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ }),
+ });
+
+ const subscriptionPayload = {
+ lastName: 'test',
+ firstName: 'test',
+ birthdate: 'test',
+ citizenId: 'test',
+ incentiveId: 'test',
+ subscriptionId: 'test',
+ email: 'test',
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ communityName: 'test',
+ specificFields: JSON.stringify(['test', 'test']),
+ attachments: ['urlTest', 'urlTest'],
+ encryptedAESKey: 'encryptedAESKey',
+ encryptedIV: 'encryptedIV',
+ encryptionKeyId: 'encryptionKeyId',
+ encryptionKeyVersion: 1,
+ };
+
+ const fileList: any[] = [
+ {
+ originalname: 'test1.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ {
+ originalname: 'test2.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ ];
+ const mockAttachment = {
+ body: {
+ data: JSON.stringify({
+ metadataId: 'randomMetadataId',
+ }),
+ },
+ files: fileList,
+ };
+ const mockAttachmentWithoutMetadata = {
+ body: '',
+ files: fileList,
+ };
+ const mockAttachmentWithoutMetadataWithoutFiles = {
+ body: '',
+ files: [],
+ };
+ const mockAttachmentWithoutFiles = {
+ body: {
+ data: JSON.stringify({
+ metadataId: 'randomMetadataId',
+ }),
+ },
+ files: [],
+ };
+ const invoiceMock = {
+ originalname: 'invoice.pdf',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'application/pdf/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ };
+ const attachmentDataMock: Metadata = Object.assign(new Metadata(), {
+ attachmentMetadata: {
+ invoices: [
+ {
+ enterprise: {
+ enterpriseName: 'IDF Mobilités',
+ sirenNumber: '362521879',
+ siretNumber: '36252187900034',
+ apeCode: '4711D',
+ enterpriseAddress: {
+ zipCode: 75018,
+ city: 'Paris',
+ street: '6 rue Lepic',
+ },
+ },
+ customer: {
+ customerId: '123789',
+ customerName: 'DELOIN',
+ customerSurname: 'Alain',
+ },
+ transaction: {
+ orderId: '30723',
+ purchaseDate: new Date('2021-03-03T14:54:18+01:00'),
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ },
+ products: [
+ {
+ productName: 'Forfait Navigo Mois',
+ quantity: 1,
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ percentTaxes: 10,
+ productDetails: {
+ periodicity: 'Mensuel',
+ zoneMin: 1,
+ zoneMax: 5,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ const response: any = {
+ status: function () {
+ return this;
+ },
+ contentType: function () {
+ return this;
+ },
+ send: (body: any) => body,
+ };
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ controller = new SubscriptionV1Controller(
+ subscriptionRepository,
+ incentiveRepository,
+ citizenRepository,
+ metadataRepository,
+ enterpriseRepository,
+ communityRepository,
+ rabbitmqService,
+ s3Service,
+ mailService,
+ currentUser,
+ subscptionService,
+ collectivityRepository,
+ );
+ });
+
+ describe('SubscriptionController', () => {
+ it('SubscriptionV1Controller create : successful', async () => {
+ subscriptionRepository.stubs.create.resolves(inputRepo);
+ incentiveRepository.stubs.findById.resolves(mockIncentive);
+ citizenRepository.stubs.findById.resolves(mockCitizen);
+ const result = await controller.createSubscription(input);
+
+ expect(result.id).to.equal(inputRepo.id);
+ });
+
+ it('SubscriptionV1Controller create : error', async () => {
+ try {
+ subscriptionRepository.stubs.create.rejects('Error');
+ incentiveRepository.stubs.findById.resolves(mockIncentive);
+ citizenRepository.stubs.findById.resolves(mockCitizen);
+ await controller.createSubscription(input);
+ } catch (err) {
+ expect(err.name).to.equal('Error');
+ }
+ });
+
+ it('SubscriptionV1Controller addAttachments : successful with metadata, without files', async () => {
+ subscriptionRepository.stubs.findById.resolves(newSubscription);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ const citizenFindByIdStub = citizenRepository.stubs.findById.resolves(
+ new Citizen({id: 'Citizen'}),
+ );
+ const metadataFindByIdStub =
+ metadataRepository.stubs.findById.resolves(attachmentDataMock);
+ const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves();
+ const subscriptionUpdateByIdStub =
+ subscriptionRepository.stubs.updateById.resolves();
+ const invoiceStub = sinon
+ .stub(invoiceUtils, 'generatePdfInvoices')
+ .resolves([invoiceMock]);
+ s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']);
+ const result = await controller.addAttachments(
+ 'randomInputId',
+ mockAttachmentWithoutFiles,
+ );
+ expect(result.id).to.equal(inputRepo.id);
+ sinon.assert.calledOnce(citizenFindByIdStub);
+ sinon.assert.calledOnce(metadataFindByIdStub);
+ sinon.assert.calledOnce(metadataDeleteByIdStub);
+ sinon.assert.calledOnce(subscriptionUpdateByIdStub);
+ invoiceStub.restore();
+ });
+
+ it('SubscriptionV1Controller addAttachments : successful with files, without metadata', async () => {
+ subscriptionRepository.stubs.findById.resolves(newSubscription);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ const citizenFindByIdStub = citizenRepository.stubs.findById.resolves(
+ new Citizen({id: 'Citizen'}),
+ );
+ const metadataFindByIdStub = metadataRepository.stubs.findById.resolves(undefined);
+ const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves();
+ const subscriptionUpdateByIdStub =
+ subscriptionRepository.stubs.updateById.resolves();
+ const invoiceStub = sinon.stub(invoiceUtils, 'generatePdfInvoices').resolves([]);
+ s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']);
+ const result = await controller.addAttachments(
+ 'randomInputId',
+ mockAttachmentWithoutMetadata,
+ );
+ expect(result.id).to.equal(inputRepo.id);
+ sinon.assert.calledOnce(citizenFindByIdStub);
+ sinon.assert.notCalled(metadataFindByIdStub);
+ sinon.assert.notCalled(metadataDeleteByIdStub);
+ sinon.assert.calledOnce(subscriptionUpdateByIdStub);
+ sinon.assert.notCalled(invoiceStub);
+ invoiceStub.restore();
+ });
+
+ it('SubscriptionV1Controller addAttachments : successful with metadata and files', async () => {
+ subscriptionRepository.stubs.findById.resolves(newSubscription);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ const citizenFindByIdStub = citizenRepository.stubs.findById.resolves(
+ new Citizen({id: 'Citizen'}),
+ );
+ const metadataFindByIdStub =
+ metadataRepository.stubs.findById.resolves(attachmentDataMock);
+ const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves();
+ const subscriptionUpdateByIdStub =
+ subscriptionRepository.stubs.updateById.resolves();
+ const invoiceStub = sinon
+ .stub(invoiceUtils, 'generatePdfInvoices')
+ .resolves([invoiceMock]);
+ s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']);
+ const result = await controller.addAttachments('randomInputId', mockAttachment);
+ expect(result.id).to.equal(inputRepo.id);
+ sinon.assert.calledOnce(citizenFindByIdStub);
+ sinon.assert.calledOnce(metadataFindByIdStub);
+ sinon.assert.calledOnce(metadataDeleteByIdStub);
+ sinon.assert.calledOnce(subscriptionUpdateByIdStub);
+ invoiceStub.restore();
+ });
+
+ it('SubscriptionV1Controller addAttachments : successful without metadata, without files', async () => {
+ subscriptionRepository.stubs.findById.resolves(newSubscription);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ const citizenFindByIdStub = citizenRepository.stubs.findById.resolves(
+ new Citizen({id: 'Citizen'}),
+ );
+ const metadataFindByIdStub =
+ metadataRepository.stubs.findById.resolves(attachmentDataMock);
+ const metadataDeleteByIdStub = metadataRepository.stubs.deleteById.resolves();
+ const subscriptionUpdateByIdStub =
+ subscriptionRepository.stubs.updateById.resolves();
+ const invoiceStub = sinon.stub(invoiceUtils, 'generatePdfInvoices').resolves([]);
+ s3Service.stubs.uploadFileListIntoBucket.resolves(['ok']);
+ const result = await controller.addAttachments(
+ 'randomInputId',
+ mockAttachmentWithoutMetadataWithoutFiles,
+ );
+ expect(result.id).to.equal(inputRepo.id);
+ sinon.assert.calledOnce(citizenFindByIdStub);
+ sinon.assert.notCalled(metadataFindByIdStub);
+ sinon.assert.notCalled(metadataDeleteByIdStub);
+ sinon.assert.notCalled(subscriptionUpdateByIdStub);
+ sinon.assert.notCalled(invoiceStub);
+ invoiceStub.restore();
+ });
+
+ it('SubscriptionV1Controller addFiles : error', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(newSubscription);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ citizenRepository.stubs.findById.rejects('Error');
+ await controller.addAttachments('randomInputId', mockAttachment);
+ } catch (err) {
+ expect(err.name).to.equal('Error');
+ sinon.restore();
+ }
+ });
+
+ it('SubscriptionV1Controller finalizeSubscription : successful', async () => {
+ subscriptionRepository.stubs.findById.resolves(inputRepoDraft);
+ subscriptionRepository.stubs.updateById.resolves();
+ enterpriseRepository.stubs.findById.resolves(hrisFalse);
+ mailService.stubs.sendMailAsHtml.resolves('ok');
+ const result = await controller.finalizeSubscription('randomInputId');
+ sinon.assert.calledOnceWithExactly(
+ subscriptionRepository.stubs.updateById,
+ 'randomInputId',
+ {status: SUBSCRIPTION_STATUS.TO_PROCESS},
+ );
+
+ sinon.assert.calledOnceWithExactly(
+ mailService.stubs.sendMailAsHtml,
+ inputRepoDraft.email,
+ 'Confirmation d’envoi de la demande',
+ 'requests-to-process',
+ sinon.match.any,
+ );
+ expect(result.id).to.equal(inputRepo.id);
+ });
+
+ it('SubscriptionV1Controller finalizeSubscription hris true : successful', async () => {
+ subscriptionRepository.stubs.findById.resolves(inputRepoDraft);
+ subscriptionRepository.stubs.updateById.resolves();
+ enterpriseRepository.stubs.findById.resolves(hrisTrue);
+ communityRepository.stubs.findById.resolves(name);
+ citizenRepository.stubs.findById.resolves(citoyen);
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ close: () => {},
+ };
+ const channel: any = {
+ publish: () => {
+ return true;
+ },
+ close: () => {},
+ };
+ const amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ await rabbitmqService.publishMessage(subscriptionPayload, 'header');
+ const result = await controller.finalizeSubscription('randomInputId');
+ expect(result.id).to.equal('randomInputId');
+ amqpTest.restore();
+ });
+
+ it('SubscriptionV1Controller finalizeSubscription hris true and commaunityId : successful', async () => {
+ subscriptionRepository.stubs.findById.resolves(inputRepoDraft1);
+ subscriptionRepository.stubs.updateById.resolves();
+ enterpriseRepository.stubs.findById.resolves(hrisTrue);
+ communityRepository.stubs.findById.resolves(name);
+ citizenRepository.stubs.findById.resolves(citoyen);
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ close: () => {},
+ };
+ const channel: any = {
+ publish: () => {
+ return true;
+ },
+ close: () => {},
+ };
+ const amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ await rabbitmqService.publishMessage(subscriptionPayload, 'header');
+ const result = await controller.finalizeSubscription('randomInputId');
+ expect(result.id).to.equal('randomInputId');
+ amqpTest.restore();
+ });
+
+ it('SubscriptionV1Controller finalizeSubscription diffrent funderType: successful', async () => {
+ subscriptionRepository.stubs.findById.resolves(inputRepoDraft2);
+ subscriptionRepository.stubs.updateById.resolves();
+ enterpriseRepository.stubs.findById.resolves(hrisTrue);
+ communityRepository.stubs.findById.resolves(name);
+ citizenRepository.stubs.findById.resolves(citoyen);
+ await rabbitmqService.publishMessage(subscriptionPayload, 'header');
+ const result = await controller.finalizeSubscription('randomInputId');
+ expect(result.id).to.equal('randomInputId');
+ });
+ it('SubscriptionV1Controller finalizeSubscription without entreprise : error', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(inputRepoDraft1);
+ subscriptionRepository.stubs.updateById.resolves();
+ communityRepository.stubs.findById.resolves(name);
+ citizenRepository.stubs.findById.resolves(citoyen);
+ await rabbitmqService.publishMessage(subscriptionPayload, 'header');
+ await controller.finalizeSubscription('randomInputId');
+ } catch (err) {
+ expect(err.id).to.equal('randomInputId');
+ }
+ });
+
+ it('SubscriptionV1Controller finalizeSubscription : error', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(inputRepoDraft);
+ subscriptionRepository.stubs.updateById.rejects('Error');
+ await controller.finalizeSubscription('randomInputId');
+ } catch (err) {
+ expect(err.name).to.equal('Error');
+ }
+ });
+
+ // get subscription TU
+ it('get(v1/maas/subscriptions)', async () => {
+ subscriptionRepository.stubs.find.resolves([newSubscription]);
+ incentiveRepository.stubs.find.resolves([mockIncentive]);
+ const result = await controller.findMaasSubscription();
+ expect(result).to.deepEqual([mockSubscription]);
+ });
+ });
+
+ function givenStubbedRepository() {
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ collectivityRepository = createStubInstance(CollectivityRepository);
+ incentiveRepository = createStubInstance(IncentiveRepository);
+ citizenRepository = createStubInstance(CitizenRepository);
+ metadataRepository = createStubInstance(MetadataRepository);
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ communityRepository = createStubInstance(CommunityRepository);
+ rabbitmqService = createStubInstance(RabbitmqService);
+ s3Service = createStubInstance(S3Service);
+ mailService = createStubInstance(MailService);
+ subscptionService = createStubInstance(SubscriptionService);
+ }
+});
+
+const mockIncentive = new Incentive({
+ id: 'incentiveId',
+ title: 'incentiveTitle',
+ transportList: ['velo'],
+ contact: 'Contactez le numéro vert au 05 206 308',
+});
+const mockCitizen = new Citizen({
+ id: 'citizenId',
+ identity: Object.assign({
+ gender: Object.assign({
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ }),
+ email: 'email@gmail.com',
+});
+
+const newSubscription = new Subscription({
+ id: '619e3ff38dd34e1774b60789',
+ incentiveId: 'incentiveId',
+ funderName: 'Rabat',
+ funderId: 'funderId',
+ incentiveTitle: "Bonus Ecologique pour l'achat d'un vélo électrique",
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-11-24T13:36:51.423Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const mockSubscription = {
+ id: '619e3ff38dd34e1774b60789',
+ incentiveId: 'incentiveId',
+ funderName: 'Rabat',
+ incentiveTitle: "Bonus Ecologique pour l'achat d'un vélo électrique",
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-11-24T13:36:51.423Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ contact: 'Contactez le numéro vert au 05 206 308',
+ funderId: 'funderId',
+};
+
+const today = new Date();
+const expirationDate = new Date(today.setMonth(today.getMonth() + 3));
+
+const mockencryptionKeyValid = new EncryptionKey({
+ id: '62977dc80929474f84c403de',
+ version: 1,
+ publicKey: `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq
+n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf
+n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq
+sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85
+OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG
+X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n
+owIDAQAB
+-----END PUBLIC KEY-----
+`,
+ expirationDate,
+ lastUpdateDate: new Date(),
+ privateKeyAccess: new PrivateKeyAccess({loginURL: 'loginURL', getKeyURL: 'getKeyURL'}),
+});
+
+const mockCollectivity = new Collectivity({
+ id: '2b6ee373-4c5b-403b-afe5-3bf3cbd2473',
+ encryptionKey: mockencryptionKeyValid,
+});
diff --git a/api/src/__tests__/controllers/funder.controller.test.ts b/api/src/__tests__/controllers/funder.controller.test.ts
new file mode 100644
index 0000000..34688e3
--- /dev/null
+++ b/api/src/__tests__/controllers/funder.controller.test.ts
@@ -0,0 +1,330 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {FunderController} from '../../controllers';
+import {
+ Collectivity,
+ Community,
+ Enterprise,
+ EncryptionKey,
+ PrivateKeyAccess,
+ Client,
+} from '../../models';
+import {FunderService} from '../../services';
+
+import {
+ CommunityRepository,
+ EnterpriseRepository,
+ CollectivityRepository,
+ ClientScopeRepository,
+} from '../../repositories';
+import {ValidationError} from '../../validationError';
+import {FUNDER_TYPE, ResourceName, StatusCode, IUser} from '../../utils';
+import {Clients} from 'keycloak-admin/lib/resources/clients';
+
+describe('Funder Controller ', () => {
+ let collectivityRepository: StubbedInstanceWithSinonAccessor,
+ communityRepository: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ clientScopeRepository: StubbedInstanceWithSinonAccessor,
+ funderService: StubbedInstanceWithSinonAccessor,
+ controller: FunderController;
+
+ beforeEach(() => {
+ givenStubbedCollectivityRepository();
+ givenStubbedCommunityRepository();
+ givenStubbedEnterpriseRepository();
+ givenStubbedFunderService();
+ controller = new FunderController(
+ communityRepository,
+ enterpriseRepository,
+ collectivityRepository,
+ funderService,
+ clientScopeRepository,
+ );
+ });
+
+ it('get(/v1/funders)', async () => {
+ funderService.stubs.getFunders.resolves(mockReturnFunder);
+ const result = await controller.find();
+
+ expect(result).to.deepEqual(mockReturnFunder);
+ funderService.stubs.getFunders.restore();
+ });
+
+ it('FunderController create : succeeded-enterprise', async () => {
+ const community = new Community({
+ name: 'random',
+ funderId: 'randomId',
+ });
+
+ communityRepository.stubs.find.resolves([]);
+ enterpriseRepository.stubs.find.resolves([
+ new Enterprise({name: 'randomEnterprise'}),
+ ]);
+ communityRepository.stubs.create.resolves(community);
+
+ const res = await controller.create(community);
+ expect(res).to.deepEqual(community);
+ });
+
+ it('FunderController create : succeeded-collectivity', async () => {
+ const community = new Community({
+ name: 'random',
+ funderId: 'randomId',
+ });
+
+ communityRepository.stubs.find.resolves([]);
+ enterpriseRepository.stubs.find.resolves([]);
+ collectivityRepository.stubs.find.resolves([
+ new Collectivity({name: 'randomEnterprise'}),
+ ]);
+ communityRepository.stubs.create.resolves(community);
+
+ const res = await controller.create(community);
+ expect(res).to.deepEqual(community);
+ });
+
+ it('FunderController create : fails because name already exists', async () => {
+ const community = new Community({
+ name: 'random',
+ funderId: 'randomId',
+ });
+ const communityExisted = new Community({
+ name: 'random',
+ funderId: 'randomId2',
+ });
+
+ const error = new ValidationError(
+ `communities.error.name.unique`,
+ `/communities`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Community,
+ );
+ try {
+ communityRepository.stubs.find.resolves([communityExisted]);
+
+ await controller.create(community);
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('FunderController create : fails because funder is not present', async () => {
+ const community = new Community({
+ name: 'random',
+ funderId: 'randomId',
+ });
+ const error = new ValidationError(
+ `communities.error.funders.missed`,
+ `/communities`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Community,
+ );
+
+ communityRepository.stubs.find.resolves([]);
+ enterpriseRepository.stubs.find.resolves([]);
+ collectivityRepository.stubs.find.resolves([]);
+ try {
+ const res = await controller.create(community);
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('FunderController get by funderId : successful', async () => {
+ const funderId = 'randomFunderId';
+ const communitiesResult: [] = [];
+ communityRepository.stubs.findByFunderId.resolves(communitiesResult);
+ const res = await controller.findByFunderId(funderId);
+
+ expect(res).to.deepEqual(communitiesResult);
+ });
+
+ it('FunderController count : successful', async () => {
+ const countRes = {
+ count: 10,
+ };
+
+ communityRepository.stubs.count.resolves(countRes);
+ const result = await controller.count();
+
+ expect(result).to.deepEqual(countRes);
+ });
+
+ it('FunderController v1/funders/communities', async () => {
+ communityRepository.stubs.find.resolves([newCommunity]);
+ funderService.stubs.getFunders.resolves(mockReturnFunder);
+ const result = await controller.findCommunities();
+
+ expect(result).to.deepEqual([mockAllCommunities]);
+ funderService.stubs.getFunders.restore();
+ communityRepository.stubs.find.restore();
+ });
+
+ it('encryption_key : asserts encryption key has been stored for collectivity', async () => {
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity2);
+ collectivityRepository.stubs.updateById.resolves();
+ enterpriseRepository.stubs.create.resolves(undefined);
+ await controller.storeEncryptionKey(mockCollectivity2.id, mockencryptionKeyValid);
+ sinon.assert.called(collectivityRepository.stubs.updateById);
+ });
+
+ it('encryption_key : asserts encryption key has been stored for enterprise', async () => {
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ enterpriseRepository.stubs.updateById.resolves();
+ enterpriseRepository.stubs.findOne.resolves(mockEnterprise);
+ await controller.storeEncryptionKey(mockEnterprise.id, mockencryptionKeyValid);
+ sinon.assert.called(enterpriseRepository.stubs.updateById);
+ });
+
+ it('findFunderById : returns enterprise when funder is of type enterprise', async () => {
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ enterpriseRepository.stubs.findOne.resolves(mockEnterprise);
+ const enterprise = await controller.findFunderById(mockEnterprise.id);
+ expect(enterprise).to.deepEqual(mockEnterprise);
+ });
+
+ it('findFunderById : returns collectivity when funder is of type collectivity', async () => {
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ const collectivity = await controller.findFunderById(mockCollectivity.id);
+ expect(collectivity).to.deepEqual(mockCollectivity);
+ });
+
+ it('findClients : returns list of clients', async () => {
+ clientScopeRepository.stubs.getClients.resolves(mockClientsList);
+ const clients = await controller.findClients();
+ expect(clients).to.deepEqual(mockClientsList);
+ });
+
+ it('findFunderById : throws error when funder not found', async () => {
+ const funderNotFoundError = new ValidationError(
+ `Funder not found`,
+ `/Funder`,
+ StatusCode.NotFound,
+ ResourceName.Funder,
+ );
+ try {
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ await controller.findFunderById('wrongFunderId');
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error).to.deepEqual(funderNotFoundError);
+ }
+ });
+
+ function givenStubbedCollectivityRepository() {
+ collectivityRepository = createStubInstance(CollectivityRepository);
+ }
+
+ function givenStubbedCommunityRepository() {
+ communityRepository = createStubInstance(CommunityRepository);
+ clientScopeRepository = createStubInstance(ClientScopeRepository);
+ }
+
+ function givenStubbedFunderService() {
+ funderService = createStubInstance(FunderService);
+ }
+
+ function givenStubbedEnterpriseRepository() {
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ }
+});
+
+const today = new Date();
+const expirationDate = new Date(today.setMonth(today.getMonth() + 7));
+const publicKey = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq
+n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf
+n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq
+sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85
+OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG
+X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n
+owIDAQAB
+-----END PUBLIC KEY-----
+`;
+
+const mockencryptionKeyValid = new EncryptionKey({
+ id: '62977dc80929474f84c403de',
+ version: 1,
+ publicKey,
+ expirationDate,
+ lastUpdateDate: new Date(),
+ privateKeyAccess: new PrivateKeyAccess(
+ new PrivateKeyAccess({loginURL: 'loginURL', getKeyURL: 'getKeyURL'}),
+ ),
+});
+
+const mockClientsList = [
+ {
+ clientId: '62977dc80929474f84c403de',
+ } as Client,
+];
+const mockencryptionKeyNogetKeyURL = new EncryptionKey({
+ id: '62977dc80929474f84c403de',
+ version: 1,
+ publicKey,
+ expirationDate,
+ lastUpdateDate: new Date(),
+});
+
+const mockCollectivity2 = new Collectivity({
+ id: '2b6ee373-4c5b-403b-afe5-3bf3cbd2473c',
+ name: 'Mulhouse',
+ citizensCount: 1,
+ mobilityBudget: 1,
+ encryptionKey: mockencryptionKeyValid,
+});
+
+const mockCollectivity = new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ citizensCount: 10,
+ mobilityBudget: 12,
+ encryptionKey: mockencryptionKeyValid,
+});
+
+const mockEnterprise = new Enterprise({
+ id: 'randomInputIdEnterprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'nameEnterprise',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ encryptionKey: mockencryptionKeyValid,
+});
+
+const mockReturnFunder = [
+ {...mockCollectivity, funderType: FUNDER_TYPE.collectivity},
+ {...mockEnterprise, funderType: FUNDER_TYPE.enterprise},
+];
+
+const newCommunity = new Community({
+ id: '6175d61442ebca0660ddf3fb',
+ name: 'fio',
+ funderId: 'randomInputIdEnterprise',
+});
+
+const mockAllCommunities = {
+ funderId: 'randomInputIdEnterprise',
+ funderName: 'nameEnterprise',
+ funderType: 'Entreprise',
+ id: '6175d61442ebca0660ddf3fb',
+ name: 'fio',
+};
+
+const currentUser: IUser = {
+ id: 'idUser',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/entreprises/Capgemini'],
+ roles: ['gestionnaires'],
+ [securityId]: 'idEnterprise',
+};
diff --git a/api/src/__tests__/controllers/incentive.controller.test.ts b/api/src/__tests__/controllers/incentive.controller.test.ts
new file mode 100644
index 0000000..9dcd289
--- /dev/null
+++ b/api/src/__tests__/controllers/incentive.controller.test.ts
@@ -0,0 +1,1325 @@
+import {AnyObject} from '@loopback/repository';
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {IncentiveController} from '../../controllers';
+import {
+ Incentive,
+ Collectivity,
+ Enterprise,
+ Citizen,
+ Link,
+ Territory,
+} from '../../models';
+import {
+ CitizenRepository,
+ CollectivityRepository,
+ EnterpriseRepository,
+ IncentiveRepository,
+ TerritoryRepository,
+} from '../../repositories';
+import {ValidationError} from '../../validationError';
+import {IncentiveService, FunderService} from '../../services';
+import {
+ AFFILIATION_STATUS,
+ CITIZEN_STATUS,
+ Roles,
+ StatusCode,
+ HTTP_METHOD,
+ GET_INCENTIVES_INFORMATION_MESSAGES,
+ FUNDER_TYPE,
+ IUser,
+ ResourceName,
+} from '../../utils';
+import {WEBSITE_FQDN} from '../../constants';
+import {TerritoryService} from '../../services/territory.service';
+
+describe('Incentives Controller', () => {
+ let repository: StubbedInstanceWithSinonAccessor,
+ repositoryCollectivity: StubbedInstanceWithSinonAccessor,
+ repositoryEnterprise: StubbedInstanceWithSinonAccessor,
+ repositoryCitizen: StubbedInstanceWithSinonAccessor,
+ repositoryTerritory: StubbedInstanceWithSinonAccessor,
+ incentiveService: StubbedInstanceWithSinonAccessor,
+ funderService: StubbedInstanceWithSinonAccessor,
+ territoryService: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ givenStubbedCollectivityRepository();
+ givenStubbedEntrpriseRepository();
+ givenStubbedCitizenRepository();
+ givenStubbedTerritoryRepository();
+ givenStubbedIncentiveService();
+ givenStubbedFunderService();
+ givenStubbedTerritoryService();
+ });
+
+ it('post(/v1/incentives) with funderName=AideEmployeur', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repositoryTerritory.stubs.findById.resolves(territoryMock);
+ repository.stubs.create.resolves(mockCreateEnterpriseIncentive);
+ repositoryEnterprise.stubs.find.resolves([mockEnterprise]);
+ const result = await controller.create(mockEnterpriseIncentive);
+
+ expect(result.funderId).to.deepEqual('randomInputIdEnterprise');
+ });
+
+ it('post(/v1/incentives) with funderName=AideEmployeur but enterprise not exist', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ try {
+ repositoryTerritory.stubs.findById.resolves(territoryMock);
+ repository.stubs.create.resolves(mockCreateEnterpriseIncentive);
+ repositoryEnterprise.stubs.find.resolves([]);
+ await controller.create(mockEnterpriseIncentive);
+ } catch ({message}) {
+ expect(error.message).to.equal(message);
+ }
+ });
+
+ it('post(/v1/incentives) with funderName=AideTerritoire', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repositoryTerritory.stubs.findById.resolves(territoryMock);
+ repository.stubs.create.resolves(mockCreateCollectivityIncentive);
+ repositoryCollectivity.stubs.find.resolves([mockCollectivity]);
+ const result = await controller.create(mockCollectivityIncentive);
+
+ expect(result.funderId).to.deepEqual('randomInputIdCollectivity');
+ });
+
+ it('post(/v1/incentives) with specific fields', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repositoryTerritory.stubs.findById.resolves(territoryMock);
+ repository.stubs.create.resolves(mockCreateIncentiveWithSpecificFields);
+ repositoryCollectivity.stubs.find.resolves([mockCollectivity]);
+ const result = await controller.create(mockIncentiveWithSpecificFields);
+
+ expect(result.funderId).to.deepEqual('randomInputIdCollectivity');
+ });
+
+ it('post(/v1/incentives) with territory mismatched name', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repositoryTerritory.stubs.findById.resolves(territoryWrongName);
+
+ try {
+ await controller.create(mockEnterpriseIncentive);
+ } catch ({message}) {
+ expect(territoryNameMismatch.message).to.equal(message);
+ }
+ });
+
+ it('post(/v1/incentives) create territory', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ territoryService.stubs.createTerritory.resolves(territoryMock);
+ repository.stubs.create.resolves(mockCreateEnterpriseIncentive);
+ repositoryEnterprise.stubs.find.resolves([mockEnterprise]);
+ const result = await controller.create(mockIncentiveTerritoryPayload);
+ expect(result.funderId).to.deepEqual('randomInputIdEnterprise');
+ });
+
+ it('post(/v1/incentives) transaction', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ try {
+ territoryService.stubs.createTerritory.resolves(territoryMock);
+ repository.stubs.create.resolves(mockCreateEnterpriseIncentive);
+ repositoryEnterprise.stubs.find.resolves([]);
+ repositoryTerritory.stubs.deleteById.resolves();
+ await controller.create(mockEnterpriseIncentivePayload2);
+ } catch ({message}) {
+ expect(error.message).to.equal(message);
+ }
+ });
+
+ it('GET v1/incentives returns an error when a user or maas token is not provided', async () => {
+ const noToken = new ValidationError(
+ 'Authorization header not found',
+ '/authorization',
+ StatusCode.Unauthorized,
+ );
+ try {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.find.resolves([]);
+ const response: any = {};
+ await controller.find(response);
+ } catch (err) {
+ expect(err).to.equal(noToken);
+ }
+ });
+ it('GET /v1/incentives should return public and private incentives for user content_editor', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.find.resolves([mockIncentiveWithSpecificFields]);
+ const response: any = {};
+ const result = await controller.find(response);
+
+ expect(result).to.deepEqual([mockIncentiveWithSpecificFields]);
+ });
+
+ it('GET /v1/incentives should return incentive belonging to user manager connected', async () => {
+ const userManager = currentUser;
+ userManager.roles = [Roles.MANAGERS];
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ userManager,
+ );
+ repository.stubs.find.resolves([mockEnterpriseIncentive]);
+ const response: any = {};
+ const result = controller.find(response);
+
+ expect(result).to.deepEqual(mockReturnIncentivesFunderEnterprise);
+ });
+
+ it('GET v1/incentives should return public incentives with user service_maas', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUserMaas,
+ );
+ const incentiveFindStub = repository.stubs.find;
+ incentiveFindStub.onCall(0).resolves(mockReturnPublicAid);
+ incentiveFindStub.onCall(1).resolves(mockReturnPrivateAid);
+ funderService.stubs.getFunders.resolves([newFunder]);
+ repositoryCitizen.stubs.findOne.resolves(mockCitizen);
+ const response: any = {};
+ const result: any = await controller.find(response);
+ expect(result).to.have.length(2);
+ expect(result).to.deepEqual(mockReturnPublicAid);
+ funderService.stubs.getFunders.restore();
+ repositoryCitizen.stubs.findOne.restore();
+ repository.stubs.find.restore();
+ });
+
+ it('GET v1/incentives should return message when citizen affiliated but no private incentive', async () => {
+ const message =
+ GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_AFFILIATED_WITHOUT_INCENTIVES;
+ const userMaaS = currentUser;
+ userMaaS.roles = [Roles.MAAS];
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ userMaaS,
+ );
+ funderService.stubs.getFunders.resolves([newFunder]);
+ repositoryCitizen.stubs.findOne.resolves(mockCitizen);
+ repository.stubs.find.resolves([]);
+ const response: any = {
+ status: function () {
+ return this;
+ },
+ contentType: function () {
+ return this;
+ },
+ send: (buffer: Buffer) => buffer,
+ };
+ const result: any = await controller.find(response);
+ expect(result.message).to.equal(message);
+ });
+
+ it('GET v1/incentives returns public incentives if has no affiliation', async () => {
+ const userMaaS = currentUser;
+ userMaaS.roles = [Roles.MAAS];
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ userMaaS,
+ );
+ repository.stubs.find.resolves(mockReturnPublicAid);
+ funderService.stubs.getFunders.resolves([]);
+ repositoryCitizen.stubs.findOne.resolves(mockCitizenNonSalarie);
+ repository.stubs.find.resolves(mockReturnPublicAid);
+ const response: any = {};
+ const result: any = await controller.find(response);
+
+ expect(result).to.have.length(2);
+ expect(result).to.deepEqual(mockReturnPublicAid);
+
+ funderService.stubs.getFunders.restore();
+ repositoryCitizen.stubs.findOne.restore();
+ repository.stubs.find.restore();
+ });
+
+ it('GET v1/incentives should return message if citizen not affiliated', async () => {
+ const message = GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_NOT_AFFILIATED;
+ const userMaaS = currentUser;
+ userMaaS.roles = [Roles.MAAS];
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ userMaaS,
+ );
+ funderService.stubs.getFunders.resolves([newFunder]);
+ repositoryCitizen.stubs.findOne.resolves(mockReturnCitizenNotAffiliated);
+ repository.stubs.find.resolves(mockReturnPublicAid);
+ const response: any = {
+ status: function () {
+ return this;
+ },
+ contentType: function () {
+ return this;
+ },
+ send: (buffer: Buffer) => buffer,
+ };
+ const result: any = await controller.find(response);
+ expect(result.message).to.equal(message);
+ });
+
+ it('GET v1/incentives returns the public and private incentives when citizen affiliated', async () => {
+ const userMaaS = currentUser;
+ userMaaS.roles = [Roles.MAAS];
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ userMaaS,
+ );
+ const incentiveFindStub = repository.stubs.find;
+ incentiveFindStub.onCall(0).resolves(mockReturnPublicAid);
+ incentiveFindStub.onCall(1).resolves(mockReturnPrivateAid);
+ funderService.stubs.getFunders.resolves([newFunder]);
+ repositoryCitizen.stubs.findOne.resolves(mockCitizen);
+ const response: any = {
+ status: function () {
+ return this;
+ },
+ contentType: function () {
+ return this;
+ },
+ send: (buffer: Buffer) => buffer,
+ };
+ const result: any = await controller.find(response);
+
+ expect(result).to.have.length(3);
+ expect(result).to.deepEqual([...mockReturnPublicAid, ...mockReturnPrivateAid]);
+
+ funderService.stubs.getFunders.restore();
+ repositoryCitizen.stubs.findOne.restore();
+ repository.stubs.findOne.restore();
+ });
+
+ it('get(/v1/incentives/search)', done => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.execute.resolves([mockIncentiveWithSpecificFields]);
+ const incentiveList = controller
+ .search('AideNationale, AideTerritoire', 'vélo')
+ .then(res => res)
+ .catch(err => err);
+
+ expect(incentiveList).to.deepEqual(mockReturnIncentives);
+ done();
+ });
+
+ it('get(/v1/incentives/count)', async () => {
+ const countRes = {
+ count: 10,
+ };
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.count.resolves(countRes);
+ const result = await controller.count();
+
+ expect(result).to.deepEqual(countRes);
+ });
+
+ it('get(/v1/incentives/{incentiveId})', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ const mockIncentive = Object.assign({}, mockCollectivityIncentive, {
+ isMCMStaff: false,
+ });
+ repository.stubs.findById.resolves(mockIncentive);
+ const incentive = await controller.findIncentiveById('606c236a624cec2becdef276');
+
+ expect(incentive).to.deepEqual(mockIncentive);
+ });
+
+ it('get(/v1/incentives/{incentiveId}) with links', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.findById.resolves(mockIncentive);
+ const links = [
+ new Link({
+ href: `${WEBSITE_FQDN}/subscriptions/new?incentiveId=randomNationalId`,
+ rel: 'subscribe',
+ method: HTTP_METHOD.GET,
+ }),
+ ];
+ const result = await controller.findIncentiveById('randomNationalId');
+ mockIncentive.links = links;
+ expect(result).to.deepEqual(mockIncentive);
+ });
+
+ it('patch(/v1/incentives/{incentiveId}) territory id not given', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.updateById.resolves();
+ try {
+ await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithoutTerritoryId,
+ );
+ } catch (err) {
+ expect(err).to.deepEqual(territoryIdNotGiven);
+ }
+ });
+
+ it('patch(/v1/incentives/{incentiveId}) mismatch territory name', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repositoryTerritory.stubs.findById.resolves(territoryWrongName);
+ repository.stubs.updateById.resolves();
+ try {
+ await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithSubscriptionLink,
+ );
+ } catch (err) {
+ expect(err).to.deepEqual(territoryNameMismatch);
+ }
+ });
+
+ it('patch(/v1/incentives/{incentiveId}) subscription link', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repositoryTerritory.stubs.findById.resolves(territoryMock);
+
+ repository.stubs.updateById.resolves();
+ const incentiveList = await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithSubscriptionLink,
+ );
+
+ expect(incentiveList).to.deepEqual(mockIncentiveWithSubscriptionLink);
+ });
+ it('patch(/v1/incentives/{incentiveId}) additionalInfos', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.updateById.resolves();
+ const incentiveList = await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithoutAdditionalInfos,
+ );
+
+ expect(incentiveList).to.deepEqual(mockIncentiveWithoutAdditionalInfos);
+ });
+ it('patch(/v1/incentives/{incentiveId}) validityDate', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.updateById.resolves();
+ const incentiveList = await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithoutValidityDate,
+ );
+
+ expect(incentiveList).to.deepEqual(mockIncentiveWithoutValidityDate);
+ });
+ it('patch(/v1/incentives/{incentiveId}) validityDuration', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.updateById.resolves();
+ const incentiveList = await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithoutValidityDuration,
+ );
+
+ expect(incentiveList).to.deepEqual(mockIncentiveWithoutValidityDuration);
+ });
+ it('patch(/v1/incentives/{incentiveId}) contact', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.updateById.resolves();
+ const incentiveList = await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithoutContact,
+ );
+
+ expect(incentiveList).to.deepEqual(mockIncentiveWithoutContact);
+ });
+
+ it('patch(/v1/incentives/{incentiveId}) specific fields', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repositoryTerritory.stubs.findById.resolves(territoryMock);
+ repository.stubs.updateById.resolves();
+ const incentiveList = await controller.updateById(
+ '606c236a624cec2becdef276',
+ mockIncentiveWithSpecificFields,
+ );
+
+ expect(incentiveList).to.deepEqual(mockIncentiveWithSpecificFields);
+ });
+
+ it('del(/v1/incentives/{incentiveId})', async () => {
+ const controller = new IncentiveController(
+ repository,
+ repositoryCollectivity,
+ repositoryEnterprise,
+ repositoryCitizen,
+ repositoryTerritory,
+ incentiveService,
+ funderService,
+ territoryService,
+ currentUser,
+ );
+ repository.stubs.deleteById.resolves();
+
+ const incentiveList = await controller.deleteById('606c236a624cec2becdef276');
+
+ expect(incentiveList).to.deepEqual(undefined);
+ });
+
+ function givenStubbedRepository() {
+ repository = createStubInstance(IncentiveRepository);
+ }
+ function givenStubbedCollectivityRepository() {
+ repositoryCollectivity = createStubInstance(CollectivityRepository);
+ }
+
+ function givenStubbedEntrpriseRepository() {
+ repositoryEnterprise = createStubInstance(EnterpriseRepository);
+ }
+
+ function givenStubbedCitizenRepository() {
+ repositoryCitizen = createStubInstance(CitizenRepository);
+ }
+
+ function givenStubbedTerritoryRepository() {
+ repositoryTerritory = createStubInstance(TerritoryRepository);
+ }
+
+ function givenStubbedIncentiveService() {
+ incentiveService = createStubInstance(IncentiveService);
+ }
+
+ function givenStubbedFunderService() {
+ funderService = createStubInstance(FunderService);
+ }
+
+ function givenStubbedTerritoryService() {
+ territoryService = createStubInstance(TerritoryService);
+ }
+});
+
+const mockCollectivityIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const mockCreateCollectivityIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ funderId: 'randomInputIdCollectivity',
+});
+
+const mockEnterpriseIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Capgemini',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideEmployeur',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const mockCreateEnterpriseIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'nameEnterprise',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideEmployeur',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ funderId: 'randomInputIdEnterprise',
+});
+
+const mockCreateIncentiveWithSpecificFields = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ funderId: 'randomInputIdCollectivity',
+ specificFields: [
+ {
+ title: 'Liste de choix',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'choix1',
+ },
+ {
+ inputChoice: 'choix2',
+ },
+ ],
+ },
+ },
+ {
+ title: 'Un texte',
+ inputFormat: 'Texte',
+ },
+ ],
+ jsonSchema: {
+ properties: {
+ 'Liste de choix': {
+ type: 'array',
+ maxItems: 2,
+ items: [
+ {
+ enum: ['choix1', 'choix2'],
+ },
+ ],
+ },
+ 'Un texte': {
+ type: 'string',
+ minLength: 1,
+ },
+ },
+ title: 'Aide pour acheter vélo électrique',
+ type: 'object',
+ required: ['Liste de choix', 'Un texte'],
+ },
+});
+
+const mockIncentiveWithSpecificFields = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ specificFields: [
+ {
+ title: 'Liste de choix',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'choix1',
+ },
+ {
+ inputChoice: 'choix2',
+ },
+ ],
+ },
+ },
+ {
+ title: 'Un texte',
+ inputFormat: 'Texte',
+ },
+ ],
+});
+
+const mockIncentiveWithSubscriptionLink = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: false,
+ subscriptionLink: 'http://link.com',
+});
+const mockIncentiveWithoutAdditionalInfos = new Incentive({
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: false,
+ subscriptionLink: 'http://link.com',
+});
+const mockIncentiveWithoutValidityDate = new Incentive({
+ additionalInfos: 'test',
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: false,
+ subscriptionLink: 'http://link.com',
+});
+const mockIncentiveWithoutValidityDuration = new Incentive({
+ additionalInfos: 'test',
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: false,
+ subscriptionLink: 'http://link.com',
+});
+const mockIncentiveWithoutContact = new Incentive({
+ additionalInfos: 'test',
+ funderName: 'nameTerritoire',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: false,
+ subscriptionLink: 'http://link.com',
+});
+
+const mockReturnIncentives: Promise = new Promise(() => {
+ return [
+ {
+ id: '606c236a624cec2becdef276',
+ title: 'Aide pour acheter vélo électrique',
+ minAmount: 'A partir de 100 €',
+ incentiveType: 'AideTerritoire',
+ transportList: ['vélo'],
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ },
+ {
+ id: '606c236a624cec2becdef276',
+ title: 'Bonus écologique pour une voiture ou une camionnette électrique ou hybride',
+ minAmount: 'A partir de 1 000 €',
+ incentiveType: 'AideNationale',
+ transportList: ['autopartage', 'voiture'],
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ },
+ ];
+});
+
+const mockCollectivity = new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ citizensCount: 10,
+ mobilityBudget: 12,
+});
+
+const mockEnterprise = new Enterprise({
+ id: 'randomInputIdEnterprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'nameEnterprise',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+});
+
+const mockReturnIncentivesFunderEnterprise: Promise = new Promise(() => {
+ return [mockEnterpriseIncentive];
+});
+
+const error = new ValidationError(
+ `incentives.error.fundername.enterprise.notExist`,
+ '/enterpriseNotExist',
+);
+
+const currentUser: IUser = {
+ id: 'idEnterprise',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/entreprises/Capgemini'],
+ roles: ['content_editor', 'gestionnaires'],
+ [securityId]: 'idEnterprise',
+};
+
+const currentUserMaas: IUser = {
+ id: 'citizenId',
+ emailVerified: true,
+ clientName: undefined,
+ membership: ['/citoyens'],
+ roles: ['offline_access', 'uma_authorization', 'maas', 'service_maas'],
+ [securityId]: 'citizenId',
+};
+const mockReturnCitizenNotAffiliated = new Citizen({
+ id: 'citizenId',
+ email: 'kennyg@gmail.com',
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'Gerard',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Kenny',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ birthDate: Object.assign({
+ value: '1994-02-18T00:00:00.000Z',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ city: 'Mulhouse',
+ postcode: '75000',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'someFunderId',
+ enterpriseEmail: 'walid.housni@adevinta.com',
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ }),
+});
+
+const mockReturnPrivateAid = [
+ new Incentive({
+ id: 'randomEmployeurId',
+ title: 'Mulhouse',
+ funderName: 'nameEnterprise',
+ incentiveType: 'AideEmployeur',
+ minAmount: '200',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ transportList: ['transportsCommun', 'voiture'],
+ validityDate: '2023-06-07T00:00:00.000Z',
+ funderId: 'someFunderId',
+ }),
+];
+
+const mockReturnPublicAid = [
+ new Incentive({
+ id: 'randomTerritoireId',
+ title: 'Aide pour acheter vélo électrique',
+ minAmount: 'A partir de 100 €',
+ incentiveType: 'AideTerritoire',
+ transportList: ['vélo'],
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ }),
+ new Incentive({
+ id: '606c236a624cec2becdef276',
+ title: 'Bonus écologique pour une voiture ou une camionnette électrique ou hybride',
+ minAmount: 'A partir de 1 000 €',
+ incentiveType: 'AideNationale',
+ transportList: ['autopartage', 'voiture'],
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ }),
+];
+
+const newFunder = {
+ id: 'someFunderId',
+ name: 'nameEnterprise',
+ funderType: FUNDER_TYPE.enterprise,
+};
+
+const mockCitizen = new Citizen({
+ id: 'citizenId',
+ email: 'kennyg@gmail.com',
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'Gerard',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Kenny',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ birthDate: Object.assign({
+ value: '1994-02-18T00:00:00.000Z',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ city: 'Mulhouse',
+ postcode: '75000',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'someFunderId',
+ enterpriseEmail: 'walid.housni@adevinta.com',
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ }),
+});
+
+const mockCitizenNonSalarie = new Citizen({
+ email: 'samy-youssef@gmail.com',
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'youssef',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Samy',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ birthDate: Object.assign({
+ value: '1995-02-18T00:00:00.000Z',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ city: 'Paris',
+ postcode: '75000',
+ status: CITIZEN_STATUS.STUDENT,
+ tos1: true,
+ tos2: true,
+});
+
+const mockIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: 'randomNationalId',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const mockIncentiveWithoutTerritoryId = new Incentive({
+ territory: {name: 'Toulouse', id: ''} as Territory,
+});
+
+const territoryIdNotGiven = new ValidationError(
+ `territory.id.undefined`,
+ `/territory`,
+ StatusCode.PreconditionFailed,
+ ResourceName.Territory,
+);
+
+const territoryNameMismatch = new ValidationError(
+ `territory.name.mismatch`,
+ `/territory`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Territory,
+);
+
+const territoryMock = new Territory({
+ name: 'Toulouse',
+ id: 'test',
+});
+
+const territoryWrongName = new Territory({
+ name: 'Toulouse Wrong Name',
+ id: '245',
+});
+
+const mockIncentiveTerritoryPayload = new Incentive({
+ territory: {name: 'Toulouse'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'nameEnterprise',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideEmployeur',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ funderId: 'randomInputIdEnterprise',
+});
+
+const mockEnterpriseIncentivePayload2 = new Incentive({
+ territory: {name: 'Toulouse'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Capgemini',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideEmployeur',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef276',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
diff --git a/api/src/__tests__/controllers/subscription.controller.test.ts b/api/src/__tests__/controllers/subscription.controller.test.ts
new file mode 100644
index 0000000..457e895
--- /dev/null
+++ b/api/src/__tests__/controllers/subscription.controller.test.ts
@@ -0,0 +1,556 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {
+ CitizenRepository,
+ CommunityRepository,
+ EnterpriseRepository,
+ MetadataRepository,
+ SubscriptionRepository,
+ UserRepository,
+} from '../../repositories';
+import {SubscriptionController} from '../../controllers';
+import {
+ Subscription,
+ Community,
+ User,
+ Metadata,
+ AttachmentMetadata,
+ Citizen,
+ ValidationSinglePayment,
+ CommonRejection,
+ OtherReason,
+} from '../../models';
+import {S3Service, SubscriptionService, FunderService, MailService} from '../../services';
+import {ValidationError} from '../../validationError';
+import {
+ CITIZEN_STATUS,
+ INCENTIVE_TYPE,
+ IUser,
+ REJECTION_REASON,
+ ResourceName,
+ StatusCode,
+ SUBSCRIPTION_STATUS,
+} from '../../utils';
+
+describe('SubscriptionController', () => {
+ let repository: StubbedInstanceWithSinonAccessor,
+ s3Service: StubbedInstanceWithSinonAccessor,
+ subscriptionService: SubscriptionService,
+ controller: SubscriptionController,
+ funderService: StubbedInstanceWithSinonAccessor,
+ communityRepository: StubbedInstanceWithSinonAccessor,
+ userRepository: StubbedInstanceWithSinonAccessor,
+ subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ metadataRepository: StubbedInstanceWithSinonAccessor,
+ citizenRepository: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ mailService: MailService,
+ spy: any,
+ input: any,
+ input1: any,
+ input2: any;
+
+ const response: any = {
+ status: function () {
+ return this;
+ },
+ contentType: function () {
+ return this;
+ },
+ send: (body: any) => body,
+ };
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ givenStubbedService();
+ subscriptionService = new SubscriptionService(
+ s3Service,
+ subscriptionRepository,
+ citizenRepository,
+ mailService,
+ communityRepository,
+ enterpriseRepository,
+ );
+ spy = sinon.spy(subscriptionService);
+ controller = new SubscriptionController(
+ repository,
+ s3Service,
+ spy,
+ funderService,
+ communityRepository,
+ metadataRepository,
+ userRepository,
+ citizenRepository,
+ currentUser,
+ response,
+ mailService,
+ );
+ input = {...initInput};
+ input1 = {...initInput1};
+ input2 = {...initInput2};
+ });
+
+ it('SubscriptionController find : successful', async () => {
+ repository.stubs.find.resolves([input]);
+ userRepository.stubs.findOne.resolves(user);
+ funderService.stubs.getFunderByName.resolves({
+ name: 'funderName',
+ funderType: 'entreprise',
+ id: 'random',
+ });
+ communityRepository.stubs.findByFunderId.resolves([
+ new Community({id: 'idCommunity', funderId: 'random'}),
+ new Community({id: 'idCommunity1', funderId: 'random'}),
+ ]);
+
+ const result = await controller.find(
+ 'A_TRAITER',
+ 'incentiveId',
+ 'incentiveType',
+ 'idCommunity',
+ 'lastName',
+ '',
+ '2021',
+ );
+
+ expect(result).to.deepEqual([input]);
+ });
+
+ it('SubscriptionController find with citizenId: successful', async () => {
+ repository.stubs.find.resolves([input]);
+ userRepository.stubs.findOne.resolves(user);
+ funderService.stubs.getFunderByName.resolves({
+ name: 'funderName',
+ funderType: 'entreprise',
+ id: 'random',
+ });
+ communityRepository.stubs.findByFunderId.resolves([
+ new Community({id: 'idCommunity', funderId: 'random'}),
+ new Community({id: 'idCommunity1', funderId: 'random'}),
+ ]);
+
+ const count = subscriptionRepository.stubs.count.resolves();
+
+ const result = await controller.find(
+ 'A_TRAITER',
+ 'incentiveId',
+ 'incentiveType',
+ 'idCommunity',
+ 'lastName',
+ 'email@gmail.com',
+ );
+
+ const res = {
+ subscriptions: [input],
+ ...count,
+ };
+
+ expect(result).to.deepEqual(res);
+ });
+
+ it('SubscriptionController find : community mismatch', async () => {
+ repository.stubs.find.resolves([input]);
+ userRepository.stubs.findOne.resolves(user);
+ funderService.stubs.getFunderByName.resolves({
+ name: 'funderName',
+ funderType: 'entreprise',
+ id: 'random',
+ });
+ try {
+ await controller.find('A_TRAITER', 'incentiveId', 'idCommunity', 'lastName');
+ } catch (err) {
+ expect(err).to.deepEqual(mismatchCommunityError);
+ }
+ });
+
+ it('SubscriptionController findById : successful', async () => {
+ repository.stubs.findById.resolves(input);
+ const result = await controller.findById('someRandomId');
+
+ expect(result).to.deepEqual(input);
+ });
+
+ it('SubscriptionController validate : error', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId1').resolves(input1);
+ // Invokes business
+ try {
+ await controller.validate('randomInputId1', {
+ mode: 'unique',
+ amount: 1,
+ } as ValidationSinglePayment);
+ sinon.assert.fail();
+ } catch (error) {
+ // Checks
+ expect(repository.stubs.updateById.notCalled).true();
+ expect(spy.checkPayment.calledOnce).false();
+ expect(error.message).to.equal(expectedError.message);
+ expect(error.path).to.equal(expectedError.path);
+ }
+ });
+
+ it('SubscriptionController validate with funderType=Enterprise : successful', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input);
+ citizenRepository.stubs.findById.resolves(citizen);
+
+ // Invokes business
+ const payment = {
+ mode: 'unique',
+ amount: 1,
+ } as ValidationSinglePayment;
+ const result = await controller.validate('randomInputId', payment);
+ // Checks
+ expect(input.status).equal(SUBSCRIPTION_STATUS.VALIDATED);
+ expect(input.subscriptionValidation).deepEqual(payment);
+ expect(result).to.Null;
+ });
+
+ it('SubscriptionController validate with funderType=Collectivity : successful', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input2);
+
+ // Invokes business
+ const payment = {
+ mode: 'unique',
+ amount: 1,
+ } as ValidationSinglePayment;
+ const result = await controller.validate('randomInputId', payment);
+ // Checks
+ expect(input2.status).equal(SUBSCRIPTION_STATUS.VALIDATED);
+ expect(input2.subscriptionValidation).deepEqual(payment);
+ expect(result).to.Null;
+ });
+
+ it('SubscriptionController reject : error', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId1').resolves(input1);
+ // Invokes business
+
+ try {
+ await controller.reject('randomInputId1', {
+ type: 'ConditionsNonRespectees',
+ } as CommonRejection);
+ sinon.assert.fail();
+ } catch (error) {
+ // Checks
+ expect(repository.stubs.updateById.notCalled).true();
+ expect(spy.checkRefusMotif.calledOnce).false();
+ expect(error.message).to.equal(expectedError.message);
+ }
+ });
+
+ it('SubscriptionController reject with funder=Enterprise && reason=Condition : successful', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input);
+ citizenRepository.stubs.findById.resolves(citizen);
+
+ // Invokes business
+ const reason = {
+ type: REJECTION_REASON.CONDITION,
+ } as CommonRejection;
+ const result = await controller.reject('randomInputId', reason);
+ // Checks
+ expect(input.status).equal(SUBSCRIPTION_STATUS.REJECTED);
+ expect(input.subscriptionRejection).deepEqual(reason);
+ expect(input.specificFields).to.Null;
+ expect(input.attachments).to.Null;
+ expect(result).to.Null;
+ });
+
+ it('SubscriptionController reject with funder=Collectivity && invalid proof : successful', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input2);
+
+ // Invokes business
+ const reason = {
+ type: REJECTION_REASON.INVALID_PROOF,
+ } as CommonRejection;
+ const result = await controller.reject('randomInputId', reason);
+ // Checks
+ expect(input2.status).equal(SUBSCRIPTION_STATUS.REJECTED);
+ expect(input2.subscriptionRejection).deepEqual(reason);
+ expect(input2.specificFields).to.Null;
+ expect(input2.attachments).to.Null;
+ expect(result).to.Null;
+ });
+
+ it('SubscriptionController reject with rejectionReason=Missing proof : successful', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input2);
+
+ // Invokes business
+ const reason = {
+ type: REJECTION_REASON.MISSING_PROOF,
+ } as CommonRejection;
+ const result = await controller.reject('randomInputId', reason);
+ // Checks
+ expect(input2.status).equal(SUBSCRIPTION_STATUS.REJECTED);
+ expect(input2.subscriptionRejection).deepEqual(reason);
+ expect(input2.specificFields).to.Null;
+ expect(input2.attachments).to.Null;
+ expect(result).to.Null;
+ });
+
+ it('SubscriptionController reject with rejectionReason=Other : successful', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input2);
+
+ // Invokes business
+ const reason = {
+ type: REJECTION_REASON.OTHER,
+ other: 'Other',
+ } as OtherReason;
+ const result = await controller.reject('randomInputId', reason);
+ // Checks
+ expect(input2.status).equal(SUBSCRIPTION_STATUS.REJECTED);
+ expect(input2.subscriptionRejection).deepEqual(reason);
+ expect(input2.specificFields).to.Null;
+ expect(input2.attachments).to.Null;
+ expect(result).to.Null;
+ });
+
+ it('SubscriptionController getSubscriptionFileByName : successful', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input);
+ s3Service.stubs.downloadFileBuffer.resolves({});
+
+ const result = await controller.getSubscriptionFileByName(
+ 'randomInputId',
+ 'helloworld.jpg',
+ );
+
+ sinon.assert.calledOnceWithExactly(
+ s3Service.stubs.downloadFileBuffer,
+ 'email@gmail.com',
+ 'randomInputId',
+ 'helloworld.jpg',
+ );
+ expect(result).to.deepEqual({});
+ });
+
+ it('SubscriptionController getSubscriptionFileByName : error', async () => {
+ // Stub method
+ repository.stubs.findById.withArgs('randomInputId').resolves(input);
+ try {
+ s3Service.stubs.downloadFileBuffer.rejects({});
+ await controller.getSubscriptionFileByName('randomInputId', 'helloworld.jpg');
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error).to.deepEqual({});
+ }
+ });
+
+ it('SubscriptionController subscriptions/export : error', async () => {
+ // Stub method
+ try {
+ userRepository.stubs.findOne.resolves(user);
+ subscriptionRepository.stubs.find.resolves([initInput]);
+ const response: any = {};
+ await controller.generateExcel(response);
+ } catch (error) {
+ expect(error).to.deepEqual(downloadError);
+ }
+ });
+
+ it('SubscriptionController getMetadata : successful', async () => {
+ metadataRepository.stubs.findById.resolves(mockMetadataFindById);
+ const result = await controller.getMetadata('randomMetadataId');
+ expect(result).to.deepEqual(mockMetadataReturnOK);
+ });
+
+ it('SubscriptionController getMetadata : error', async () => {
+ try {
+ metadataRepository.stubs.findById.rejects('Error');
+ await controller.getMetadata('randomMetadataId');
+ } catch (err) {
+ expect(err.name).to.equal('Error');
+ }
+ });
+
+ it('SubscriptionV1Controller createMetadata : successful', async () => {
+ metadataRepository.stubs.create.resolves(new Metadata({id: 'randomMetadataId'}));
+ const result: any = await controller.createMetadata(mockMetadata);
+ expect(result.metadataId).to.equal('randomMetadataId');
+ });
+
+ it('SubscriptionV1Controller createMetadata : error', async () => {
+ try {
+ metadataRepository.stubs.create.rejects('Error');
+ await controller.createMetadata(mockMetadata);
+ } catch (err) {
+ expect(err.name).to.equal('Error');
+ }
+ });
+
+ function givenStubbedRepository() {
+ repository = createStubInstance(SubscriptionRepository);
+ communityRepository = createStubInstance(CommunityRepository);
+ userRepository = createStubInstance(UserRepository);
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ citizenRepository = createStubInstance(CitizenRepository);
+ metadataRepository = createStubInstance(MetadataRepository);
+ }
+
+ function givenStubbedService() {
+ s3Service = createStubInstance(S3Service);
+ funderService = createStubInstance(FunderService);
+ }
+});
+
+const initInput = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ communityId: 'id1',
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const initInput1 = new Subscription({
+ id: 'randomInputId1',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const initInput2 = new Subscription({
+ id: 'randomInputId1',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const citizen = Object.assign(new Citizen(), {
+ id: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ birthdate: '1991-11-17',
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: {
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: 'email1@gmail.com',
+ },
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+});
+
+const expectedError = new ValidationError(
+ 'subscriptions.error.bad.status',
+ '/subscriptionBadStatus',
+ StatusCode.UnprocessableEntity,
+);
+
+const mismatchCommunityError = new ValidationError(
+ `subscriptions.error.communities.mismatch`,
+ `/subscriptions`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+);
+
+const downloadError = new ValidationError(
+ 'Le téléchargement a échoué, veuillez réessayer',
+ '/downloadXlsx',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+);
+
+const currentUser: IUser = {
+ id: 'idUser',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/entreprises/Capgemini'],
+ roles: ['gestionnaires'],
+ [securityId]: 'idEnterprise',
+};
+
+const user = new User({
+ id: 'idUser',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['gestionnaires'],
+ communityIds: ['id1', 'id2'],
+});
+
+const mockMetadataFindById = new Metadata({
+ id: 'randomMetadataId',
+ incentiveId: 'randomAidId',
+ citizenId: 'randomCitizenId',
+ attachmentMetadata: new AttachmentMetadata(
+ Object.assign({
+ invoices: [
+ {
+ customer: {
+ customerName: 'name',
+ customerSurname: 'surname',
+ },
+ transaction: {
+ purchaseDate: new Date('2021-03-03'),
+ },
+ products: [
+ {
+ productName: 'fileName',
+ },
+ ],
+ },
+ ],
+ totalElements: 1,
+ }),
+ ),
+});
+
+const mockMetadataReturnOK = {
+ incentiveId: 'randomAidId',
+ citizenId: 'randomCitizenId',
+ attachmentMetadata: [{fileName: '03-03-2021_fileName_surname_name.pdf'}],
+};
+
+const mockMetadata = new Metadata({
+ incentiveId: 'randomAidId',
+ citizenId: 'randomCitizenId',
+ attachmentMetadata: new AttachmentMetadata({}),
+});
diff --git a/api/src/__tests__/controllers/territory.controller.test.ts b/api/src/__tests__/controllers/territory.controller.test.ts
new file mode 100644
index 0000000..a6a16d4
--- /dev/null
+++ b/api/src/__tests__/controllers/territory.controller.test.ts
@@ -0,0 +1,163 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {IncentiveRepository, TerritoryRepository} from '../../repositories';
+import {TerritoryController} from '../../controllers';
+import {ValidationError} from '../../validationError';
+import {TerritoryService} from '../../services/territory.service';
+import {Incentive, Territory} from '../../models';
+import {ResourceName, StatusCode} from '../../utils';
+
+describe('TerritoryController', () => {
+ let territoryRepository: StubbedInstanceWithSinonAccessor,
+ incentiveRepository: StubbedInstanceWithSinonAccessor,
+ territoryService: StubbedInstanceWithSinonAccessor,
+ territoryController: TerritoryController;
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ givenStubbedService();
+ territoryController = new TerritoryController(
+ territoryRepository,
+ incentiveRepository,
+ territoryService,
+ );
+ });
+
+ it('TerritoryController Post /territories : Succesfull', async () => {
+ territoryService.stubs.createTerritory.resolves(territoryMock);
+ const result = await territoryController.create(territoryPayload);
+ expect(result).to.deepEqual(territoryMock);
+ });
+
+ it('TerritoryController Get /territories : Successful', async () => {
+ territoryRepository.stubs.find.resolves([territoryMock]);
+ const result = await territoryController.find();
+ expect(result).to.deepEqual([territoryMock]);
+ });
+
+ it('TerritoryController Get /territories/{id} : Successful', async () => {
+ territoryRepository.stubs.findById.resolves(territoryMock);
+ const result = await territoryController.findById('634c83b994f56f610415f9c6');
+ expect(result).to.deepEqual(territoryMock);
+ });
+
+ it('TerritoryController Patch /territories/{id} unique name', async () => {
+ try {
+ territoryRepository.stubs.findOne.resolves(territoryMockMatchName);
+ await territoryController.updateById('634c83b994f56f610415f9c6', {
+ name: 'new Territory name',
+ } as Territory);
+ } catch (error) {
+ expect(error).to.deepEqual(territoryNameUnique);
+ }
+ });
+
+ it('TerritoryController Patch /territories/{id} Successful', async () => {
+ territoryRepository.stubs.findOne.resolves();
+ incentiveRepository.stubs.find.resolves([mockIncentive]);
+ territoryRepository.stubs.updateById.resolves();
+ incentiveRepository.stubs.updateById.resolves();
+ await territoryController.updateById('634c83b994f56f610415f9c6', {
+ name: 'new Territory name',
+ } as Territory);
+ });
+
+ // TODO: REMOVING DEPRECATED territoryName.
+ it('TerritoryController Patch /territories/{id} Successful with territoryName', async () => {
+ territoryRepository.stubs.findOne.resolves();
+ incentiveRepository.stubs.find.resolves([mockIncentive2]);
+ territoryRepository.stubs.updateById.resolves();
+ incentiveRepository.stubs.updateById.resolves();
+ await territoryController.updateById('634c83b994f56f610415f9c6', {
+ name: 'new Territory name',
+ } as Territory);
+ });
+
+ it('TerritoryController GET /territories/count Successful', async () => {
+ const territoryCount = {
+ count: 12,
+ };
+ territoryRepository.stubs.count.resolves(territoryCount);
+ const result = await territoryController.count();
+ expect(result).to.deepEqual(territoryCount);
+ });
+
+ function givenStubbedRepository() {
+ territoryRepository = createStubInstance(TerritoryRepository);
+ incentiveRepository = createStubInstance(IncentiveRepository);
+ }
+
+ function givenStubbedService() {
+ territoryService = createStubInstance(TerritoryService);
+ }
+
+ const territoryPayload = new Territory({
+ name: 'Toulouse',
+ });
+
+ const territoryMock = new Territory({
+ name: 'Toulouse',
+ id: '634c83b994f56f610415f9c6',
+ });
+
+ const territoryMockMatchName = new Territory({
+ name: 'new Territory Name',
+ id: '634c83b994f56f610415f9c6',
+ });
+
+ const territoryNameUnique = new ValidationError(
+ 'territory.name.error.unique',
+ '/territoryName',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Territory,
+ );
+
+ // TODO: REMOVING DEPRECATED territoryName.
+ const mockIncentive2 = new Incentive({
+ territory: {name: 'Toulouse', id: '634c83b994f56f610415f9c6'} as Territory,
+ territoryName: 'Toulouse', // TODO: REMOVING DEPRECATED territoryName.
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: 'randomNationalId',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ });
+
+ const mockIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: '634c83b994f56f610415f9c6'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: 'randomNationalId',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ });
+});
diff --git a/api/src/__tests__/controllers/user.controller.test.ts b/api/src/__tests__/controllers/user.controller.test.ts
new file mode 100644
index 0000000..3315893
--- /dev/null
+++ b/api/src/__tests__/controllers/user.controller.test.ts
@@ -0,0 +1,505 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {
+ CommunityRepository,
+ UserEntityRepository,
+ UserRepository,
+ KeycloakGroupRepository,
+} from '../../repositories';
+import {UserController} from '../../controllers';
+import {KeycloakService, FunderService} from '../../services';
+import {Community, User, KeycloakRole} from '../../models';
+import {FUNDER_TYPE, IUser, ResourceName, StatusCode} from '../../utils';
+import {ValidationError} from '../../validationError';
+
+describe('UserController (unit)', () => {
+ let userRepository: StubbedInstanceWithSinonAccessor,
+ keycloakGroupRepository: StubbedInstanceWithSinonAccessor,
+ userEntityRepository: StubbedInstanceWithSinonAccessor,
+ communityRepository: StubbedInstanceWithSinonAccessor,
+ funderService: StubbedInstanceWithSinonAccessor,
+ kcService: StubbedInstanceWithSinonAccessor,
+ controller: UserController;
+
+ const currentUser: IUser = {
+ id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/citizens'],
+ roles: ['offline_access', 'uma_authorization'],
+ [securityId]: 'citizenId',
+ };
+
+ const user = new User({
+ id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['gestionnaires', 'superviseurs'],
+ communityIds: ['id1', 'id2'],
+ });
+
+ const userSupervisor = new User({
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['superviseurs'],
+ });
+
+ const errorManualAffiliation = new ValidationError(
+ `users.funder.manualAffiliation.refuse`,
+ `/users`,
+ StatusCode.PreconditionFailed,
+ ResourceName.User,
+ );
+
+ const enterpriseNoManualAffiliation = {
+ id: 'random',
+ emailFormat: ['@arandom.fr', '@random.com'],
+ hasManualAffiliation: false,
+ name: 'enterprise',
+ isHris: true,
+ funderType: FUNDER_TYPE.enterprise,
+ };
+
+ const userWithManualAffiliation = new User({
+ id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['gestionnaires', 'superviseurs'],
+ communityIds: ['id1', 'id2'],
+ canReceiveAffiliationMail: true,
+ });
+
+ beforeEach(() => {
+ givenStubbedComponent();
+ controller = new UserController(
+ userRepository,
+ keycloakGroupRepository,
+ userEntityRepository,
+ communityRepository,
+ kcService,
+ funderService,
+ currentUser,
+ );
+ });
+
+ describe('UserController', () => {
+ it('UserController create : fails because of emailformat error', async () => {
+ const errorKc = new ValidationError(
+ `email.error.emailFormat`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ try {
+ funderService.stubs.getFunders.resolves([
+ {
+ id: 'random',
+ emailFormat: ['@arandom.fr', '@random.com'],
+ funderType: FUNDER_TYPE.enterprise,
+ },
+ ]);
+
+ await controller.create(user);
+ } catch ({message}) {
+ expect(message).to.equal(errorKc.message);
+ }
+ funderService.stubs.getFunders.restore();
+ });
+
+ it('UserController create : fails because of unmismatched roles', async () => {
+ const errorKc = new ValidationError(
+ `users.error.roles.mismatch`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ try {
+ funderService.stubs.getFunders.resolves([
+ {
+ id: 'random',
+ emailFormat: ['@random.fr', '@random.com'],
+ funderType: FUNDER_TYPE.enterprise,
+ },
+ ]);
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves(['superviseurs']);
+
+ await controller.create(user);
+ } catch ({message}) {
+ expect(message).to.equal(errorKc.message);
+ }
+ funderService.stubs.getFunders.restore();
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore();
+ });
+
+ it('UserController create : fails because of unmismatched communauties', async () => {
+ const errorKc = new ValidationError(
+ `users.error.communities.mismatch`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ try {
+ funderService.stubs.getFunders.resolves([
+ {
+ id: 'random',
+ emailFormat: ['@random.fr', '@random.com'],
+ funderType: FUNDER_TYPE.enterprise,
+ },
+ ]);
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([
+ 'superviseurs',
+ 'gestionnaires',
+ 'newRole',
+ ]);
+ communityRepository.stubs.findByFunderId.resolves([]);
+
+ await controller.create(user);
+ } catch ({message}) {
+ expect(message).to.equal(errorKc.message);
+ }
+ funderService.stubs.getFunders.restore();
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore();
+ communityRepository.stubs.findByFunderId.restore();
+ });
+
+ it('UserController create : fails because of create repository error', async () => {
+ const errorRepository = 'can not add data in database';
+ try {
+ funderService.stubs.getFunders.resolves([
+ {id: 'random', funderType: FUNDER_TYPE.collectivity},
+ ]);
+ kcService.stubs.createUserKc.resolves({id: 'randomInputId'});
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([
+ 'superviseurs',
+ 'gestionnaires',
+ 'newRole',
+ ]);
+ communityRepository.stubs.findByFunderId.resolves([
+ new Community({id: 'id1'}),
+ new Community({id: 'id2'}),
+ new Community({id: 'id3'}),
+ ]);
+ userRepository.stubs.create.rejects(errorRepository);
+ kcService.stubs.deleteUserKc.resolves();
+
+ await controller.create(user);
+ } catch ({name}) {
+ expect(name).to.equal(errorRepository);
+ }
+
+ kcService.stubs.createUserKc.restore();
+ userRepository.stubs.create.restore();
+ funderService.stubs.getFunders.restore();
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore();
+ communityRepository.stubs.findByFunderId.restore();
+ kcService.stubs.deleteUserKc.restore();
+ });
+
+ it('UserController create : fails because of can not find funder', async () => {
+ const errorFunder = new ValidationError(
+ `users.error.funders.missed`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ try {
+ kcService.stubs.createUserKc.resolves({
+ id: 'randomInputId',
+ });
+ funderService.stubs.getFunders.resolves([]);
+ kcService.stubs.deleteUserKc.resolves();
+
+ await controller.create(user);
+ } catch (err) {
+ expect(err).to.deepEqual(errorFunder);
+ }
+
+ kcService.stubs.createUserKc.restore();
+ kcService.stubs.deleteUserKc.restore();
+ userRepository.stubs.create.restore();
+ funderService.stubs.getFunders.restore();
+ });
+
+ it('UserController create : fails because of manual affiliation condition error', async () => {
+ try {
+ funderService.stubs.getFunders.resolves([enterpriseNoManualAffiliation]);
+
+ await controller.create(userWithManualAffiliation);
+ } catch ({message}) {
+ expect(message).to.equal(errorManualAffiliation.message);
+ }
+ funderService.stubs.getFunders.restore();
+ });
+
+ it('UserController create user funder : successful', async () => {
+ funderService.stubs.getFunders.resolves([
+ {
+ id: 'random',
+ emailFormat: ['@random.fr', '@random.com'],
+ funderType: FUNDER_TYPE.enterprise,
+ },
+ ]);
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([
+ 'superviseurs',
+ 'gestionnaires',
+ 'newRole',
+ ]);
+ communityRepository.stubs.findByFunderId.resolves([
+ new Community({id: 'id1'}),
+ new Community({id: 'id2'}),
+ new Community({id: 'id3'}),
+ ]);
+ kcService.stubs.createUserKc.resolves({id: 'randomInputId'});
+
+ userRepository.stubs.create.resolves(user);
+
+ const result = await controller.create(user);
+
+ expect(result).to.deepEqual({
+ id: user.id,
+ email: user.email,
+ lastName: user.lastName,
+ firstName: user.firstName,
+ });
+ funderService.stubs.getFunders.restore();
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore();
+ communityRepository.stubs.findByFunderId.restore();
+ kcService.stubs.createUserKc.restore();
+ userRepository.stubs.create.restore();
+ });
+
+ it('UserController create funder keycloakResult undefined', async () => {
+ funderService.stubs.getFunders.resolves([
+ {
+ id: 'random',
+ emailFormat: ['@random.fr', '@random.com'],
+ funderType: FUNDER_TYPE.enterprise,
+ },
+ ]);
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([
+ 'superviseurs',
+ 'gestionnaires',
+ 'newRole',
+ ]);
+ communityRepository.stubs.findByFunderId.resolves([
+ new Community({id: 'id1'}),
+ new Community({id: 'id2'}),
+ new Community({id: 'id3'}),
+ ]);
+
+ kcService.stubs.createUserKc.resolves(undefined);
+
+ const result = await controller.create(user);
+
+ expect(result).to.deepEqual(undefined);
+
+ funderService.stubs.getFunders.restore();
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore();
+ communityRepository.stubs.findByFunderId.restore();
+ kcService.stubs.createUserKc.restore();
+ });
+
+ it('UserController create supervisor funder successful', async () => {
+ funderService.stubs.getFunders.resolves([
+ {
+ id: 'random',
+ emailFormat: ['@random.fr', '@random.com'],
+ funderType: FUNDER_TYPE.enterprise,
+ },
+ ]);
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves([
+ 'superviseurs',
+ 'gestionnaires',
+ ]);
+
+ kcService.stubs.createUserKc.resolves({id: 'randomInputId'});
+
+ userRepository.stubs.create.resolves(userSupervisor);
+
+ const result = await controller.create(userSupervisor);
+
+ expect(result).to.deepEqual({
+ id: userSupervisor.id,
+ email: userSupervisor.email,
+ lastName: userSupervisor.lastName,
+ firstName: userSupervisor.firstName,
+ });
+ funderService.stubs.getFunders.restore();
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore();
+ communityRepository.stubs.findByFunderId.restore();
+ kcService.stubs.createUserKc.restore();
+ userRepository.stubs.create.restore();
+ });
+
+ it('UserController /count : successful', async () => {
+ const countRes = {
+ count: 12,
+ };
+
+ userRepository.stubs.count.resolves(countRes);
+ const result = await controller.count();
+
+ expect(result).to.deepEqual(countRes);
+ });
+
+ it('UserController /get : successful', async () => {
+ funderService.stubs.getFunders.resolves([newFunder]);
+ userRepository.stubs.find.resolves([users]);
+ communityRepository.stubs.find.resolves([newCommunity]);
+ userEntityRepository.stubs.getUserRoles.resolves([roles]);
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves(['test', 'test1']);
+
+ const result = await controller.find();
+
+ expect(result).to.deepEqual([mockUsersWithInfos]);
+ funderService.stubs.getFunders.restore();
+ userRepository.stubs.find.restore();
+ communityRepository.stubs.find.restore();
+ userEntityRepository.stubs.getUserRoles.restore();
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.restore();
+ });
+
+ it('UserController /v1/users/roles : successful', async () => {
+ const res: any = ['gestionnaires', 'superviseurs'];
+ keycloakGroupRepository.stubs.getSubGroupFunderRoles.resolves(res);
+
+ const result = await controller.getRolesForUsers();
+
+ expect(result).to.deepEqual(res);
+ });
+
+ it('UserController findUserById: Successful ', async () => {
+ userRepository.stubs.findById.resolves(user);
+ userEntityRepository.stubs.getUserRoles.resolves([roles]);
+ const result = await controller.findUserById(
+ 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ );
+ const res: string[] = ['test'];
+ expect(result).to.deepEqual({...result, roles: res});
+ userRepository.stubs.findById.restore();
+ userEntityRepository.stubs.getUserRoles.restore();
+ });
+
+ it('UserController findUserById: Error ', async () => {
+ const userError = new ValidationError(
+ `Access Denied`,
+ `/authorization`,
+ StatusCode.Forbidden,
+ );
+
+ try {
+ userRepository.stubs.findById.resolves(user);
+ userEntityRepository.stubs.getUserRoles.resolves([roles]);
+ const communities = new Community({id: 'id1', name: 'name'});
+ communityRepository.stubs.findByFunderId.resolves([communities]);
+ await controller.findUserById('1234567890');
+ } catch ({message}) {
+ expect(message).to.equal(userError.message);
+ }
+ userRepository.stubs.findById.restore();
+ userEntityRepository.stubs.getUserRoles.restore();
+ });
+
+ it('UserController updateById : fails because of manual affiliation condition error', async () => {
+ try {
+ funderService.stubs.getFunders.resolves([enterpriseNoManualAffiliation]);
+
+ await controller.updateById(
+ 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ userWithManualAffiliation,
+ );
+ } catch ({message}) {
+ expect(message).to.equal(errorManualAffiliation.message);
+ }
+ funderService.stubs.getFunders.restore();
+ });
+
+ it('UserController updateById: successful ', async () => {
+ userRepository.stubs.findById.resolves(users);
+ kcService.stubs.updateUserGroupsKc.resolves();
+ const result = await controller.updateById(
+ 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ usersUpdated,
+ );
+ expect(result).to.deepEqual({id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d'});
+ userRepository.stubs.findById.restore();
+ kcService.stubs.updateUserGroupsKc.restore();
+ });
+
+ it('UserController deleteById: successful ', async () => {
+ kcService.stubs.deleteUserKc.resolves('a0e48494-1bfb-4142-951b-16ec6d9c8e1d');
+ const result = await controller.deleteById('a0e48494-1bfb-4142-951b-16ec6d9c8e1d');
+ expect(result).to.deepEqual({id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d'});
+ kcService.stubs.deleteUserKc.restore();
+ });
+ });
+
+ function givenStubbedComponent() {
+ userRepository = createStubInstance(UserRepository);
+ communityRepository = createStubInstance(CommunityRepository);
+ userEntityRepository = createStubInstance(UserEntityRepository);
+ keycloakGroupRepository = createStubInstance(KeycloakGroupRepository);
+ communityRepository = createStubInstance(CommunityRepository);
+ kcService = createStubInstance(KeycloakService);
+ funderService = createStubInstance(FunderService);
+ }
+});
+
+const users = new User({
+ email: 'w.housni24@gmail.co',
+ firstName: 'Walid',
+ lastName: 'Walid HOUSNI',
+ id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d',
+ communityIds: ['618a4dad80ea32653c7a20d7'],
+});
+
+const usersUpdated = new User({
+ email: 'w.housni24@gmail.co',
+ firstName: 'Baghrous',
+ lastName: 'Abdelmoumene',
+ id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d',
+ communityIds: ['618a4dad80ea32653c7a20d7'],
+ roles: ['gestionnaires'],
+});
+
+const roles = new KeycloakRole({name: 'test'});
+
+const newCommunity = new Community({
+ id: '618a4dad80ea32653c7a20d7',
+ name: 'Something wonderful',
+ funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d',
+});
+
+const newFunder = {
+ id: 'efec7e68-fc17-4078-82c5-65d53961f34d',
+ name: 'Collectivity United.',
+ citizensCount: undefined,
+ mobilityBudget: undefined,
+ funderType: FUNDER_TYPE.collectivity,
+};
+
+const mockUsersWithInfos = {
+ email: 'w.housni24@gmail.co',
+ firstName: 'Walid',
+ lastName: 'Walid HOUSNI',
+ id: 'a0e48494-1bfb-4142-951b-16ec6d9c8e1d',
+ funderId: 'efec7e68-fc17-4078-82c5-65d53961f34d',
+ communityIds: ['618a4dad80ea32653c7a20d7'],
+ funderType: 'Collectivité',
+ communityName: 'Something wonderful',
+ funderName: 'Collectivity United.',
+ roles: 'Test',
+};
diff --git a/api/src/__tests__/cronjob/nonActivatedAccountDeletionCronJob.test.ts b/api/src/__tests__/cronjob/nonActivatedAccountDeletionCronJob.test.ts
new file mode 100644
index 0000000..1d0b70e
--- /dev/null
+++ b/api/src/__tests__/cronjob/nonActivatedAccountDeletionCronJob.test.ts
@@ -0,0 +1,63 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {CronJobService, CitizenService} from '../../services';
+import {NonActivatedAccountDeletionCronJob} from '../../cronjob';
+
+import {CronJob} from '../../models';
+
+describe('nonActivatedAccountDeletion cronjob', () => {
+ let inactifAccountDeletionCronJobs: any = null;
+
+ let cronJobService: StubbedInstanceWithSinonAccessor,
+ citizenService: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ cronJobService = createStubInstance(CronJobService);
+ citizenService = createStubInstance(CitizenService);
+ inactifAccountDeletionCronJobs = new NonActivatedAccountDeletionCronJob(
+ cronJobService,
+ citizenService,
+ );
+ });
+
+ afterEach(() => {});
+
+ it('nonActivatedAccountDeletion cronjob : performJob success', async () => {
+ cronJobService.stubs.getCronsLog.resolves([]);
+ cronJobService.stubs.delCronLog.resolves();
+ cronJobService.stubs.createCronLog.resolves();
+ citizenService.stubs.accountDeletionService.resolves();
+ cronJobService.stubs.delCronLog.resolves();
+ await inactifAccountDeletionCronJobs.performJob();
+ });
+
+ it('nonActivatedAccountDeletion cronjob : performJob success with a log in DB', async () => {
+ cronJobService.stubs.getCronsLog.resolves([mockCronLog]);
+ cronJobService.stubs.delCronLog.resolves();
+ cronJobService.stubs.createCronLog.resolves();
+ citizenService.stubs.accountDeletionService.resolves();
+ cronJobService.stubs.delCronLog.resolves();
+ await inactifAccountDeletionCronJobs.performJob();
+ });
+
+ it('nonActivatedAccountDeletion cronjob : performJob error', async () => {
+ try {
+ cronJobService.stubs.getCronsLog.resolves([]);
+ cronJobService.stubs.createCronLog.rejects();
+ await inactifAccountDeletionCronJobs.performJob();
+ } catch (err) {
+ expect(citizenService.stubs.accountDeletionService.calledOnce).false();
+ cronJobService.stubs.delCronLog.resolves();
+ }
+ });
+
+ const mockCronLog = new CronJob({
+ id: '123456',
+ type: 'Delete_user_account',
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+});
diff --git a/api/src/__tests__/cronjob/rabbitmqCronJob.test.ts b/api/src/__tests__/cronjob/rabbitmqCronJob.test.ts
new file mode 100644
index 0000000..5d253e1
--- /dev/null
+++ b/api/src/__tests__/cronjob/rabbitmqCronJob.test.ts
@@ -0,0 +1,83 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {EventEmitter} from 'events';
+
+import {RabbitmqService} from '../../services';
+import {RabbitmqCronJob} from '../../cronjob';
+import {EVENT_MESSAGE, UPDATE_MODE} from '../../utils';
+
+describe('Rabbitmq cronjob', () => {
+ let rabbitmqCronjob: any = null;
+
+ let clock: any,
+ rabbitmqService: StubbedInstanceWithSinonAccessor,
+ parentProcessService: any;
+
+ beforeEach(() => {
+ rabbitmqService = createStubInstance(RabbitmqService);
+ parentProcessService = new EventEmitter();
+ rabbitmqCronjob = new RabbitmqCronJob(rabbitmqService, parentProcessService);
+ });
+
+ afterEach(() => {});
+
+ it('Rabbitmq cronjob : performJob success', async () => {
+ const enterpriseRepositoryResult: string[] = ['enterprise'];
+ rabbitmqService.stubs.getHRISEnterpriseNameList.resolves(enterpriseRepositoryResult);
+ sinon.spy(parentProcessService, 'emit');
+ await rabbitmqCronjob.performJob();
+ sinon.assert.calledWithExactly(parentProcessService.emit, EVENT_MESSAGE.UPDATE, {
+ type: EVENT_MESSAGE.UPDATE,
+ data: {[UPDATE_MODE.ADD]: enterpriseRepositoryResult, [UPDATE_MODE.DELETE]: []},
+ });
+ rabbitmqService.stubs.getHRISEnterpriseNameList.restore();
+ });
+
+ it('Rabbitmq cronjob : performJob error', async () => {
+ sinon.spy(parentProcessService, 'emit');
+ try {
+ rabbitmqService.stubs.getHRISEnterpriseNameList.rejects();
+ await rabbitmqCronjob.performJob();
+ } catch (err) {
+ expect(err.message).to.equal('An error occurred');
+ expect(parentProcessService.emit.calledOnce).false();
+ rabbitmqService.stubs.getHRISEnterpriseNameList.restore();
+ }
+ });
+
+ it('Rabbitmq cronjob : get ready event', async () => {
+ const rabbitStub = sinon.stub(rabbitmqCronjob, 'performJob').resolves();
+ const fireStub = sinon.stub(rabbitmqCronjob, 'fireOnTick').resolves();
+ const startStub = sinon.stub(rabbitmqCronjob, 'start').resolves();
+ parentProcessService.emit(EVENT_MESSAGE.READY);
+ sinon.assert.calledOnce(fireStub);
+ sinon.assert.calledOnce(startStub);
+ startStub.restore();
+ fireStub.restore();
+ rabbitStub.restore();
+ });
+
+ it('Rabbitmq cronjob : performJob no enterprise to update', async () => {
+ const enterpriseRepositoryResult: string[] = [];
+ rabbitmqService.stubs.getHRISEnterpriseNameList.resolves(enterpriseRepositoryResult);
+ sinon.spy(parentProcessService, 'emit');
+ await rabbitmqCronjob.performJob();
+ expect(parentProcessService.emit.calledOnce).false();
+ rabbitmqService.stubs.getHRISEnterpriseNameList.restore();
+ });
+
+ it('Rabbitmq cronjob : onTick', async () => {
+ clock = sinon.useFakeTimers();
+ const rabbitStub = sinon.stub(rabbitmqCronjob, 'performJob').resolves();
+ parentProcessService.emit(EVENT_MESSAGE.READY);
+ clock.tick(1 * 24 * 60 * 60 * 1000);
+ sinon.assert.calledOnce(rabbitStub);
+ rabbitStub.restore();
+ clock.restore();
+ });
+});
diff --git a/api/src/__tests__/cronjob/subscriptionCronJob.test.ts b/api/src/__tests__/cronjob/subscriptionCronJob.test.ts
new file mode 100644
index 0000000..837c66c
--- /dev/null
+++ b/api/src/__tests__/cronjob/subscriptionCronJob.test.ts
@@ -0,0 +1,58 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {CronJobService, SubscriptionService} from '../../services';
+import {SubscriptionCronJob} from '../../cronjob';
+
+import {CronJob} from '../../models';
+
+describe('subscription cronjob', () => {
+ let subscriptionCronjob: any = null;
+
+ let cronJobService: StubbedInstanceWithSinonAccessor,
+ subscriptionService: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ cronJobService = createStubInstance(CronJobService);
+ subscriptionService = createStubInstance(SubscriptionService);
+ subscriptionCronjob = new SubscriptionCronJob(cronJobService, subscriptionService);
+ });
+
+ afterEach(() => {});
+
+ it('subscription cronjob : performJob success', async () => {
+ cronJobService.stubs.getCronsLog.resolves([]);
+ cronJobService.stubs.createCronLog.resolves();
+ subscriptionService.stubs.deleteSubscription.resolves();
+ cronJobService.stubs.delCronLog.resolves();
+ await subscriptionCronjob.performJob();
+ });
+
+ it('subscription cronjob : performJob success with a log in DB', async () => {
+ cronJobService.stubs.getCronsLog.resolves([mockCronLog]);
+ cronJobService.stubs.delCronLog.resolves();
+ cronJobService.stubs.createCronLog.resolves();
+ subscriptionService.stubs.deleteSubscription.resolves();
+ cronJobService.stubs.delCronLog.resolves();
+ await subscriptionCronjob.performJob();
+ });
+
+ it('subscription cronjob : performJob error', async () => {
+ try {
+ cronJobService.stubs.getCronsLog.resolves([]);
+ cronJobService.stubs.createCronLog.rejects();
+ await subscriptionCronjob.performJob();
+ } catch (err) {
+ expect(subscriptionService.stubs.deleteSubscription.calledOnce).false();
+ }
+ });
+
+ const mockCronLog = new CronJob({
+ id: '123456',
+ type: 'Delete_subscription',
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+});
diff --git a/api/src/__tests__/interceptors/affiliation.interceptors.test.ts b/api/src/__tests__/interceptors/affiliation.interceptors.test.ts
new file mode 100644
index 0000000..f7a9ef7
--- /dev/null
+++ b/api/src/__tests__/interceptors/affiliation.interceptors.test.ts
@@ -0,0 +1,375 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+ sinon,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {AffiliationInterceptor} from '../../interceptors';
+import {ValidationError} from '../../validationError';
+import {
+ IncentiveRepository,
+ CitizenRepository,
+ SubscriptionRepository,
+} from '../../repositories';
+import {FunderService} from '../../services';
+import {Affiliation, Incentive, Citizen, Subscription, Territory} from '../../models';
+import {
+ AFFILIATION_STATUS,
+ FUNDER_TYPE,
+ IUser,
+ ResourceName,
+ Roles,
+ StatusCode,
+} from '../../utils';
+
+describe('affiliation Interceptor', () => {
+ let interceptor: any = null;
+ let citizenRepository: StubbedInstanceWithSinonAccessor,
+ incentiveRepository: StubbedInstanceWithSinonAccessor,
+ funderService: StubbedInstanceWithSinonAccessor,
+ currentUserProfile: IUser,
+ subscriptionRepository: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ interceptor = new AffiliationInterceptor(
+ citizenRepository,
+ incentiveRepository,
+ subscriptionRepository,
+ funderService,
+ currentUserProfile,
+ );
+ });
+ const error = new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+
+ it('AffiliationInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+ it('AffiliationInterceptor: error"', async () => {
+ try {
+ funderService.stubs.getFunders.resolves([
+ {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ ]);
+
+ citizenRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtx, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor Create Subscription: error"', async () => {
+ try {
+ const incentive = new Incentive({id: '78952215', funderId: 'someFunderId'});
+ funderService.stubs.getFunders.resolves([
+ {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ ]);
+
+ incentiveRepository.stubs.findOne.resolves(incentive);
+
+ citizenRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxCreateSubscription, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor Create Subscription: error 2"', async () => {
+ try {
+ funderService.stubs.getFunders.resolves([
+ {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ ]);
+
+ incentiveRepository.stubs.findOne.resolves(null);
+
+ citizenRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxCreateSubscription2, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor add files: error"', async () => {
+ try {
+ const subscription = new Subscription({id: '5654555', incentiveId: '5854235'});
+ const incentive = new Incentive({id: '78952215', funderId: 'someFunderId'});
+
+ funderService.stubs.getFunders.resolves([
+ {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ ]);
+
+ incentiveRepository.stubs.findOne.resolves(incentive);
+ subscriptionRepository.stubs.findOne.resolves(subscription);
+
+ citizenRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxAddFiles, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ incentiveRepository.stubs.findOne.restore();
+ subscriptionRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor add files: error 2"', async () => {
+ try {
+ const subscription = new Subscription({id: '5654555'});
+ const incentive = new Incentive({id: '78952215'});
+
+ funderService.stubs.getFunders.resolves([
+ {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ ]);
+
+ incentiveRepository.stubs.findOne.resolves(incentive);
+ subscriptionRepository.stubs.findOne.resolves(subscription);
+
+ citizenRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxAddFiles, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ subscriptionRepository.stubs.findOne.restore();
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor: OK', async () => {
+ funderService.stubs.getFunders.resolves([
+ {id: 'testFunder', funderType: FUNDER_TYPE.collectivity, name: 'testName'},
+ ]);
+ const affiliation = new Affiliation(
+ Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}),
+ );
+ affiliation.affiliationStatus = AFFILIATION_STATUS.AFFILIATED;
+
+ const citizen = new Citizen({
+ affiliation,
+ });
+ citizenRepository.stubs.findOne.resolves(citizen);
+
+ const result = await interceptor.intercept(invocationCtx, () => {});
+ expect(result).to.Null;
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor find id incentive territory"', async () => {
+ try {
+ funderService.stubs.getFunders.resolves([
+ {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ ]);
+
+ const affiliation = new Affiliation(
+ Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}),
+ );
+ const citizen = new Citizen({
+ affiliation,
+ });
+
+ incentiveRepository.stubs.findOne.resolves(mockPublicIncentive);
+
+ citizenRepository.stubs.findOne.resolves(citizen);
+
+ await interceptor.intercept(invocationCtxFindId, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor find id incentive"', async () => {
+ try {
+ funderService.stubs.getFunders.resolves([
+ {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ ]);
+
+ const affiliation = new Affiliation(
+ Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}),
+ );
+ const citizen = new Citizen({
+ affiliation,
+ });
+
+ incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive);
+
+ citizenRepository.stubs.findOne.resolves(citizen);
+
+ await interceptor.intercept(invocationCtxFindId, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+
+ funderService.stubs.getFunders.restore();
+ citizenRepository.stubs.findOne.restore();
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor findIncentiveById as content_editor with employer incentive"', async () => {
+ const contentEditor = currentUserProfile;
+ contentEditor.roles = [Roles.CONTENT_EDITOR];
+
+ incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive);
+
+ const result = await interceptor.intercept(invocationCtxFindId, () => {});
+ expect(result).to.Null;
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor addAttachments: subscription not found', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxAddAttachments, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor finalizeSubscription: subscription not found', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxFinalizeSubscription, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ function givenStubbedRepository() {
+ citizenRepository = createStubInstance(CitizenRepository);
+ incentiveRepository = createStubInstance(IncentiveRepository);
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ funderService = createStubInstance(FunderService);
+ currentUserProfile = {
+ id: 'testId',
+ clientName: 'testName-client',
+ emailVerified: true,
+ [securityId]: 'testId',
+ roles: ['maas'],
+ };
+ }
+});
+
+const invocationCtx = {
+ methodName: 'findCommunitiesByFunderId',
+ args: ['testFunder'],
+};
+
+const invocationCtxCreateSubscription = {
+ methodName: 'createSubscription',
+ args: [{incentiveId: 'testAides'}],
+};
+
+const invocationCtxCreateSubscription2 = {
+ methodName: 'createSubscription',
+ args: [{incentiveId: 'testAides'}],
+};
+
+const invocationCtxAddFiles = {
+ methodName: 'addFiles',
+ args: ['tstSubscription'],
+};
+
+const invocationCtxFindId = {
+ methodName: 'findIncentiveById',
+ args: ['606c236a624cec2becdef277'],
+};
+
+const invocationCtxAddAttachments = {
+ methodName: 'addAttachments',
+ args: ['606c236a624cec2becdef277'],
+};
+
+const invocationCtxFinalizeSubscription = {
+ methodName: 'finalizeSubscription',
+ args: ['606c236a624cec2becdef277'],
+};
+
+const mockPublicIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef277',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const mockPrivateIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'test'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideEmployeur',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef277',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ funderId: 'funderId',
+});
+
+const errorNotFound = new ValidationError(
+ `Subscription not found`,
+ '/subscriptionNotFound',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+);
diff --git a/api/src/__tests__/interceptors/affiliationPublic.interceptors.test.ts b/api/src/__tests__/interceptors/affiliationPublic.interceptors.test.ts
new file mode 100644
index 0000000..83030c5
--- /dev/null
+++ b/api/src/__tests__/interceptors/affiliationPublic.interceptors.test.ts
@@ -0,0 +1,172 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+ sinon,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {AffiliationPublicInterceptor} from '../../interceptors';
+import {ValidationError} from '../../validationError';
+import {IncentiveRepository} from '../../repositories';
+import {Incentive, Territory} from '../../models';
+import {IUser, ResourceName, StatusCode} from '../../utils';
+
+describe('affiliation Interceptor', () => {
+ let interceptor: any = null;
+ let interceptor2: any = null;
+ let currentUserProfile: IUser,
+ otherUser: IUser,
+ incentiveRepository: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ givenStubbedRepository2();
+ interceptor = new AffiliationPublicInterceptor(
+ incentiveRepository,
+ currentUserProfile,
+ );
+ interceptor2 = new AffiliationPublicInterceptor(incentiveRepository, otherUser);
+ });
+ const error = new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+
+ const errorNotFound = new ValidationError(
+ `Incentive not found`,
+ '/incentiveNotFound',
+ StatusCode.NotFound,
+ ResourceName.Incentive,
+ );
+
+ it('AffiliationInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ it('AffiliationInterceptor find id incentive territoire"', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(mockPublicIncentive);
+
+ await interceptor.intercept(invocationCtxFindId, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor find id incentive error"', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive);
+
+ await interceptor.intercept(invocationCtxFindId, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor find id incentive error2"', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(mockPrivateIncentive);
+
+ await interceptor2.intercept(invocationCtxFindId2, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ it('AffiliationInterceptor find id incentive not found"', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(null);
+
+ await interceptor2.intercept(invocationCtxFindId2, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ function givenStubbedRepository() {
+ incentiveRepository = createStubInstance(IncentiveRepository);
+ currentUserProfile = {
+ id: 'testId',
+ clientName: 'testName-client',
+ emailVerified: true,
+ [securityId]: 'testId',
+ roles: ['maas', 'service_maas'],
+ };
+ }
+ function givenStubbedRepository2() {
+ incentiveRepository = createStubInstance(IncentiveRepository);
+ otherUser = {
+ id: 'testId',
+ clientName: 'testName-client',
+ emailVerified: true,
+ [securityId]: 'testId',
+ roles: ['maas'],
+ };
+ }
+});
+
+const invocationCtxFindId = {
+ methodName: 'findIncentiveById',
+ args: ['606c236a624cec2becdef277'],
+};
+
+const invocationCtxFindId2 = {
+ methodName: 'findIncentiveById',
+ args: ['1111111'],
+};
+
+const mockPublicIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'randomTerritoryId'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'incentive pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef277',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const mockPrivateIncentive = new Incentive({
+ territory: {name: 'Toulouse', id: 'randomTerritoryId'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'incentive pour acheter vélo électrique',
+ incentiveType: 'AideEmployeur',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: '606c236a624cec2becdef277',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+ funderId: 'funderId',
+});
diff --git a/api/src/__tests__/interceptors/citizen.interceptors.test.ts b/api/src/__tests__/interceptors/citizen.interceptors.test.ts
new file mode 100644
index 0000000..1e27bef
--- /dev/null
+++ b/api/src/__tests__/interceptors/citizen.interceptors.test.ts
@@ -0,0 +1,491 @@
+import {
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {Citizen, User} from '../../models';
+import {CitizenService} from '../../services';
+import {CitizenInterceptor} from '../../interceptors';
+import {CitizenRepository, UserRepository} from '../../repositories';
+import {ValidationError} from '../../validationError';
+import {AFFILIATION_STATUS, IUser, ResourceName, StatusCode} from '../../utils';
+import {CitizenController} from '../../controllers';
+
+describe('CitizenInterceptor', () => {
+ let interceptor: any = null;
+ let secondInterceptor: any = null;
+ let thirdInterceptor: any = null;
+ let citizenService: StubbedInstanceWithSinonAccessor,
+ citizenRepository: StubbedInstanceWithSinonAccessor,
+ userRepository: StubbedInstanceWithSinonAccessor,
+ currentUser: IUser,
+ otherUser: IUser,
+ citizenUser: IUser;
+
+ const err: any = new ValidationError(`citizens.error.birthdate.age`, '/birthdate');
+
+ const errorAffiliationNotFound: any = new ValidationError(
+ 'citizens.affiliation.not.found',
+ '/citizensAffiliationNotFound',
+ StatusCode.NotFound,
+ ResourceName.Affiliation,
+ );
+
+ const errorAffiliationImpossible: any = new ValidationError(
+ 'citizen.affiliation.impossible',
+ '/citizenAffiliationImpossible',
+ StatusCode.PreconditionFailed,
+ ResourceName.Affiliation,
+ );
+
+ const errorAccess: any = new ValidationError(
+ `citizen.disaffiliation.impossible`,
+ '/citizenDisaffiliationImpossible',
+ );
+
+ const errorNotFound: any = new ValidationError(
+ `Citizen not found`,
+ '/citizenNotFound',
+ StatusCode.NotFound,
+ ResourceName.Citizen,
+ );
+
+ const errorPassword: any = new ValidationError(
+ `Password cannot be empty`,
+ '/password',
+ StatusCode.PreconditionFailed,
+ ResourceName.Account,
+ );
+ const AccessDenied: any = new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+
+ const invocationContextCreates = {
+ target: {},
+ methodName: 'create',
+ args: [
+ {
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ birthDate: Object.assign({
+ value: '3000-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ email: 'test@gmail.com',
+ city: 'city',
+ status: 'salarie',
+ birthdate: '3000-11-17',
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const invocationContextCreatesuccessful = {
+ target: {},
+ methodName: 'create',
+ args: [
+ {
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ email: 'test@gmail.com',
+ city: 'city',
+ status: 'salarie',
+ birthdate: '1991-11-17',
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const invocationContextReplaceById = {
+ target: {},
+ methodName: 'replaceById',
+ args: [
+ 'id',
+ {
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ birthDate: Object.assign({
+ value: '3000-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ email: 'test@gmail.com',
+ city: 'city',
+ status: 'salarie',
+ birthdate: '3000-11-17',
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const invocationContextDisaffiliation = {
+ target: {},
+ methodName: 'disaffiliation',
+ args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff'],
+ };
+
+ const invocationContextvalidateAffiliation = {
+ target: {},
+ methodName: 'validateAffiliation',
+ args: ['', {token: ''}],
+ };
+
+ const invocationContextvalidateAffiliationWithId = {
+ target: {},
+ methodName: 'validateAffiliation',
+ args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff', {token: ''}],
+ };
+
+ const invocationContextDisaffiliat = {
+ target: {},
+ methodName: 'disaffiliation',
+ args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff'],
+ };
+
+ const invocationContextFindCitizenId = {
+ target: {},
+ methodName: 'findCitizenId',
+ args: ['c3234ee6-a932-40bf-8a46-52d694cf61ff'],
+ };
+
+ beforeEach(() => {
+ givenStubbedService();
+ givenStubbedServiceId();
+ givenStubbedServiceCitizen();
+ interceptor = new CitizenInterceptor(
+ citizenService,
+ citizenRepository,
+ userRepository,
+ currentUser,
+ );
+ secondInterceptor = new CitizenInterceptor(
+ citizenService,
+ citizenRepository,
+ userRepository,
+ otherUser,
+ );
+
+ thirdInterceptor = new CitizenInterceptor(
+ citizenService,
+ citizenRepository,
+ userRepository,
+ citizenUser,
+ );
+ });
+
+ it('CitizenInterceptor creates: error password', async () => {
+ try {
+ await interceptor.intercept(invocationContextCreates);
+ } catch (error) {
+ expect(error.message).to.equal(errorPassword.message);
+ }
+ });
+
+ it('CitizenInterceptor ReplaceById: error date', async () => {
+ try {
+ await interceptor.intercept(invocationContextReplaceById);
+ } catch (error) {
+ expect(error.message).to.equal(err.message);
+ }
+ });
+
+ it('CitizenInterceptor find: findCitizenId', async () => {
+ try {
+ await interceptor.intercept(invocationContextvalidateAffiliation, () => {});
+ } catch (err) {
+ expect(err.message).to.deepEqual(errorAffiliationNotFound.message);
+ }
+ });
+
+ it('CitizenInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ it('CitizenInterceptor validateAffiliation: ValidationError', async () => {
+ try {
+ await interceptor.intercept(invocationContextvalidateAffiliation, () => {});
+ } catch (err) {
+ expect(err.message).to.deepEqual(errorAffiliationNotFound.message);
+ }
+ });
+
+ it('CitizenInterceptor validateAffiliation: impossible affiliation ', async () => {
+ const citizen: any = {
+ id: 'randomId',
+ affiliation: {
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ entrepriseId: 'c3234ee6-a932-40bf-8a46-52d694cf61ff',
+ },
+ };
+ try {
+ userRepository.stubs.findById.resolves(mockUser);
+ citizenRepository.stubs.findOne.resolves(citizen);
+ await secondInterceptor.intercept(
+ invocationContextvalidateAffiliationWithId,
+ () => {},
+ );
+ } catch (err) {
+ expect(err.message).to.deepEqual(errorAffiliationImpossible.message);
+ }
+ });
+
+ it('CitizenInterceptor validateAffiliation: impossible affiliation if not same citizen ', async () => {
+ const citizen: any = {
+ id: 'randomId',
+ affiliation: {
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ entrepriseId: 'c3234ee6-a932-40bf-8a46-52d694cf61ll',
+ },
+ };
+ try {
+ userRepository.stubs.findById.resolves(mockUser);
+ citizenRepository.stubs.findOne.resolves(citizen);
+ await thirdInterceptor.intercept(
+ invocationContextvalidateAffiliationWithId,
+ () => {},
+ );
+ } catch (err) {
+ expect(err.message).to.deepEqual(errorAffiliationImpossible.message);
+ }
+ });
+
+ it('CitizenInterceptor disaffiliation: error disaffiliation impossible', async () => {
+ try {
+ citizenRepository.stubs.findOne.resolves(mockAffiliatedCitizen);
+ citizenService.stubs.findEmployees.resolves({
+ employees: [mockAffiliatedCitizen],
+ employeesCount: 1,
+ });
+ await interceptor.intercept(invocationContextDisaffiliation, () => {});
+ } catch (err) {
+ expect(err.message).to.deepEqual(errorAccess.message);
+ }
+ });
+
+ it('CitizenInterceptor disaffiliation: error access denied', async () => {
+ try {
+ citizenRepository.stubs.findOne.resolves(mockAffiliatedCitizen);
+ citizenService.stubs.findEmployees.resolves({
+ employees: [mockCitizenDisaffiliation],
+ employeesCount: 1,
+ });
+ await interceptor.intercept(invocationContextDisaffiliat, () => {});
+ } catch (err) {
+ expect(err.message).to.deepEqual(AccessDenied.message);
+ }
+ });
+
+ it('CitizenInterceptor disaffiliation affiliated citizen: error access denied', async () => {
+ try {
+ citizenRepository.stubs.findOne.resolves(mockAffiliatedCitizen);
+ citizenService.stubs.findEmployees.resolves({
+ employees: [mockCitizenToAffiliate],
+ employeesCount: 1,
+ });
+ await interceptor.intercept(invocationContextDisaffiliat, () => {});
+ } catch (err) {
+ expect(err.message).to.deepEqual(AccessDenied.message);
+ }
+ });
+
+ it('CitizenInterceptor disaffiliation: error citizen not found', async () => {
+ try {
+ citizenRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationContextDisaffiliation, () => {});
+ } catch (err) {
+ expect(err.message).to.deepEqual(errorNotFound.message);
+ }
+ });
+
+ it('CitizenInterceptor findCitizenById: error citizen not found', async () => {
+ try {
+ citizenRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationContextFindCitizenId, () => {});
+ } catch (err) {
+ expect(err.message).to.deepEqual(errorNotFound.message);
+ }
+ });
+
+ /**
+ * givenStubbedService without id
+ */
+ function givenStubbedService() {
+ citizenService = createStubInstance(CitizenService);
+ citizenRepository = createStubInstance(CitizenRepository);
+ userRepository = createStubInstance(UserRepository);
+ currentUser = {
+ id: '',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/entreprises/Capgemini'],
+ roles: ['gestionnaires'],
+ [securityId]: 'idEnterprise',
+ };
+ }
+
+ /**
+ * givenStubbedService with id
+ */
+ function givenStubbedServiceId() {
+ citizenService = createStubInstance(CitizenService);
+ citizenRepository = createStubInstance(CitizenRepository);
+ userRepository = createStubInstance(UserRepository);
+ otherUser = {
+ id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/entreprises/Capgemini'],
+ roles: ['gestionnaires'],
+ [securityId]: 'idEnterprise',
+ };
+ }
+
+ /**
+
+ * givenStubbedService with citizen as user
+
+ */
+ function givenStubbedServiceCitizen() {
+ citizenService = createStubInstance(CitizenService);
+ citizenRepository = createStubInstance(CitizenRepository);
+ userRepository = createStubInstance(UserRepository);
+ citizenUser = {
+ id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff',
+ emailVerified: true,
+ clientName: undefined,
+ funderType: undefined,
+ funderName: undefined,
+ incentiveType: undefined,
+ roles: ['citoyens'],
+ [securityId]: 'c3234ee6-a932-40bf-8a46-52d694cf61ff',
+ };
+ }
+});
+
+const mockAffiliatedCitizen = new Citizen({
+ id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff',
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'Xina',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Zhong',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ affiliation: Object.assign({
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ }),
+});
+
+const mockCitizenDisaffiliation = new Citizen({
+ id: '123',
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'Xina',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Zhong',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ affiliation: Object.assign({
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ }),
+});
+
+const mockCitizenToAffiliate = new Citizen({
+ id: '123',
+ identity: Object.assign({
+ firstName: Object.assign({
+ value: 'Xina',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ lastName: Object.assign({
+ value: 'Zhong',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date(),
+ }),
+ }),
+ affiliation: Object.assign({
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ }),
+});
+
+const mockUser = new User({
+ id: 'c3234ee6-a932-40bf-8a46-52d694cf61ff',
+ roles: ['gestionnaires'],
+});
diff --git a/api/src/__tests__/interceptors/enterprise.interceptor.test.ts b/api/src/__tests__/interceptors/enterprise.interceptor.test.ts
new file mode 100644
index 0000000..16ec0f6
--- /dev/null
+++ b/api/src/__tests__/interceptors/enterprise.interceptor.test.ts
@@ -0,0 +1,74 @@
+import {expect, sinon} from '@loopback/testlab';
+
+import {ValidationError} from '../../validationError';
+import {StatusCode} from '../../utils';
+import {EnterpriseInterceptor} from '../../interceptors';
+
+describe('Enterprise Interceptor', () => {
+ let interceptor: any = null;
+
+ beforeEach(() => {
+ interceptor = new EnterpriseInterceptor();
+ });
+
+ it('EnterpriseInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ it('EnterpriseInterceptor create : success', async () => {
+ const result = await interceptor.intercept(
+ invocationContextCreateEnterprise,
+ () => {},
+ );
+ expect(result).to.Null;
+ });
+
+ it('EnterpriseInterceptor create : error 422 when bad email format provided', async () => {
+ try {
+ await interceptor.intercept(invocationContextCreateEnterpriseBadFormat);
+ } catch (err) {
+ expect(err).to.deepEqual(errorBadEmailFormat);
+ }
+ });
+});
+
+const errorBadEmailFormat: any = new ValidationError(
+ 'Enterprise email formats are not valid',
+ '/enterpriseEmailBadFormat',
+ StatusCode.UnprocessableEntity,
+);
+
+const invocationContextCreateEnterprise = {
+ target: {},
+ methodName: 'create',
+ args: [
+ {
+ name: 'Capgemini',
+ siretNumber: 33070384400036,
+ emailFormat: ['@professional.com'],
+ employeesCount: 200000,
+ budgetAmount: 300000,
+ isHris: false,
+ },
+ ],
+};
+
+const invocationContextCreateEnterpriseBadFormat = {
+ target: {},
+ methodName: 'create',
+ args: [
+ {
+ name: 'Capgemini',
+ siretNumber: 33070384400036,
+ emailFormat: ['bademailformat'],
+ employeesCount: 200000,
+ budgetAmount: 300000,
+ isHris: false,
+ },
+ ],
+};
diff --git a/api/src/__tests__/interceptors/funder.interceptor.test.ts b/api/src/__tests__/interceptors/funder.interceptor.test.ts
new file mode 100644
index 0000000..2cda7f6
--- /dev/null
+++ b/api/src/__tests__/interceptors/funder.interceptor.test.ts
@@ -0,0 +1,251 @@
+import {
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {CollectivityRepository, EnterpriseRepository} from '../../repositories';
+import {ValidationError} from '../../validationError';
+import {IUser, ResourceName, StatusCode} from '../../utils';
+import {FunderInterceptor} from '../../interceptors/funder.interceptor';
+import {Collectivity, EncryptionKey, Enterprise, PrivateKeyAccess} from '../../models';
+
+describe('FunderInterceptor', () => {
+ let interceptor: any = null;
+ let collectivityRepository: StubbedInstanceWithSinonAccessor,
+ currentUser: IUser,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ givenStubbedService();
+ interceptor = new FunderInterceptor(
+ collectivityRepository,
+ enterpriseRepository,
+ currentUser,
+ );
+ });
+
+ it('FunderInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+ it('storeEncryptionKey : asserts error happens when no funder found', async () => {
+ const error = new ValidationError(
+ `Funder not found`,
+ `/Funder`,
+ StatusCode.NotFound,
+ ResourceName.Funder,
+ );
+ try {
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ await interceptor.intercept(invocationContextStoreEncryptionKey);
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('storeEncryptionKey : access denied when saving encryption_key for an other funder', async () => {
+ const error = new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ try {
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockOtherEnterpise);
+ await interceptor.intercept(invocationContextStoreEncryptionKey);
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('encryption_key : error when collectivity and no privateKeyAccess provided', async () => {
+ const privateKeyAccessmissingError = new ValidationError(
+ `encryptionKey.error.privateKeyAccess.missing`,
+ '/EncryptionKey',
+ StatusCode.UnprocessableEntity,
+ );
+ try {
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextStoreEncryptionKeyNoPrivateKeyAccess);
+ } catch (error) {
+ expect(error).to.deepEqual(privateKeyAccessmissingError);
+ }
+ });
+
+ it('encryption_key : error when enterprise not Hris and no privateKeyAccess provided', async () => {
+ const privateKeyAccessmissingError = new ValidationError(
+ `encryptionKey.error.privateKeyAccess.missing`,
+ '/EncryptionKey',
+ StatusCode.UnprocessableEntity,
+ );
+ try {
+ enterpriseRepository.stubs.findOne.resolves(mockEnterprise);
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ await interceptor.intercept(invocationContextStoreEncryptionKeyNoPrivateKeyAccess);
+ } catch (error) {
+ expect(error).to.deepEqual(privateKeyAccessmissingError);
+ }
+ });
+
+ it('encryption_key : asserts encryption key has been stored for collectivity', async () => {
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity2);
+ enterpriseRepository.stubs.create.resolves(undefined);
+ const result = await interceptor.intercept(
+ invocationContextStoreEncryptionKey,
+ () => {},
+ );
+ expect(result).to.Null;
+ });
+
+ it('encryption_key : asserts encryption key has been stored for enterprise', async () => {
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ enterpriseRepository.stubs.findOne.resolves(mockEnterprise);
+ const result = await interceptor.intercept(
+ invocationContextStoreEncryptionKey,
+ () => {},
+ );
+ expect(result).to.Null;
+ });
+
+ it('storeEncryptionKey : encryption key stored for enterprise hris without privateKeyAccess', async () => {
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ enterpriseRepository.stubs.findOne.resolves(mockEnterpriseHris);
+ const result = await interceptor.intercept(
+ invocationContextStoreEncryptionKeyNoPrivateKeyAccess,
+ () => {},
+ );
+ expect(result).to.Null;
+ });
+
+ it('encryption_key : asserts encryption key has been stored for enterprise hris', async () => {
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ enterpriseRepository.stubs.findOne.resolves(mockEnterpriseHris);
+ const result = await interceptor.intercept(
+ invocationContextStoreEncryptionKey,
+ () => {},
+ );
+ expect(result).to.Null;
+ });
+
+ function givenStubbedService() {
+ collectivityRepository = createStubInstance(CollectivityRepository);
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ currentUser = {
+ id: 'citizenId',
+ groups: ['funder'],
+ clientName: 'funder-backend',
+ emailVerified: true,
+ [securityId]: 'citizenId',
+ };
+ }
+
+ const today = new Date();
+ const expirationDate = new Date(today.setMonth(today.getMonth() + 7));
+ const publicKey = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq
+n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf
+n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq
+sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85
+OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG
+X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n
+owIDAQAB
+-----END PUBLIC KEY-----
+`;
+
+ const mockencryptionKeyValid = new EncryptionKey({
+ id: '62977dc80929474f84c403de',
+ version: 1,
+ publicKey,
+ expirationDate,
+ lastUpdateDate: new Date(),
+ privateKeyAccess: new PrivateKeyAccess({
+ loginURL: 'loginURL',
+ getKeyURL: 'getKeyURL',
+ }),
+ });
+
+ const mockCollectivity2 = new Collectivity({
+ id: '2b6ee373-4c5b-403b-afe5-3bf3cbd2473c',
+ name: 'funder',
+ citizensCount: 1,
+ mobilityBudget: 1,
+ encryptionKey: mockencryptionKeyValid,
+ });
+
+ const mockCollectivity = new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'funder',
+ citizensCount: 10,
+ mobilityBudget: 12,
+ encryptionKey: mockencryptionKeyValid,
+ });
+ const mockEnterprise = new Enterprise({
+ id: 'randomInputIdEnterprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'funder',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ });
+
+ const mockEnterpriseHris = new Enterprise({
+ id: 'randomInputIdEnterprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'funder',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ isHris: true,
+ });
+
+ const mockOtherEnterpise = new Enterprise({
+ id: 'randomInputIdEnterprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'funder-other',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ isHris: false,
+ });
+
+ const invocationContextStoreEncryptionKey = {
+ target: {},
+ methodName: 'storeEncryptionKey',
+ args: [
+ 'id',
+ {
+ id: '62977dc80929474f84c403de',
+ publicKey,
+ expirationDate,
+ lastUpdateDate: new Date(),
+ privateKeyAccess: new PrivateKeyAccess({
+ loginURL: 'loginURL',
+ getKeyURL: 'getKeyURL',
+ }),
+ },
+ ],
+ };
+
+ const invocationContextStoreEncryptionKeyNoPrivateKeyAccess = {
+ target: {},
+ methodName: 'storeEncryptionKey',
+ args: [
+ 'id',
+ {
+ id: '62977dc80929474f84c403de',
+ publicKey,
+ expirationDate,
+ lastUpdateDate: new Date(),
+ },
+ ],
+ };
+});
diff --git a/api/src/__tests__/interceptors/incentive.interceptors.test.ts b/api/src/__tests__/interceptors/incentive.interceptors.test.ts
new file mode 100644
index 0000000..76fc4c9
--- /dev/null
+++ b/api/src/__tests__/interceptors/incentive.interceptors.test.ts
@@ -0,0 +1,335 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {IncentiveInterceptor} from '../../interceptors';
+import {Incentive, Territory} from '../../models';
+import {IncentiveRepository} from '../../repositories';
+import {ResourceName, StatusCode} from '../../utils';
+import {ValidationError} from '../../validationError';
+
+describe('IncentiveInterceptor', () => {
+ let interceptor: any = null;
+
+ const errorMinDate: any = new ValidationError(
+ `incentives.error.validityDate.minDate`,
+ '/validityDate',
+ );
+
+ const errorIsMCMStaffSubscription: any = new ValidationError(
+ `incentives.error.isMCMStaff.subscriptionLink`,
+ '/isMCMStaff',
+ );
+
+ const errorIsMCMStaffSpecificFields: any = new ValidationError(
+ `incentives.error.isMCMStaff.specificFieldOrSubscriptionLink`,
+ '/isMCMStaff',
+ );
+
+ const errorTitleAlreadyUsedForFunder: any = new ValidationError(
+ `incentives.error.title.alreadyUsedForFunder`,
+ '/incentiveTitleAlreadyUsed',
+ );
+
+ const errorNotFound = new ValidationError(
+ `Incentive not found`,
+ '/incentiveNotFound',
+ StatusCode.NotFound,
+ ResourceName.Incentive,
+ );
+
+ const invocationContextCreates = {
+ target: {},
+ methodName: 'create',
+ args: [
+ {
+ title: 'incentives to test',
+ description: 'incentive to test in unit test',
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ funderName: 'idf',
+ incentiveType: 'AideNationale',
+ conditions: '',
+ paymentMethod: 'cb',
+ allocatedAmount: '1231',
+ minAmount: '15',
+ transportList: ['velo'],
+ additionalInfos: '',
+ contact: '',
+ validityDate: '2010-06-08',
+ isMCMStaff: true,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const invocationCtxDeleteById = {
+ target: {},
+ methodName: 'deleteById',
+ args: ['azezaeaz'],
+ };
+
+ const invocationContextCreatesMCMStaffCases = {
+ target: {},
+ methodName: 'create',
+ args: [
+ {
+ id: '',
+ title: 'incentives to test',
+ description: 'incentive to test in unit test',
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ funderName: 'idf',
+ incentiveType: 'AideNationale',
+ conditions: '',
+ paymentMethod: 'cb',
+ allocatedAmount: '1231',
+ minAmount: '15',
+ transportList: ['velo'],
+ additionalInfos: '',
+ contact: '',
+ validityDate: '3000-06-08',
+ isMCMStaff: true,
+ subscriptionLink: 'https://subscriptionLink.com',
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const invocationContextCreate = {
+ target: {},
+ methodName: 'create',
+ args: [
+ {
+ title: 'incentives to test',
+ description: 'incentive to test in unit test',
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ funderName: 'idf',
+ incentiveType: 'AideNationale',
+ conditions: '',
+ paymentMethod: 'cb',
+ allocatedAmount: '1231',
+ minAmount: '15',
+ transportList: ['velo'],
+ additionalInfos: '',
+ contact: '',
+ validityDate: '3000-06-08',
+ isMCMStaff: true,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const invocationContextReplaceById = {
+ target: {},
+ methodName: 'replaceById',
+ args: [
+ 'id',
+ {
+ title: 'incentives to test',
+ description: 'incentive to test in unit test',
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ funderName: 'idf',
+ incentiveType: 'AideNationale',
+ conditions: '',
+ paymentMethod: 'cb',
+ allocatedAmount: '1231',
+ minAmount: '15',
+ transportList: ['velo'],
+ additionalInfos: '',
+ contact: '',
+ validityDate: '2010-06-08',
+ isMCMStaff: true,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const invocationContextUpdateById = {
+ target: {},
+ methodName: 'updateById',
+ args: [
+ 'id',
+ {
+ title: 'Un réseau de transport en commun régional plus performant',
+ description: 'incentive to test in unit test',
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ funderName: 'Mulhouse',
+ incentiveType: 'AideTerritoire',
+ conditions: '',
+ paymentMethod: 'cb',
+ allocatedAmount: '1231',
+ minAmount: '15',
+ transportList: ['velo'],
+ additionalInfos: '',
+ contact: '',
+ validityDate: '2024-06-08',
+ isMCMStaff: true,
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ ],
+ };
+
+ const mockTerritoryIncentive = new Incentive({
+ id: '61d372bcf3a8e84cc09ace7f',
+ title: 'Un réseau de transport en commun régional plus performant',
+ description: 'Description dolor sit amet, consectetur adipiscing elit.',
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ funderName: 'Mulhouse',
+ incentiveType: 'AideTerritoire',
+ conditions:
+ "Conditions d'obtention: Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ paymentMethod:
+ 'Modalité de versement: Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ allocatedAmount: 'Montant: Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ minAmount: 'A partir de 5€ par mois sous conditions',
+ transportList: ['transportsCommun'],
+ attachments: ['justificatifDomicile'],
+ additionalInfos: 'Info complémentaire: Ut bibendum tincidunt turpis gravida iaculis.',
+ contact: 'Contactez le numéro vert au 05 603 603',
+ validityDuration: '24 mois',
+ validityDate: '2024-07-31T00:00:00.000Z',
+ isMCMStaff: true,
+ createdAt: new Date('2022-01-03T22:03:40.565Z'),
+ updatedAt: new Date('2022-01-05T09:31:10.289Z'),
+ funderId: '0d606650-4689-4438-9911-72bbd069cd2b',
+ });
+
+ let incentiveRepository: StubbedInstanceWithSinonAccessor;
+
+ function givenStubbedRepository() {
+ incentiveRepository = createStubInstance(IncentiveRepository);
+ }
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ interceptor = new IncentiveInterceptor(incentiveRepository);
+ });
+
+ it('IncentiveInterceptor creates: error date', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(null);
+ await interceptor.intercept(invocationContextCreates);
+ } catch (err) {
+ expect(err.message).to.equal(errorMinDate.message);
+ }
+ });
+
+ it('IncentiveInterceptor creates: error MCM true subscription', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(null);
+ await interceptor.intercept(invocationContextCreatesMCMStaffCases);
+ } catch (err) {
+ expect(err.message).to.equal(errorIsMCMStaffSubscription.message);
+ }
+ });
+
+ it('IncentiveInterceptor creates: error MCM false no subscription', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(null);
+ invocationContextCreatesMCMStaffCases.args[0].isMCMStaff = false;
+ delete (invocationContextCreatesMCMStaffCases?.args[0] as Partial)
+ .subscriptionLink;
+ await interceptor.intercept(invocationContextCreatesMCMStaffCases);
+ } catch (err) {
+ expect(err.message).to.equal(errorIsMCMStaffSpecificFields.message);
+ }
+ });
+
+ it('IncentiveInterceptor creates: error MCM false specific Fields', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(null);
+ (
+ invocationContextCreatesMCMStaffCases.args[0] as Partial
+ ).specificFields = [{} as any];
+ await interceptor.intercept(invocationContextCreatesMCMStaffCases);
+ } catch (err) {
+ expect(err.message).to.equal(errorIsMCMStaffSpecificFields.message);
+ }
+ });
+
+ it('IncentiveInterceptor creates: error title already used for same funder', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(mockTerritoryIncentive);
+ await interceptor.intercept(invocationContextCreate);
+ } catch (err) {
+ expect(err.message).to.equal(errorTitleAlreadyUsedForFunder.message);
+ }
+ });
+
+ it('IncentiveInterceptor creates: successful', async () => {
+ incentiveRepository.stubs.findOne.resolves(null);
+ const result = await interceptor.intercept(invocationContextCreate, () => {});
+
+ expect(result).to.Null;
+ });
+
+ it('IncentiveInterceptor ReplaceById: error date', async () => {
+ try {
+ await interceptor.intercept(invocationContextReplaceById);
+ } catch (err) {
+ expect(err.message).to.equal(errorMinDate.message);
+ }
+ });
+
+ it('IncentiveInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ it('IncentiveInterceptor updateById: error title already used for same funder', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(mockTerritoryIncentive);
+ await interceptor.intercept(invocationContextUpdateById);
+ } catch (err) {
+ expect(err.message).to.equal(errorTitleAlreadyUsedForFunder.message);
+ }
+ });
+
+ it('IncentiveInterceptor updateById: error incentive not found', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(null);
+ await interceptor.intercept(invocationContextUpdateById);
+ } catch (err) {
+ expect(err.message).to.equal(errorNotFound.message);
+ }
+ });
+
+ it('IncentiveInterceptor updateById: successful', async () => {
+ incentiveRepository.stubs.findOne.onCall(0).resolves(mockTerritoryIncentive);
+ incentiveRepository.stubs.findOne.onCall(1).resolves(null);
+ expect(
+ await interceptor.intercept(invocationContextUpdateById, () => {}),
+ ).not.to.throwError();
+ });
+
+ it('IncentiveInterceptor deleteById: incentive not found', async () => {
+ try {
+ incentiveRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxDeleteById, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+});
diff --git a/api/src/__tests__/interceptors/subscription.interceptor.test.ts b/api/src/__tests__/interceptors/subscription.interceptor.test.ts
new file mode 100644
index 0000000..92e0e05
--- /dev/null
+++ b/api/src/__tests__/interceptors/subscription.interceptor.test.ts
@@ -0,0 +1,289 @@
+import {
+ expect,
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+ sinon,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {SubscriptionInterceptor} from '../../interceptors';
+import {SubscriptionRepository, UserRepository} from '../../repositories';
+import {User, Subscription} from '../../models';
+import {ValidationError} from '../../validationError';
+import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../../utils';
+
+describe('SubscriptionInterceptor', () => {
+ let interceptor: any = null;
+
+ let subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ user: StubbedInstanceWithSinonAccessor,
+ currentUserProfile: IUser;
+
+ const error = new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+
+ const errorNotFound = new ValidationError(
+ `Subscription not found`,
+ '/subscriptionNotFound',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+ );
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ interceptor = new SubscriptionInterceptor(
+ user,
+ subscriptionRepository,
+ currentUserProfile,
+ );
+ });
+
+ it('DelandesnInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+ it('SubscriptionInterceptor user without community', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithoutCom);
+ subscriptionRepository.stubs.findById.resolves(mockSubscription);
+
+ await interceptor.intercept(invocationContextFindById, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('SubscriptionInterceptor findById : subscription not found', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithoutCom);
+ subscriptionRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationContextFindById, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ });
+ it('SubscriptionInterceptor user with all perimeter', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithAllCom);
+ subscriptionRepository.stubs.findById.resolves(mockSubscription);
+
+ await interceptor.intercept(invocationContextFindById, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+ it('SubscriptionInterceptor user with community, findById', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.findById.resolves(mockSubscription);
+
+ await interceptor.intercept(invocationContextFindById, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+ it('SubscriptionInterceptor user with community, validate', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.findById.resolves(mockSubscription);
+
+ await interceptor.intercept(invocationContextValidate, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('SubscriptionInterceptor validate : subsciption not found', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationContextValidate, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ });
+
+ it('SubscriptionInterceptor user with community, reject', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.findById.resolves(mockSubscription);
+
+ await interceptor.intercept(invocationContextReject, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('SubscriptionInterceptor reject : subscription not found', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationContextReject, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ });
+
+ it('SubscriptionInterceptor user with community, getSubscriptionFileByName', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.findById.resolves(mockSubscription);
+
+ await interceptor.intercept(invocationContextGetSubscriptionFileByName, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('SubscriptionInterceptor getSubscriptionFileByName : subscription not found', async () => {
+ try {
+ user.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.findById.resolves(undefined);
+
+ await interceptor.intercept(invocationContextGetSubscriptionFileByName, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ });
+
+ it('SubscriptionInterceptor subscription with wrong status, getSubscriptionFileByName', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(mockSubscription2);
+
+ await interceptor.intercept(invocationContextSubscriptionStatus, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ it('SubscriptionInterceptor user with wrong clientName, getSubscriptionFileByName', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(mockSubscription);
+
+ await interceptor.intercept(invocationContextUserClientName, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(error);
+ }
+ });
+
+ function givenStubbedRepository() {
+ user = createStubInstance(UserRepository);
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ currentUserProfile = {
+ id: 'testId',
+ clientName: 'testName-client',
+ emailVerified: true,
+ [securityId]: 'testId',
+ roles: ['maas'],
+ };
+ }
+});
+
+const invocationContextFindById = {
+ target: {},
+ methodName: 'findById',
+ args: ['randomInputId'],
+};
+
+const invocationContextValidate = {
+ target: {},
+ methodName: 'findById',
+ args: ['validate'],
+};
+
+const invocationContextReject = {
+ target: {},
+ methodName: 'reject',
+ args: ['validate'],
+};
+
+const invocationContextGetSubscriptionFileByName = {
+ target: {},
+ methodName: 'getSubscriptionFileByName',
+ args: ['validate'],
+};
+
+const invocationContextSubscriptionStatus = {
+ target: {},
+ methodName: 'getSubscriptionFileByName',
+ args: ['randomInputId2'],
+};
+
+const invocationContextUserClientName = {
+ target: {},
+ methodName: 'getSubscriptionFileByName',
+ args: ['randomInputId'],
+};
+
+const mockUserWithoutCom = new User({
+ id: 'testId',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['gestionnaires'],
+ communityIds: [],
+});
+const mockUserWithAllCom = new User({
+ id: 'testId',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['gestionnaires'],
+});
+const mockUserWithCom = new User({
+ id: 'testId',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'random',
+ roles: ['gestionnaires'],
+ communityIds: ['id1'],
+});
+
+const mockSubscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ communityId: 'id1',
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const mockSubscription2 = new Subscription({
+ id: 'randomInputId2',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ communityId: 'id1',
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
diff --git a/api/src/__tests__/interceptors/subscription.metadata.interceptors.test.ts b/api/src/__tests__/interceptors/subscription.metadata.interceptors.test.ts
new file mode 100644
index 0000000..06db29c
--- /dev/null
+++ b/api/src/__tests__/interceptors/subscription.metadata.interceptors.test.ts
@@ -0,0 +1,194 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {ValidationError} from '../../validationError';
+import {IncentiveRepository, MetadataRepository} from '../../repositories';
+import {IUser, ResourceName, StatusCode} from '../../utils';
+import {SubscriptionMetadataInterceptor} from '../../interceptors';
+import {Incentive, Metadata} from '../../models';
+
+describe('SubscriptionV1 metadata Interceptor', () => {
+ let interceptor: any = null;
+ let metadataRepository: StubbedInstanceWithSinonAccessor,
+ incentiveRepository: StubbedInstanceWithSinonAccessor,
+ currentUserProfile: IUser;
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ interceptor = new SubscriptionMetadataInterceptor(
+ incentiveRepository,
+ metadataRepository,
+ currentUserProfile,
+ );
+ });
+
+ it('SubscriptionMetadataInterceptor args: error incentive isMCMStaff false', async () => {
+ try {
+ incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: false}));
+ await interceptor.intercept(invocationContextArgsOK);
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotMCMStaff);
+ }
+ });
+
+ it('SubscriptionMetadataInterceptor args: error products length === 0', async () => {
+ try {
+ incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: true}));
+ await interceptor.intercept(invocationContextArgsKOProducts);
+ } catch (err) {
+ expect(err).to.deepEqual(errorMetadataInvoicesOrProductsLength);
+ }
+ });
+
+ it('SubscriptionMetadataInterceptor args: error invoices length !== totalElements', async () => {
+ try {
+ incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: true}));
+ await interceptor.intercept(invocationContextArgsKOInvoicesTotalElements);
+ } catch (err) {
+ expect(err).to.deepEqual(errorMetadataInvoicesTotalElements);
+ }
+ });
+
+ it('SubscriptionMetadataInterceptor args: getMetadata user id not matches error', async () => {
+ try {
+ metadataRepository.stubs.findOne.resolves(
+ new Metadata({citizenId: 'citizenIdError'}),
+ );
+ await interceptor.intercept(invocationContextArgsGetOK);
+ } catch (err) {
+ expect(err.message).to.equal(errorMetadataNotHis.message);
+ }
+ });
+
+ it('SubscriptionMetadataInterceptor args: success', async () => {
+ incentiveRepository.stubs.findById.resolves(new Incentive({isMCMStaff: true}));
+ metadataRepository.stubs.findOne.resolves(null);
+ await interceptor.intercept(invocationContextArgsOK, () => {});
+ });
+
+ it('SubscriptionMetadataInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ it('SubscriptionMetadataInterceptor getMetadata: metadata not found', async () => {
+ try {
+ metadataRepository.stubs.findOne.resolves(null);
+
+ await interceptor.intercept(invocationCtxGetMetadata, () => {});
+ } catch (err) {
+ expect(err).to.deepEqual(errorNotFound);
+ }
+ incentiveRepository.stubs.findOne.restore();
+ });
+
+ function givenStubbedRepository() {
+ incentiveRepository = createStubInstance(IncentiveRepository);
+ metadataRepository = createStubInstance(MetadataRepository);
+ currentUserProfile = {
+ id: 'citizenId',
+ clientName: 'testName-client',
+ emailVerified: true,
+ [securityId]: 'citizenId',
+ };
+ }
+});
+
+const errorNotMCMStaff: any = new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+);
+
+const errorMetadataInvoicesOrProductsLength: any = new ValidationError(
+ `Metadata invoices or products length invalid`,
+ '/metadata',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Metadata,
+);
+
+const errorMetadataInvoicesTotalElements: any = new ValidationError(
+ `Metadata invoices length must be equal to totalElements`,
+ '/metadata',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Metadata,
+);
+
+const errorMetadataNotHis: any = new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+);
+
+const errorNotFound = new ValidationError(
+ `Metadata not found`,
+ '/metadataNotFound',
+ StatusCode.NotFound,
+ ResourceName.Metadata,
+);
+
+const invocationCtxGetMetadata = {
+ methodName: 'getMetadata',
+ args: ['606c236a624cec2becdef277'],
+};
+
+const invocationContextArgsOK = {
+ target: {},
+ methodName: 'createMetadata',
+ args: [
+ {
+ incentiveId: 'incentiveId',
+ attachmentMetadata: {
+ invoices: [{products: [{}]}],
+ totalElements: 1,
+ },
+ },
+ ],
+};
+
+const invocationContextArgsGetOK = {
+ target: {},
+ methodName: 'getMetadata',
+ args: [
+ {
+ metadataId: 'idMetadata',
+ },
+ ],
+};
+
+const invocationContextArgsKOProducts = {
+ target: {},
+ methodName: 'createMetadata',
+ args: [
+ {
+ incentiveId: 'incentiveId',
+ attachmentMetadata: {
+ invoices: [{products: []}],
+ totalElements: 3,
+ },
+ },
+ ],
+};
+
+const invocationContextArgsKOInvoicesTotalElements = {
+ target: {},
+ methodName: 'createMetadata',
+ args: [
+ {
+ incentiveId: 'incentiveId',
+ attachmentMetadata: {
+ invoices: [{products: [{}]}],
+ totalElements: 3,
+ },
+ },
+ ],
+};
diff --git a/api/src/__tests__/interceptors/subscriptionV1.attachments.interceptors.test.ts b/api/src/__tests__/interceptors/subscriptionV1.attachments.interceptors.test.ts
new file mode 100644
index 0000000..7443d08
--- /dev/null
+++ b/api/src/__tests__/interceptors/subscriptionV1.attachments.interceptors.test.ts
@@ -0,0 +1,531 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {Readable} from 'stream';
+
+import {
+ Subscription,
+ AttachmentType,
+ Metadata,
+ EncryptionKey,
+ Collectivity,
+ PrivateKeyAccess,
+} from '../../models';
+import {ValidationError} from '../../validationError';
+import {
+ SubscriptionRepository,
+ MetadataRepository,
+ CollectivityRepository,
+ EnterpriseRepository,
+} from '../../repositories';
+import {ClamavService, S3Service} from '../../services';
+import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../../utils';
+import {SubscriptionV1AttachmentsInterceptor} from '../../interceptors';
+
+describe('SubscriptionV1 attachments Interceptor', () => {
+ let interceptor: any = null;
+ let subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ currentUserProfile: IUser,
+ clamavService: StubbedInstanceWithSinonAccessor,
+ metadataRepository: StubbedInstanceWithSinonAccessor,
+ collectivityRepository: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor;
+ const s3 = new S3Service();
+
+ const inputSubscription = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ });
+
+ const inputSubscriptionWithFiles = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ attachments: [{} as AttachmentType],
+ });
+
+ const inputSubscriptionWrongIncentiveId = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId',
+ incentiveId: 'incentiveId',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ });
+
+ const inputSubscriptionNotDraft = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId',
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ });
+
+ const inputSubscriptionFail = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId2',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ });
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ interceptor = new SubscriptionV1AttachmentsInterceptor(
+ s3,
+ subscriptionRepository,
+ metadataRepository,
+ enterpriseRepository,
+ collectivityRepository,
+ currentUserProfile,
+ clamavService,
+ );
+ });
+
+ it('SubscriptionV1Interceptor args: error Subscription does not exists', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(undefined);
+ await interceptor.intercept(invocationContextArgsOK);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err).to.deepEqual(errorSubscriptionDoesnotExist);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error User id fail : error', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscriptionFail);
+ await interceptor.intercept(invocationContextUserIdError);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err.message).to.equal(errorStatusUser.message);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error subscription not DRAFT', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscriptionNotDraft);
+ await interceptor.intercept(invocationContextArgsOK);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err).to.deepEqual(errorStatus);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error one file to upload', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsNoFileError);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err.message).to.equal(errorNoFile.message);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error already files in db', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscriptionWithFiles);
+ metadataRepository.stubs.findById.resolves();
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsOK);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err.message).to.equal(errorAlreadyFiles.message);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error metadata does not match', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscriptionWrongIncentiveId);
+ metadataRepository.stubs.findById.resolves(
+ new Metadata({incentiveId: 'errorincentiveId'}),
+ );
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsOK);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err.message).to.equal(errorMismatchincentiveId.message);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error nb of file', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsNbFileError);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err).to.deepEqual(errorNbFile);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error file mime type', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsMimeTypeError);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err).to.deepEqual(errorMimeType);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error file size', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsFileSizeError);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err.message).to.equal(errorFileSize.message);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: error corrupted file', async () => {
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ metadataRepository.stubs.findById.resolves();
+ clamavService.stubs.checkCorruptedFiles.resolves(false);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsOK);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(err.message).to.equal(errorCorrepted.message);
+ }
+ });
+
+ it('SubscriptionV1Interceptor : error when funder not found', async () => {
+ const encryptionKeyNotFoundError = new ValidationError(
+ `Funder not found`,
+ '/Funder',
+ StatusCode.NotFound,
+ ResourceName.EncryptionKey,
+ );
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(undefined);
+ await interceptor.intercept(invocationContextArgsOK);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(encryptionKeyNotFoundError).to.deepEqual(encryptionKeyNotFoundError);
+ }
+ });
+
+ it('SubscriptionV1Interceptor : error when Encryption Key not found', async () => {
+ const encryptionKeyNotFoundError = new ValidationError(
+ `Encryption Key not found`,
+ '/EncryptionKey',
+ StatusCode.NotFound,
+ ResourceName.EncryptionKey,
+ );
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivityWithoutEncryptionKey);
+ await interceptor.intercept(invocationContextArgsOK);
+ sinon.assert.fail();
+ } catch (err) {
+ expect(encryptionKeyNotFoundError).to.deepEqual(encryptionKeyNotFoundError);
+ }
+ });
+
+ it('SubscriptionV1Interceptor : error when Encryption Key expired', async () => {
+ const encryptionKeyExpiredError = new ValidationError(
+ `Encryption Key Expired`,
+ '/EncryptionKey',
+ StatusCode.UnprocessableEntity,
+ ResourceName.EncryptionKey,
+ );
+ try {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivityEncryptionKeyExpired);
+ await interceptor.intercept(invocationContextArgsOK);
+ } catch (err) {
+ expect(err).to.deepEqual(encryptionKeyExpiredError);
+ }
+ });
+
+ it('SubscriptionV1Interceptor args: success', async () => {
+ subscriptionRepository.stubs.findOne.resolves(inputSubscription);
+ metadataRepository.stubs.findById.resolves();
+ clamavService.stubs.checkCorruptedFiles.resolves(true);
+ enterpriseRepository.stubs.findOne.resolves(undefined);
+ collectivityRepository.stubs.findOne.resolves(mockCollectivity);
+ await interceptor.intercept(invocationContextArgsOK, () => {});
+ });
+
+ it('SubscriptionV1Interceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ function givenStubbedRepository() {
+ metadataRepository = createStubInstance(MetadataRepository);
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ collectivityRepository = createStubInstance(CollectivityRepository);
+ clamavService = createStubInstance(ClamavService);
+ currentUserProfile = {
+ id: 'citizenId',
+ clientName: 'testName-client',
+ emailVerified: true,
+ [securityId]: 'citizenId',
+ };
+ }
+});
+
+const errorSubscriptionDoesnotExist: any = new ValidationError(
+ 'Subscription does not exist',
+ '/subscription',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+);
+
+const errorStatus: any = new ValidationError(
+ `Only subscriptions with Draft status are allowed`,
+ '/status',
+ StatusCode.PreconditionFailed,
+ ResourceName.Subscription,
+);
+
+const errorMimeType: any = new ValidationError(
+ `Uploaded files do not have valid content type`,
+ '/attachments',
+ StatusCode.PreconditionFailed,
+ ResourceName.AttachmentsType,
+);
+
+const errorFileSize: any = new ValidationError(
+ `Uploaded files do not have a valid file size`,
+ '/attachments',
+);
+
+const errorNoFile: any = new ValidationError(
+ `You need the provide at least one file or valid metadata`,
+ '/attachments',
+);
+
+const errorAlreadyFiles: any = new ValidationError(
+ `You already provided files to this subscription`,
+ '/attachments',
+);
+
+const errorCorrepted: any = new ValidationError(
+ 'A corrupted file has been found',
+ '/antivirus',
+);
+const errorStatusUser: any = new ValidationError('Access denied', '/authorization');
+
+const errorNbFile: any = new ValidationError(
+ `Too many files to upload`,
+ '/attachments',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Attachments,
+);
+
+const errorMismatchincentiveId: any = new ValidationError(
+ `Metadata does not match this subscription`,
+ '/attachments',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Attachments,
+);
+
+const file: any = {
+ originalname: 'test1.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+};
+
+const fileListNbFileError: any[] = [
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+];
+
+const fileListMimeTypeError: any[] = [
+ {
+ originalname: 'test1.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/exe',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+];
+
+const fileListFileSizeError: any[] = [
+ {
+ originalname: 'test1.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 12000000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+];
+
+const invocationContextArgsMimeTypeError = {
+ target: {},
+ methodName: 'addFiles',
+ args: [
+ 'idSubscription1',
+ {
+ files: fileListMimeTypeError,
+ body: {
+ data: '',
+ },
+ },
+ ],
+};
+
+const invocationContextUserIdError = {
+ target: {},
+ methodName: 'addFiles',
+ args: [
+ 'idSubscription1',
+ {
+ files: [file],
+ },
+ ],
+};
+const invocationContextArgsNoFileError = {
+ target: {},
+ methodName: 'addFiles',
+ args: [
+ 'idSubscription',
+ {
+ body: {
+ data: '',
+ },
+ },
+ ],
+};
+
+const invocationContextArgsFileSizeError = {
+ target: {},
+ methodName: 'addFiles',
+ args: [
+ 'idSubscription',
+ {
+ files: fileListFileSizeError,
+ body: {
+ data: '',
+ },
+ },
+ ],
+};
+
+const invocationContextArgsNbFileError = {
+ target: {},
+ methodName: 'addFiles',
+ args: [
+ 'idSubscription',
+ {
+ files: fileListNbFileError,
+ body: {
+ data: '',
+ },
+ },
+ ],
+};
+
+const invocationContextArgsOK = {
+ target: {},
+ methodName: 'addFiles',
+ args: [
+ 'idSubscription',
+ {
+ files: [file],
+ body: {
+ data: '{\r\n "metadataId": "metadataId"\r\n}',
+ },
+ },
+ ],
+};
+
+const today = new Date();
+const expirationDate = new Date(today.setMonth(today.getMonth() + 3));
+
+const mockencryptionKeyValid = new EncryptionKey({
+ id: '62977dc80929474f84c403de',
+ version: 1,
+ publicKey: 'publicKey',
+ expirationDate,
+ lastUpdateDate: new Date(),
+ privateKeyAccess: new PrivateKeyAccess({
+ loginURL: 'loginURL',
+ getKeyURL: 'getKeyURL',
+ }),
+});
+
+const mockencryptionKeyExpired = new EncryptionKey({
+ id: '62977dc80929474f84c403de',
+ version: 1,
+ publicKey: 'publicKey',
+ expirationDate: new Date(new Date().setMonth(new Date().getMonth() - 1)),
+ lastUpdateDate: new Date(),
+ privateKeyAccess: new PrivateKeyAccess({
+ loginURL: 'loginURL',
+ getKeyURL: 'getKeyURL',
+ }),
+});
+
+const mockCollectivity = new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ citizensCount: 10,
+ mobilityBudget: 12,
+ encryptionKey: mockencryptionKeyValid,
+});
+
+const mockCollectivityEncryptionKeyExpired = new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ citizensCount: 10,
+ mobilityBudget: 12,
+ encryptionKey: mockencryptionKeyExpired,
+});
+
+const mockCollectivityWithoutEncryptionKey = new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ citizensCount: 10,
+ mobilityBudget: 12,
+});
diff --git a/api/src/__tests__/interceptors/subscriptionV1.finalize.interceptors.test.ts b/api/src/__tests__/interceptors/subscriptionV1.finalize.interceptors.test.ts
new file mode 100644
index 0000000..8d14cff
--- /dev/null
+++ b/api/src/__tests__/interceptors/subscriptionV1.finalize.interceptors.test.ts
@@ -0,0 +1,118 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+
+import {Subscription} from '../../models';
+import {ValidationError} from '../../validationError';
+import {SubscriptionRepository} from '../../repositories';
+import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../../utils';
+import {SubscriptionV1FinalizeInterceptor} from '../../interceptors/external';
+
+describe('SubscriptionV1 finalize Interceptor', () => {
+ let interceptor: any = null;
+ let subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ currentUserProfile: IUser;
+
+ const inputSubscription = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ });
+
+ const inputSubscriptionNotBrouillon = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId',
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ });
+
+ const inputSubscriptionFail = new Subscription({
+ id: 'idSubscription',
+ citizenId: 'citizenId2',
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ });
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ interceptor = new SubscriptionV1FinalizeInterceptor(
+ subscriptionRepository,
+ currentUserProfile,
+ );
+ });
+
+ it('SubscriptionV1FinalizeInterceptor args: error Subscription does not exists', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(undefined);
+ await interceptor.intercept(invocationContextArgsOK);
+ } catch (err) {
+ expect(err).to.deepEqual(errorSubscriptionDoesnotExist);
+ }
+ });
+
+ it('SubscriptionV1FinalizeInterceptor args: error User id fail : error', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(inputSubscriptionFail);
+ await interceptor.intercept(invocationContextArgsOK);
+ } catch (err) {
+ expect(err.message).to.equal(errorStatusUser.message);
+ }
+ });
+
+ it('SubscriptionV1FinalizeInterceptor args: error Subscription not BROUILLON', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(inputSubscriptionNotBrouillon);
+ await interceptor.intercept(invocationContextArgsOK);
+ } catch (err) {
+ expect(err).to.deepEqual(errorStatus);
+ }
+ });
+
+ it('SubscriptionV1FinalizeInterceptor args: success', async () => {
+ subscriptionRepository.stubs.findById.resolves(inputSubscription);
+ await interceptor.intercept(invocationContextArgsOK, () => {});
+ });
+
+ it('SubscriptionV1FinalizeInterceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ function givenStubbedRepository() {
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ currentUserProfile = {
+ id: 'citizenId',
+ clientName: 'testName-client',
+ emailVerified: true,
+ [securityId]: 'citizenId',
+ };
+ }
+});
+
+const errorSubscriptionDoesnotExist: any = new ValidationError(
+ 'Subscription does not exist',
+ '/subscription',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+);
+
+const errorStatus: any = new ValidationError(
+ `Only subscriptions with Draft status are allowed`,
+ '/status',
+ StatusCode.PreconditionFailed,
+ ResourceName.Subscription,
+);
+
+const errorStatusUser: any = new ValidationError('Access denied', '/authorization');
+
+const invocationContextArgsOK = {
+ target: {},
+ methodName: 'finalizeSubscription',
+ args: ['idSubscription'],
+};
diff --git a/api/src/__tests__/interceptors/subscriptionV1.interceptors.test.ts b/api/src/__tests__/interceptors/subscriptionV1.interceptors.test.ts
new file mode 100644
index 0000000..f0be6fe
--- /dev/null
+++ b/api/src/__tests__/interceptors/subscriptionV1.interceptors.test.ts
@@ -0,0 +1,311 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {SubscriptionV1Interceptor} from '../../interceptors';
+import {Incentive, Community, Territory} from '../../models';
+import {ValidationError} from '../../validationError';
+import {IncentiveRepository, CommunityRepository} from '../../repositories';
+
+describe('SubscriptionV1 Interceptor', () => {
+ let interceptor: any = null;
+ let repository: StubbedInstanceWithSinonAccessor,
+ communityRepository: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ givenStubbedRepository();
+ interceptor = new SubscriptionV1Interceptor(repository, communityRepository);
+ });
+
+ it('SubscriptionV1Interceptor creates: error format "newField1"', async () => {
+ try {
+ repository.stubs.findById.resolves(mockIncentive);
+ await interceptor.intercept(invocationContextCreates, () => {});
+ } catch (err) {
+ expect(err.message).to.equal(errorFormat.message);
+ }
+ repository.stubs.findById.restore();
+ });
+
+ it('SubscriptionV1Interceptor creates: error with funderId without communities', async () => {
+ try {
+ repository.stubs.findById.resolves(mockIncentiveFunderId);
+ communityRepository.stubs.findByFunderId.resolves([
+ new Community({id: 'community1'}),
+ ]);
+
+ await interceptor.intercept(invocationContextCreatesuccessful, () => {});
+ } catch (err) {
+ expect(err.message).to.equal('subscriptions.error.communities.mismatch');
+ }
+ repository.stubs.findById.restore();
+ communityRepository.stubs.findByFunderId.restore();
+ });
+
+ // eslint-disable-next-line
+ it('SubscriptionV1Interceptor creates: error with funderId without communities and not mcmstaff', async () => {
+ try {
+ repository.stubs.findById.resolves(new Incentive({isMCMStaff: false}));
+
+ await interceptor.intercept(invocationContextCreatesuccessful, () => {});
+ } catch (err) {
+ expect(err.message).to.equal('Access denied');
+ }
+ repository.stubs.findById.restore();
+ });
+
+ it('SubscriptionV1Interceptor creates: successful with specificFields', async () => {
+ repository.stubs.findById.resolves(mockIncentive);
+ const result = await interceptor.intercept(
+ invocationContextCreatesuccessful,
+ () => {},
+ );
+ expect(result).to.Null;
+ repository.stubs.findById.restore();
+ });
+
+ it('SubscriptionV1Interceptor creates: successful without specificFields', async () => {
+ repository.stubs.findById.resolves(mockIncentiveWithoutSpecificFields);
+ const result = await interceptor.intercept(
+ invocationContextCreatesuccessfulwithoutSpecFields,
+ () => {},
+ );
+ expect(result).to.Null;
+ repository.stubs.findById.restore();
+ });
+
+ it('SubscriptionV1Interceptor args', async () => {
+ try {
+ repository.stubs.findById.resolves(mockIncentive);
+ await interceptor.intercept(invocationContextArgsMimeTypeError);
+ } catch (err) {
+ expect(err).to.Null;
+ }
+ repository.stubs.findById.restore();
+ });
+
+ it('SubscriptionV1Interceptor value', async () => {
+ const res = 'successful binding';
+ sinon.stub(interceptor.intercept, 'bind').resolves(res);
+ const result = await interceptor.value();
+
+ expect(result).to.equal(res);
+ interceptor.intercept.bind.restore();
+ });
+
+ function givenStubbedRepository() {
+ repository = createStubInstance(IncentiveRepository);
+ communityRepository = createStubInstance(CommunityRepository);
+ }
+});
+
+const errorFormat: any = new ValidationError(
+ `is not of a type(s) array`,
+ '/subscription',
+);
+
+const invocationContextCreates = {
+ target: {},
+ methodName: 'createSubscription',
+ args: [
+ {
+ incentiveId: 'incentiveId',
+ newField1: 'field1',
+ newField2: 'field2',
+ consent: true,
+ },
+ ],
+};
+
+const invocationContextCreatesuccessfulwithoutSpecFields = {
+ target: {},
+ methodName: 'createSubscription',
+ args: [
+ {
+ incentiveId: 'incentiveId',
+ consent: true,
+ },
+ ],
+};
+
+const mockIncentive = new Incentive({
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'incentiveTitle',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: 'incentiveId',
+ conditions: 'Vivre à Toulouse',
+ specificFields: [
+ {
+ title: 'newField1',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'newField1',
+ },
+ {
+ inputChoice: 'newField11',
+ },
+ ],
+ },
+ },
+ {
+ title: 'newField2',
+ inputFormat: 'Texte',
+ },
+ ],
+ jsonSchema: {
+ properties: {
+ newField1: {
+ type: 'array',
+ maxItems: 2,
+ items: [
+ {
+ enum: ['newField1', 'newField11'],
+ },
+ ],
+ },
+ newField2: {
+ type: 'string',
+ minLength: 1,
+ },
+ },
+ title: 'test',
+ type: 'object',
+ required: ['newField1', 'newField2'],
+ additionalProperties: false,
+ },
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const mockIncentiveFunderId = new Incentive({
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'incentiveTitle',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: 'incentiveId',
+ conditions: 'Vivre à Toulouse',
+ funderId: 'testFunderId',
+ specificFields: [
+ {
+ title: 'newField1',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'newField1',
+ },
+ {
+ inputChoice: 'newField11',
+ },
+ ],
+ },
+ },
+ {
+ title: 'newField2',
+ inputFormat: 'Texte',
+ },
+ ],
+ jsonSchema: {
+ properties: {
+ newField1: {
+ type: 'array',
+ maxItems: 2,
+ items: [
+ {
+ enum: ['newField1', 'newField11'],
+ },
+ ],
+ },
+ newField2: {
+ type: 'string',
+ minLength: 1,
+ },
+ },
+ title: 'test',
+ type: 'object',
+ required: ['newField1', 'newField2'],
+ additionalProperties: false,
+ },
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const mockIncentiveWithoutSpecificFields = new Incentive({
+ territory: {name: 'IDF', id: 'randomTerritoryId'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'incentiveTitle',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: 'incentiveId',
+ conditions: 'Vivre à Toulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const invocationContextArgsMimeTypeError = {
+ target: {},
+ methodName: 'createSubscription',
+ args: [
+ {
+ incentiveId: 'incentiveId',
+ newField1: ['newField1'],
+ newField2: 'field2',
+ consent: true,
+ },
+ {
+ files: [],
+ },
+ ],
+};
+
+const invocationContextCreatesuccessful = {
+ target: {},
+ methodName: 'createSubscription',
+ args: [
+ {
+ incentiveId: 'incentiveId',
+ newField1: ['newField1'],
+ newField2: 'field2',
+ },
+ ],
+};
diff --git a/api/src/__tests__/providers/authorization.provider.test.ts b/api/src/__tests__/providers/authorization.provider.test.ts
new file mode 100644
index 0000000..1c30632
--- /dev/null
+++ b/api/src/__tests__/providers/authorization.provider.test.ts
@@ -0,0 +1,92 @@
+import {expect} from '@loopback/testlab';
+import {securityId} from '@loopback/security';
+import {
+ AuthorizationContext,
+ AuthorizationDecision,
+ AuthorizationMetadata,
+} from '@loopback/authorization';
+import {AuthorizationProvider} from '../../providers';
+
+describe('Authorization provider', () => {
+ let authorizationProvider: AuthorizationProvider;
+
+ beforeEach(() => {
+ authorizationProvider = new AuthorizationProvider();
+ });
+
+ it('should value: OK', async () => {
+ const result = authorizationProvider.value();
+ expect(result).to.deepEqual(
+ authorizationProvider.authorize.bind(authorizationProvider),
+ );
+ });
+
+ it('should authorize: OK ALLOW', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ maas: 'maas',
+ membership: ['membership'],
+ roles: ['financeurs'],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: null as any,
+ };
+ const metadata: AuthorizationMetadata = {
+ allowedRoles: ['financeurs'],
+ };
+ const result = await authorizationProvider.authorize(authContext, metadata);
+ expect(result).to.deepEqual(AuthorizationDecision.ALLOW);
+ });
+
+ it('should authorize: OK ALLOW no allowed Roles', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ maas: 'maas',
+ membership: ['membership'],
+ roles: ['financeurs'],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: null as any,
+ };
+ const metadata: AuthorizationMetadata = {};
+ const result = await authorizationProvider.authorize(authContext, metadata);
+ expect(result).to.deepEqual(AuthorizationDecision.ALLOW);
+ });
+
+ it('should authorize: OK DENY allowed Roles && no user roles', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ maas: 'maas',
+ membership: ['membership'],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: null as any,
+ };
+ const metadata: AuthorizationMetadata = {
+ allowedRoles: ['financeurs'],
+ };
+ const result = await authorizationProvider.authorize(authContext, metadata);
+ expect(result).to.deepEqual(AuthorizationDecision.DENY);
+ });
+});
diff --git a/api/src/__tests__/repositories/communautes.repository.test.ts b/api/src/__tests__/repositories/communautes.repository.test.ts
new file mode 100644
index 0000000..c999c3a
--- /dev/null
+++ b/api/src/__tests__/repositories/communautes.repository.test.ts
@@ -0,0 +1,25 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {CommunityRepository} from '../../repositories';
+import {testdbMongo} from './testdb.datasource';
+
+describe('Community repository (unit)', () => {
+ let repository: CommunityRepository;
+
+ beforeEach(() => {
+ repository = new CommunityRepository(testdbMongo);
+ });
+
+ describe('Community', () => {
+ it('Community findByFunderId : successful', async () => {
+ const result = await repository.findByFunderId(
+ '757fa925-bcd9-4d88-a071-b6b189377029',
+ );
+ expect(result).to.deepEqual([]);
+ });
+ });
+});
diff --git a/api/src/__tests__/repositories/keycloak-group.repository.test.ts b/api/src/__tests__/repositories/keycloak-group.repository.test.ts
new file mode 100644
index 0000000..d310b8c
--- /dev/null
+++ b/api/src/__tests__/repositories/keycloak-group.repository.test.ts
@@ -0,0 +1,116 @@
+import {expect} from '@loopback/testlab';
+
+import {realmName} from '../../constants';
+import {GroupRoleMapping, KeycloakGroup, KeycloakRole} from '../../models';
+
+import {
+ GroupRoleMappingRepository,
+ KeycloakGroupRepository,
+ KeycloakRoleRepository,
+} from '../../repositories';
+import {GROUPS} from '../../utils';
+
+import {testdbPostgres} from './testdb.datasource';
+
+describe('keycloakGroup (unit)', () => {
+ let repository: KeycloakGroupRepository,
+ groupRoleMappingRepository: GroupRoleMappingRepository,
+ keycloakRoleRepository: KeycloakRoleRepository;
+
+ beforeEach(async () => {
+ repository = new KeycloakGroupRepository(
+ testdbPostgres,
+ async () => groupRoleMappingRepository,
+ async () => keycloakRoleRepository,
+ );
+
+ groupRoleMappingRepository = new GroupRoleMappingRepository(testdbPostgres);
+
+ keycloakRoleRepository = new KeycloakRoleRepository(testdbPostgres);
+
+ await repository.deleteAll();
+ await groupRoleMappingRepository.deleteAll();
+ await keycloakRoleRepository.deleteAll();
+ });
+
+ describe('keycloakGroup', () => {
+ it('keycloakGroup get roles : successful', async () => {
+ await repository.create(
+ new KeycloakGroup({
+ id: 'parent',
+ name: GROUPS.funders,
+ parentGroup: '',
+ realmId: realmName,
+ }),
+ );
+ await repository.create(
+ new KeycloakGroup({
+ id: 'grp1',
+ name: 'grp1',
+ parentGroup: 'parent',
+ realmId: realmName,
+ }),
+ );
+ await repository.create(
+ new KeycloakGroup({
+ id: 'grp2',
+ name: 'grp2',
+ parentGroup: 'parent',
+ realmId: realmName,
+ }),
+ );
+ await repository.create(
+ new KeycloakGroup({
+ id: 'grp3',
+ name: 'grp3',
+ parentGroup: 'parent2',
+ realmId: realmName,
+ }),
+ );
+
+ await keycloakRoleRepository.create(new KeycloakRole({id: 'role1', name: 'role1'}));
+ await keycloakRoleRepository.create(new KeycloakRole({id: 'role2', name: 'role2'}));
+ await keycloakRoleRepository.create(new KeycloakRole({id: 'role3', name: 'role3'}));
+
+ await groupRoleMappingRepository.create(
+ new GroupRoleMapping({roleId: 'role1', groupId: 'grp1'}),
+ );
+ await groupRoleMappingRepository.create(
+ new GroupRoleMapping({roleId: 'role2', groupId: 'grp1'}),
+ );
+ await groupRoleMappingRepository.create(
+ new GroupRoleMapping({roleId: 'role5', groupId: 'grp3'}),
+ );
+ await groupRoleMappingRepository.create(
+ new GroupRoleMapping({roleId: 'role6', groupId: 'grp2'}),
+ );
+ await groupRoleMappingRepository.create(
+ new GroupRoleMapping({roleId: 'role3', groupId: 'grp1'}),
+ );
+
+ const result = await repository.getSubGroupFunderRoles();
+ expect(result).to.deepEqual(['role1', 'role2', 'role3']);
+ });
+
+ it('keycloakGroup get roles : fails', async () => {
+ await repository.create(
+ new KeycloakGroup({
+ id: 'parent',
+ name: 'grp1',
+ parentGroup: '',
+ realmId: realmName,
+ }),
+ );
+ await repository.create(
+ new KeycloakGroup({
+ id: 'grp2',
+ name: 'grp2',
+ parentGroup: 'grp1',
+ realmId: realmName,
+ }),
+ );
+ const result = await repository.getSubGroupFunderRoles();
+ expect(result).to.deepEqual([]);
+ });
+ });
+});
diff --git a/api/src/__tests__/repositories/testdb.datasource.ts b/api/src/__tests__/repositories/testdb.datasource.ts
new file mode 100644
index 0000000..894ad86
--- /dev/null
+++ b/api/src/__tests__/repositories/testdb.datasource.ts
@@ -0,0 +1,11 @@
+import {juggler} from '@loopback/repository';
+
+export const testdbMongo: juggler.DataSource = new juggler.DataSource({
+ name: 'mongoDS',
+ connector: 'memory',
+});
+
+export const testdbPostgres: juggler.DataSource = new juggler.DataSource({
+ name: 'idpdbDS',
+ connector: 'memory',
+});
diff --git a/api/src/__tests__/services/authentication.service.test.ts b/api/src/__tests__/services/authentication.service.test.ts
new file mode 100644
index 0000000..412db21
--- /dev/null
+++ b/api/src/__tests__/services/authentication.service.test.ts
@@ -0,0 +1,295 @@
+import {expect, sinon} from '@loopback/testlab';
+import {Request} from 'express';
+import jwt from 'jsonwebtoken';
+import axios from 'axios';
+
+import {AuthenticationService} from '../../services';
+import 'regenerator-runtime/runtime';
+import {securityId} from '@loopback/security';
+import {ValidationError} from '../../validationError';
+import {StatusCode} from '../../utils';
+
+describe('authentication service', () => {
+ let authenticationService: AuthenticationService;
+
+ beforeEach(() => {
+ authenticationService = new AuthenticationService();
+ });
+
+ it('should extractCredentials: OK', () => {
+ const request = {
+ headers: {
+ authorization: 'Bearer xxx.yyy.zzz',
+ },
+ };
+ const result = authenticationService.extractCredentials(request as Request);
+ expect(result).to.equal(request.headers.authorization.split(' ')[1]);
+ });
+
+ it('should extractCredentials: KO no header', () => {
+ const expectedError = new ValidationError(
+ `Authorization header not found`,
+ '/authorization',
+ StatusCode.Unauthorized,
+ );
+ const errorRequest = {
+ headers: {},
+ };
+ try {
+ authenticationService.extractCredentials(errorRequest as Request);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedError);
+ }
+ });
+
+ it('should extractCredentials: KO not bearer', () => {
+ const expectedError = new ValidationError(
+ `Authorization header is not of type 'Bearer'.`,
+ '/authorization',
+ StatusCode.Unauthorized,
+ );
+ const errorRequest = {
+ headers: {
+ authorization: 'Test',
+ },
+ };
+ try {
+ authenticationService.extractCredentials(errorRequest as Request);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedError);
+ }
+ });
+
+ it('should extractCredentials: KO bearer too many parts', () => {
+ const expectedError = new ValidationError(
+ `Authorization header not valid`,
+ '/authorization',
+ StatusCode.Unauthorized,
+ );
+ const errorRequest = {
+ headers: {
+ authorization: 'Bearer xxx.yyy.zzz test',
+ },
+ };
+ try {
+ authenticationService.extractCredentials(errorRequest as Request);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedError);
+ }
+ });
+
+ it('should verifyToken: OK', async () => {
+ const user = {
+ sub: 'id',
+ email_verified: true,
+ realm_access: {
+ roles: ['roles'],
+ },
+ };
+ const userResult = {
+ [securityId]: user.sub,
+ id: user.sub,
+ emailVerified: user.email_verified,
+ clientName: undefined,
+ funderType: undefined,
+ funderName: undefined,
+ groups: undefined,
+ incentiveType: undefined,
+ roles: user.realm_access.roles,
+ };
+ const getResult = {
+ data: {
+ keys: [
+ {
+ kid: 'kid',
+ kty: 'RSA',
+ alg: 'RS256',
+ use: 'sig',
+ n: `t1xAo85zcjxtsR-bSLe47pAJwj8yW7DX5G6LC0DgUc649J8E_f7QLnFwFkqbntD_jLWngnWceo_HoNR1o-8BS2d8n
+ dG__RKAu9nZof4qX8BV0WRbGQS85kSFfMlj9rW85kD_QZ-4FsP83Fzl4yCT868zRvarJyD3QSFRxVhueRwE5CtZWe
+ 7ycCPOzRPs_9XPUIkUIQd8Sk9JP7GB_3n27TEHl66ovSsC92f-7mJSwpX3EqD8jjlvyirTcxDOctGXA-ZoTMbGpZA
+ gsGo-U6BZHrFop-6HyDoaMjDUV-lhIBTUkLz2Cge_8I4jUWix_5twPATZ4sitYfmS8eUzh4hggw`,
+ e: 'AQAB',
+ x5c: [
+ `MIIClTCCAX0CBgGC9AA0ojANBgkqhkiG9w0BAQsFADAOMQwwCgYDVQQDDANtY20wHhcNMjIwODMxMTMwMjEwWhcNMz
+ IwODMxMTMwMzUwWjAOMQwwCgYDVQQDDANtY20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3XECjznNyP
+ G2xH5tIt7jukAnCPzJbsNfkbosLQOBRzrj0nwT9/tAucXAWSpue0P+MtaeCdZx6j8eg1HWj7wFLZ3yd0b/9EoC72dmh
+ /ipfwFXRZFsZBLzmRIV8yWP2tbzmQP9Bn7gWw/zcXOXjIJPzrzNG9qsnIPdBIVHFWG55HATkK1lZ7vJwI87NE+z/1c9
+ QiRQhB3xKT0k/sYH/efbtMQeXrqi9KwL3Z/7uYlLClfcSoPyOOW/KKtNzEM5y0ZcD5mhMxsalkCCwaj5ToFkesWin7o
+ fIOhoyMNRX6WEgFNSQvPYKB7/wjiNRaLH/m3A8BNniyK1h+ZLx5TOHiGCDAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAG
+ 7z4qy6KZYJwYvs8LveH5o9pah06T9Jd1fFTHVKCT0g6Ag8xaunccoFj6RLIFmOk5XsuXKp3lUMDOZR0utsLtoFU4mMdK
+ 19NdHl2a500DkW4ujk2pDYJlYqnAiVR4DJ4svyMCoLf1ubriZn6Ip6m1K9AZZw4d5jW/VitqVkwBY5DUYLJ0HNHZ242i
+ /VClj80u4zun7d7gIRvkcHF4k3lK7DB0LvJEXj8ZL7ExuNeyUGbblvjFLSKx0nJCL2AjFloqBO971NCm06bVLok/pOy4
+ df9KfboIyL7C8EEWhdxUQnEoQIX1zyS1yJb1PWhF2Ay6XPCzYVZMLhSFaHlCULhMk=`,
+ ],
+ x5t: 'dAgpup7prlDbyAnTLYuAdZYTGUg',
+ 'x5t#S256': 'DVtIRKD8VQPG0GFIfL0mvDbmO9k8PC2z32HdhaZBaa0',
+ },
+ ],
+ },
+ };
+ const axiosGet = sinon.stub(axios, 'get').returns(getResult as any);
+ const decodeStub = sinon.stub(jwt, 'decode').returns({header: {kid: 'kid'}} as any);
+
+ const verifyStub = sinon.stub(jwt, 'verify').returns(userResult as any);
+ const result = await authenticationService.verifyToken('token');
+ expect(result).to.deepEqual(userResult);
+ axiosGet.restore();
+ verifyStub.restore();
+ decodeStub.restore();
+ });
+
+ it('should verifyToken: KO no token', async () => {
+ const expectedError = new ValidationError(
+ `Error verifying token'.`,
+ '/authorization',
+ StatusCode.Unauthorized,
+ );
+ const token: any = undefined;
+ try {
+ await authenticationService.verifyToken(token);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedError);
+ }
+ });
+
+ it('should verifyToken: KO jwt expired', async () => {
+ const expectedError = new ValidationError(
+ `Error verifying token`,
+ '/authorization',
+ StatusCode.Unauthorized,
+ );
+ const verifyStub = sinon.stub(jwt, 'verify').returns(expectedError as any);
+ try {
+ await authenticationService.verifyToken('token');
+ } catch (err) {
+ expect(err).to.deepEqual(expectedError);
+ }
+ verifyStub.restore();
+ });
+
+ it('should convertToUser: OK collectivity', () => {
+ const user = {
+ sub: 'id',
+ email_verified: true,
+ membership: ['/collectivités/Mulhouse'],
+ realm_access: {
+ roles: ['roles'],
+ },
+ };
+ const userResult = {
+ [securityId]: user.sub,
+ id: user.sub,
+ clientName: undefined,
+ emailVerified: user.email_verified,
+ funderType: 'collectivités',
+ groups: ['Mulhouse'],
+ funderName: 'Mulhouse',
+ incentiveType: 'AideTerritoire',
+ roles: user.realm_access.roles,
+ };
+ const result = authenticationService.convertToUser(user);
+ expect(result).to.deepEqual(userResult);
+ });
+
+ it('should convertToUser: OK enterprise', () => {
+ const user = {
+ sub: 'id',
+ email_verified: true,
+ membership: ['/entreprises/Capgemini'],
+ realm_access: {
+ roles: ['roles'],
+ },
+ };
+ const userResult = {
+ [securityId]: user.sub,
+ id: user.sub,
+ clientName: undefined,
+ emailVerified: user.email_verified,
+ funderType: 'entreprises',
+ funderName: 'Capgemini',
+ groups: ['Capgemini'],
+ incentiveType: 'AideEmployeur',
+ roles: user.realm_access.roles,
+ };
+ const result = authenticationService.convertToUser(user);
+ expect(result).to.deepEqual(userResult);
+ });
+
+ it('should convertToUser: OK undefined funderType && funderName && incentiveType', () => {
+ const user = {
+ sub: 'id',
+ email_verified: true,
+ clientName: undefined,
+ membership: ['undefined'],
+ realm_access: {
+ roles: ['roles'],
+ },
+ };
+ const userResult = {
+ [securityId]: user.sub,
+ id: user.sub,
+ emailVerified: user.email_verified,
+ clientName: user.clientName,
+ funderType: undefined,
+ funderName: undefined,
+ groups: ['undefined'],
+ incentiveType: undefined,
+ roles: user.realm_access.roles,
+ };
+ const result = authenticationService.convertToUser(user);
+ expect(result).to.deepEqual(userResult);
+ });
+
+ it('should convertToUser: OK no membership', () => {
+ const user = {
+ sub: 'id',
+ email_verified: true,
+ realm_access: {
+ roles: ['roles'],
+ },
+ };
+ const userResult = {
+ [securityId]: user.sub,
+ id: user.sub,
+ emailVerified: user.email_verified,
+ clientName: undefined,
+ funderType: undefined,
+ funderName: undefined,
+ groups: undefined,
+ incentiveType: undefined,
+ roles: user.realm_access.roles,
+ };
+ const result = authenticationService.convertToUser(user);
+ expect(result).to.deepEqual(userResult);
+ });
+
+ it('should convertToUser: OK more roles', () => {
+ const user = {
+ sub: 'id',
+ email_verified: true,
+ azp: 'test',
+ realm_access: {
+ roles: ['roles'],
+ },
+ resource_access: {
+ test: {
+ roles: ['moreRoles'],
+ },
+ },
+ };
+ const userResult = {
+ [securityId]: user.sub,
+ id: user.sub,
+ emailVerified: user.email_verified,
+ clientName: undefined,
+ funderType: undefined,
+ funderName: undefined,
+ groups: undefined,
+ incentiveType: undefined,
+ roles: ['roles', 'moreRoles'],
+ };
+ const result = authenticationService.convertToUser(user);
+ expect(result).to.deepEqual(userResult);
+ });
+});
diff --git a/api/src/__tests__/services/child_processes/connect.test.ts b/api/src/__tests__/services/child_processes/connect.test.ts
new file mode 100644
index 0000000..b8dfbcb
--- /dev/null
+++ b/api/src/__tests__/services/child_processes/connect.test.ts
@@ -0,0 +1,28 @@
+import {expect} from '@loopback/testlab';
+
+import amqp, {Channel} from 'amqplib';
+import sinon from 'sinon';
+import {connect, createChannel} from '../../../services/child_processes/connect';
+
+describe('Child Process connect', () => {
+ it('Child Process connect : success', async () => {
+ const connection: any = {
+ createChannel: () => {},
+ };
+ const amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ const connectionResult = await connect();
+ expect(connectionResult).to.deepEqual(connection);
+ amqpTest.restore();
+ });
+
+ it('Child Process create channel : success', async () => {
+ const channel: Channel = Object.assign({name: 'channel'});
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ };
+ const channelResult = await createChannel(connection);
+ expect(channelResult).to.deepEqual(channel);
+ });
+});
diff --git a/api/src/__tests__/services/child_processes/consume.test.ts b/api/src/__tests__/services/child_processes/consume.test.ts
new file mode 100644
index 0000000..522c549
--- /dev/null
+++ b/api/src/__tests__/services/child_processes/consume.test.ts
@@ -0,0 +1,327 @@
+import {expect, sinon} from '@loopback/testlab';
+
+import amqp, {Channel, Connection} from 'amqplib';
+import process from 'process';
+import {EventEmitter} from 'events';
+import {ConsumeProcess} from '../../../services/child_processes/consume';
+import {EVENT_MESSAGE, UPDATE_MODE, IMessage, logger} from '../../../utils';
+import {connect, createChannel} from '../../../services/child_processes/connect';
+import {fromCallback} from 'bluebird';
+
+describe('Child Process consume', () => {
+ let connectStub: any = null;
+ let processStub: any = null;
+ let consumeProcess: any = null;
+ let listenerConnectStub: any = null;
+ let listenerChannelStub: any = null;
+ let retryConnectStub: any = null;
+ let retryChannelStub: any = null;
+ let queue: any;
+
+ const createQueue = () => {
+ let messages: any = [];
+ let subscriber: any = null;
+
+ return {
+ add: (item: any) => {
+ if (subscriber) {
+ subscriber(item);
+ } else {
+ messages.push(item);
+ }
+ },
+ addConsumer: (consumer: any) => {
+ messages.forEach((item: any) => consumer(item));
+ messages = [];
+ subscriber = consumer;
+ },
+ };
+ };
+
+ const channel: any = Object.assign(new EventEmitter(), {
+ consume: async (queueName: string, consumer: any) => {
+ queue.addConsumer(consumer);
+ return Promise.resolve({consumerTag: 'consumerTag'});
+ },
+ cancel: () => {},
+ close: () => {},
+ checkQueue: () => {
+ return Promise.resolve();
+ },
+ sendToQueue: async (queueName: any, content: any) => {
+ queue.add({
+ content,
+ });
+ },
+ });
+
+ const connection: any = Object.assign(new EventEmitter(), {
+ createChannel: () => {
+ return channel;
+ },
+ });
+
+ beforeEach(() => {
+ queue = createQueue();
+ process.send = () => {
+ return true;
+ };
+ connectStub = sinon.stub(amqp, 'connect').resolves(connection);
+ processStub = sinon.stub(process, 'emit');
+ consumeProcess = new ConsumeProcess();
+ listenerConnectStub = sinon.stub(consumeProcess, 'addConnectionErrorListeners');
+ listenerChannelStub = sinon.stub(consumeProcess, 'addChannelErrorListeners');
+ retryConnectStub = sinon.stub(consumeProcess, 'retryConnection');
+ retryChannelStub = sinon.stub(consumeProcess, 'retryChannel');
+ });
+
+ afterEach(() => {
+ connectStub.restore();
+ processStub.restore();
+ listenerConnectStub.restore();
+ listenerChannelStub.restore();
+ retryConnectStub.restore();
+ retryChannelStub.restore();
+ });
+
+ it('Child Process consume : start error connection', async () => {
+ connectStub.restore();
+ const errorConnection: any = {
+ createChannel: () => {
+ const err = new Error();
+ return Promise.reject(err);
+ },
+ };
+ connectStub = sinon.stub(amqp, 'connect').resolves(errorConnection);
+ try {
+ await consumeProcess.start();
+ } catch (err) {
+ expect(retryConnectStub.calledOnce).true();
+ }
+ });
+
+ it('Child Process consume : addConnectionErrorListeners error', async () => {
+ const spy = sinon.spy(logger, 'error');
+ await consumeProcess.start();
+ listenerConnectStub.restore();
+ await consumeProcess.addConnectionErrorListeners();
+ await consumeProcess.connection.emit('error');
+ expect(logger.error).called;
+ spy.restore();
+ });
+
+ it('Child Process consume : addConnectionErrorListeners exit', async () => {
+ await consumeProcess.start();
+ listenerConnectStub.restore();
+ await consumeProcess.addConnectionErrorListeners();
+ await consumeProcess.connection.emit('exit');
+ expect(consumeProcess.retryConnection.calledOnce).true();
+ });
+
+ it('Child Process consume : addConnectionErrorListeners close', async () => {
+ await consumeProcess.start();
+ listenerConnectStub.restore();
+ await consumeProcess.addConnectionErrorListeners();
+ await consumeProcess.connection.emit('close');
+ expect(consumeProcess.retryConnection.calledOnce).true();
+ });
+
+ it('Child Process consume : addChannelErrorListeners error', async () => {
+ await consumeProcess.start();
+ listenerChannelStub.restore();
+ await consumeProcess.addChannelErrorListeners();
+ await consumeProcess.channel.emit('error');
+ expect(consumeProcess.retryChannel.calledOnce).true();
+ });
+
+ it('Child Process consume : addChannelErrorListeners exit', async () => {
+ await consumeProcess.start();
+ listenerChannelStub.restore();
+ await consumeProcess.addChannelErrorListeners();
+ await consumeProcess.channel.emit('exit');
+ expect(consumeProcess.retryChannel.calledOnce).true();
+ });
+
+ it('Child Process consume : addChannelErrorListeners close', async () => {
+ const spy = sinon.spy(logger, 'error');
+ await consumeProcess.start();
+ listenerChannelStub.restore();
+ await consumeProcess.addChannelErrorListeners();
+ await consumeProcess.channel.emit('close');
+ expect(logger.error).called;
+ spy.restore();
+ });
+
+ it('Child Process consume : bulkAddConsumer success', async () => {
+ const enterpriseNameList: string[] = ['enterprise'];
+ const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true);
+ await consumeProcess.start();
+ await consumeProcess.bulkAddConsumer(enterpriseNameList);
+ expect(checkStub.calledOnce).true();
+ expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({
+ enterprise: 'consumerTag',
+ });
+ checkStub.restore();
+ });
+
+ it('Child Process consume : bulkAddConsumer success with message', async () => {
+ const enterpriseNameList: string[] = ['enterprise'];
+ const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true);
+ await consumeProcess.start();
+ await consumeProcess.bulkAddConsumer(enterpriseNameList);
+ consumeProcess.channel.sendToQueue('mob.subscription.status.enterprise', 'test');
+ expect(process.send).called;
+ expect(checkStub.calledOnce).true();
+ expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({
+ enterprise: 'consumerTag',
+ });
+ checkStub.restore();
+ });
+
+ it('Child Process consume : bulkAddConsumer error', async () => {
+ connectStub.restore();
+ const errorChannel: any = {
+ consume: () => {
+ const err = new Error();
+ return Promise.reject(err);
+ },
+ };
+ const errorConnection: any = {
+ createChannel: () => {
+ return errorChannel;
+ },
+ };
+ connectStub = sinon.stub(amqp, 'connect').resolves(errorConnection);
+
+ const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true);
+ const enterpriseNameList: string[] = ['enterprise'];
+ await consumeProcess.start();
+ try {
+ await consumeProcess.bulkAddConsumer(enterpriseNameList);
+ } catch (err) {
+ expect(checkStub.calledOnce).true();
+ expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({});
+ checkStub.restore();
+ }
+ });
+
+ it('Child Process consume : bulkCancelConsumer success', async () => {
+ const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true);
+ const enterpriseNameList: string[] = ['enterprise'];
+ await consumeProcess.start();
+ consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'};
+ await consumeProcess.bulkCancelConsumer(enterpriseNameList);
+ expect(checkStub.calledOnce).true();
+ expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({});
+ checkStub.restore();
+ });
+
+ it('Child Process consume : bulkCancelConsumer no existing queue', async () => {
+ const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(false);
+ const enterpriseNameList: string[] = ['enterprise'];
+ await consumeProcess.start();
+ consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'};
+ await consumeProcess.bulkCancelConsumer(enterpriseNameList);
+ expect(checkStub.calledOnce).true();
+ expect(consumeProcess.channel.cancel).not.called;
+ expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({
+ enterprise: 'consumerTag',
+ });
+ checkStub.restore();
+ });
+
+ it('Child Process consume : bulkCancelConsumer error', async () => {
+ connectStub.restore();
+ const errorChannel: any = {
+ cancel: () => {
+ const err = new Error();
+ return Promise.reject(err);
+ },
+ };
+ const errorConnection: any = {
+ createChannel: () => {
+ return errorChannel;
+ },
+ };
+ connectStub = sinon.stub(amqp, 'connect').resolves(errorConnection);
+
+ const checkStub = sinon.stub(consumeProcess, 'checkExistingQueue').resolves(true);
+ const enterpriseNameList: string[] = ['enterprise'];
+ await consumeProcess.start();
+ consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'};
+
+ try {
+ await consumeProcess.bulkCancelConsumer(enterpriseNameList);
+ } catch (err) {
+ expect(checkStub.calledOnce).true();
+ expect(consumeProcess.hashMapConsumerEnterprise).to.deepEqual({
+ enterprise: 'consumerTag',
+ });
+ checkStub.restore();
+ }
+ });
+
+ it('Child Process consume : checkExistingQueue returns true', async () => {
+ const enterpriseName: string = 'enterprise';
+ await consumeProcess.start();
+ const result: boolean = await consumeProcess.checkExistingQueue(enterpriseName);
+ expect(result).to.equal(true);
+ });
+
+ it('Child Process consume : restartConsumers success', async () => {
+ await consumeProcess.start();
+ consumeProcess.hashMapConsumerEnterprise = {enterprise: 'consumerTag'};
+ const bulkAddStub = sinon.stub(consumeProcess, 'bulkAddConsumer').resolves();
+ await consumeProcess.restartConsumers();
+ expect(consumeProcess.bulkAddConsumer.calledOnce).true();
+ bulkAddStub.restore();
+ });
+
+ it('Child Process consume : restartConsumers false', async () => {
+ await consumeProcess.restartConsumers();
+ expect(consumeProcess.bulkAddConsumer).not.called;
+ });
+
+ it('Child Process consume : retryConnection landscape false', async () => {
+ retryConnectStub.restore();
+ await consumeProcess.retryConnection();
+ expect(consumeProcess.start).not.called;
+ expect(consumeProcess.restartConsumers).not.called;
+ });
+
+ it('Child Process consume : retryConnection landscape true', async () => {
+ retryConnectStub.restore();
+ const env = Object.assign({}, process.env);
+ process.env.LANDSCAPE = 'test';
+ const clock = sinon.useFakeTimers();
+ const startStub = sinon.stub(consumeProcess, 'start').resolves();
+ const restartConsumersStub = sinon
+ .stub(consumeProcess, 'restartConsumers')
+ .resolves();
+ await consumeProcess.retryConnection();
+ clock.tick(15000);
+ expect(consumeProcess.start).called;
+ expect(consumeProcess.restartConsumers).called;
+ process.env = env;
+ startStub.restore();
+ restartConsumersStub.restore();
+ clock.restore();
+ });
+
+ it('Child Process consume : retryChannel success', async () => {
+ retryChannelStub.restore();
+ const clock = sinon.useFakeTimers();
+ const restartConsumersStub = sinon
+ .stub(consumeProcess, 'restartConsumers')
+ .resolves();
+ await consumeProcess.start();
+ await consumeProcess.retryChannel();
+ expect(consumeProcess.addChannelErrorListeners).not.called;
+ expect(consumeProcess.restartConsumers).not.called;
+ clock.tick(15000);
+ expect(consumeProcess.addChannelErrorListeners).called;
+ expect(consumeProcess.restartConsumers).called;
+ restartConsumersStub.restore();
+ clock.restore();
+ });
+});
diff --git a/api/src/__tests__/services/citizen.service.test.ts b/api/src/__tests__/services/citizen.service.test.ts
new file mode 100644
index 0000000..c5fb628
--- /dev/null
+++ b/api/src/__tests__/services/citizen.service.test.ts
@@ -0,0 +1,1589 @@
+import * as Excel from 'exceljs';
+import {
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+ expect,
+ sinon,
+ stubServerRequest,
+} from '@loopback/testlab';
+import {securityId, UserProfile} from '@loopback/security';
+import {
+ CitizenService,
+ JwtService,
+ MailService,
+ Tab,
+ KeycloakService,
+} from '../../services';
+import {ValidationError} from '../../validationError';
+import {
+ AFFILIATION_STATUS,
+ CITIZEN_STATUS,
+ ResourceName,
+ StatusCode,
+ SUBSCRIPTION_STATUS,
+} from '../../utils';
+import {
+ Incentive,
+ Citizen,
+ Enterprise,
+ Subscription,
+ UserEntity,
+ OfflineUserSession,
+ OfflineClientSession,
+ Client,
+ User,
+ Territory,
+} from '../../models';
+import {
+ UserRepository,
+ CitizenRepository,
+ EnterpriseRepository,
+ SubscriptionRepository,
+ UserEntityRepository,
+ ClientRepository,
+ OfflineClientSessionRepository,
+ OfflineUserSessionRepository,
+} from '../../repositories';
+
+describe('Citizen services', () => {
+ let citizenService: any = null;
+ let citizenRepository: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ userRepository: StubbedInstanceWithSinonAccessor,
+ subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ userEntityRepository: StubbedInstanceWithSinonAccessor,
+ clientRepository: StubbedInstanceWithSinonAccessor,
+ offlineClientSessionRepository: StubbedInstanceWithSinonAccessor,
+ offlineUserSessionRepository: StubbedInstanceWithSinonAccessor;
+
+ const currentUser: UserProfile = {
+ id: 'idUser',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['/entreprise/capgemini'],
+ roles: ['offline_access', 'uma_authorization'],
+ incentiveType: 'AideEmployeur',
+ funderName: 'funderName',
+ [securityId]: 'idUser',
+ };
+ let mailService: any = null;
+ let jwtService: any = null;
+ let keycloakService: any = null;
+
+ const citizen = new Citizen({
+ identity: {
+ gender: {
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ firstName: {
+ value: 'Xina',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ lastName: {
+ value: 'Zhong',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ birthDate: {
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ });
+
+ beforeEach(() => {
+ mailService = createStubInstance(MailService);
+ keycloakService = createStubInstance(KeycloakService);
+ citizenRepository = createStubInstance(CitizenRepository);
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ userRepository = createStubInstance(UserRepository);
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ userRepository = createStubInstance(UserRepository);
+ userEntityRepository = createStubInstance(UserEntityRepository);
+ clientRepository = createStubInstance(ClientRepository);
+ offlineClientSessionRepository = createStubInstance(OfflineClientSessionRepository);
+ offlineUserSessionRepository = createStubInstance(OfflineUserSessionRepository);
+
+ jwtService = new JwtService();
+ citizenService = new CitizenService(
+ citizenRepository,
+ enterpriseRepository,
+ userRepository,
+ subscriptionRepository,
+ jwtService,
+ currentUser,
+ userEntityRepository,
+ clientRepository,
+ offlineClientSessionRepository,
+ offlineUserSessionRepository,
+ keycloakService,
+ mailService,
+ );
+ });
+
+ it('sendAffiliationMail: successfull', () => {
+ citizenService.sendAffiliationMail(mailService, mockCitizen, 'funderName');
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ 'test@outlook.com',
+ `Bienvenue dans votre communauté moB ${'funderName'}`,
+ 'citizen-affiliation',
+ sinon.match.any,
+ ),
+ ).true();
+ });
+
+ it('validateEmailPattern : successful', () => {
+ try {
+ citizenService.validateEmailPattern('rerer@toto.fr', ['@toto.fr', '@toto.com']);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('validateEmailPattern : fail', () => {
+ try {
+ citizenService.validateEmailPattern('rerer@titi.fr', ['@toto.fr', '@toto.com']);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error).to.deepEqual(expectedErrorEmailFormat);
+ }
+ });
+
+ it('Check Affiliation: OK', async () => {
+ citizenRepository.stubs.findById.resolves(mockCitizen);
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+ sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true);
+
+ sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken);
+
+ const citizen = await citizenService.checkAffiliation(mockedToken);
+ expect(citizen).to.deepEqual(mockCitizen);
+ });
+
+ it('Check Affiliation: Token KO', async () => {
+ sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(false);
+
+ try {
+ await citizenService.checkAffiliation(mockedToken);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedErrorNotValid);
+ }
+ });
+
+ it('Check Affiliation: Enterprise Repository KO', async () => {
+ citizenRepository.stubs.findById.resolves(mockCitizen);
+ enterpriseRepository.stubs.findById.rejects();
+
+ sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true);
+
+ sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken);
+
+ try {
+ await citizenService.checkAffiliation(mockedToken);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedErrorNotValid);
+ }
+ });
+
+ it('Check Affiliation: Citizen Repository KO', async () => {
+ citizenRepository.stubs.findById.rejects();
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+
+ sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true);
+
+ sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken);
+
+ try {
+ await citizenService.checkAffiliation(mockedToken);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedErrorNotValid);
+ }
+ });
+
+ it('Check Affiliation: Citizen Affiliation KO', async () => {
+ const mockCitizenAffliationKO = new Citizen({...mockCitizen});
+
+ citizenRepository.stubs.findById.resolves(mockCitizenAffliationKO);
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+
+ sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true);
+
+ sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken);
+
+ try {
+ await citizenService.checkAffiliation(mockedToken);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedErrorNotValid);
+ }
+ });
+
+ it('Check Affiliation: Affiliation enterpriseId matches token KO', async () => {
+ const mockCitizenAffliationKO: any = {
+ ...mockCitizen,
+ affiliation: {...mockCitizen.affiliation},
+ };
+ mockCitizenAffliationKO.affiliation.enterpriseId = 'KO';
+
+ citizenRepository.stubs.findById.resolves(mockCitizenAffliationKO);
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+
+ sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true);
+
+ sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken);
+
+ try {
+ await citizenService.checkAffiliation(mockedToken);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedErrorNotValid);
+ }
+ });
+
+ it('Check Affiliation: Status KO', async () => {
+ const mockCitizenAffliationKO: any = {
+ ...mockCitizen,
+ affiliation: {...mockCitizen.affiliation},
+ };
+ mockCitizenAffliationKO.affiliation!.affiliationStatus =
+ AFFILIATION_STATUS.AFFILIATED;
+ citizenRepository.stubs.findById.resolves(mockCitizenAffliationKO);
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+
+ sinon.stub(jwtService, 'verifyAffiliationAccessToken').returns(true);
+
+ sinon.stub(jwtService, 'decodeAffiliationAccessToken').returns(mockedDecodedToken);
+
+ try {
+ await citizenService.checkAffiliation(mockedToken);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedErrorBadStatus);
+ }
+ });
+
+ it('sendDisaffiliationMail: successfull', () => {
+ citizenService.sendDisaffiliationMail(mailService, mockCitizen);
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ 'email@gmail.com',
+ 'Votre affiliation employeur vient d’être supprimée',
+ 'disaffiliation-citizen',
+ ),
+ ).true();
+ });
+ it('sendRejectedAffiliation: successfull', () => {
+ citizenService.sendRejectedAffiliation(mockCitizen, 'enterpriseName');
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ 'email@gmail.com',
+ "Votre demande d'affiliation a été refusée",
+ 'affiliation-rejection',
+ ),
+ ).true();
+ });
+ it('sendValidatedAffiliation: successfull', () => {
+ citizenService.sendValidatedAffiliation(mockCitizen, 'enterpriseName');
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ 'email@gmail.com',
+ "Votre demande d'affiliation a été acceptée !",
+ 'affiliation-validation',
+ ),
+ ).true();
+ });
+
+ it('Check Disaffiliation: KO', async () => {
+ citizenRepository.stubs.findById.resolves(new Citizen({id: 'randomInputId'}));
+ userRepository.stubs.findOne.resolves(mockUserWithCom);
+ subscriptionRepository.stubs.find.resolves();
+
+ try {
+ await citizenService.checkDisaffiliation('randomInputId');
+ } catch (err) {
+ expect(err).to.deepEqual(expectedErrorDisaffiliation);
+ }
+ });
+
+ it('check generateRow : success', async () => {
+ try {
+ const header: string[] = [
+ 'Date de la demande',
+ "Nom de l'aide",
+ 'Financeur',
+ 'Statut',
+ 'specificField',
+ ];
+ const excepted: string[] = [
+ '06/04/2021',
+ 'incentiveTitle',
+ 'funderName',
+ 'à traiter',
+ 'value',
+ ];
+ const result: string[] = await citizenService.generateRow(
+ mockDemandeWithSpecefiqueFields,
+ header,
+ );
+ expect(result).to.deepEqual(excepted);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check generateRow avec justificatifs: success', async () => {
+ try {
+ const header: string[] = [
+ 'Date de la demande',
+ "Nom de l'aide",
+ 'Financeur',
+ 'Statut',
+ 'Nom des justificatifs transmis',
+ ];
+ const excepted: string[] = [
+ '06/04/2021',
+ 'incentiveTitle',
+ 'funderName',
+ 'à traiter',
+ 'originalName',
+ ];
+
+ const result: string[] = await citizenService.generateRow(
+ mockDemandeWithJustificatifs,
+ header,
+ );
+ expect(result).to.deepEqual(excepted);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check generateHeader : success', async () => {
+ try {
+ const excepted: string[] = [
+ 'Date de la demande',
+ "Nom de l'aide",
+ 'Financeur',
+ 'Statut',
+ 'Nom des justificatifs transmis',
+ 'specificField',
+ ];
+
+ const result: string[] = await citizenService.generateHeader(incentive);
+ expect(result).to.deepEqual(excepted);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check generateTabsDataStructure without SpecefiqueFields: success', async () => {
+ try {
+ const excepted: Tab[] = tabsWithoutSF;
+ const result: Tab[] = await citizenService.generateTabsDataStructure(
+ [mockSubscription, {incentiveId: null}],
+ [{id: 'incentiveId'}],
+ );
+ expect(result).to.deepEqual(excepted);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check generateTabsDataStructure : success', async () => {
+ try {
+ const excepted: Tab[] = tabs;
+ const result: Tab[] = await citizenService.generateTabsDataStructure(
+ [mockDemandeWithSpecefiqueFields],
+ [incentive],
+ );
+ expect(result).to.deepEqual(excepted);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check getListMaasNames : success', async () => {
+ userEntityRepository.stubs.findOne.resolves(userEntity);
+ offlineUserSessionRepository.stubs.find.resolves([offlineUserSession]);
+ offlineClientSessionRepository.stubs.find.resolves([offlineClientSession]);
+ clientRepository.stubs.find.resolves([client]);
+ try {
+ const result: string[] = await citizenService.getListMaasNames('email@gmail.com');
+ expect(result).to.deepEqual(['name maas']);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check getListMaasNames empty: success', async () => {
+ userEntityRepository.stubs.findOne.resolves(userEntity);
+ offlineUserSessionRepository.stubs.find.resolves([]);
+ offlineClientSessionRepository.stubs.find.resolves([]);
+ clientRepository.stubs.find.resolves([]);
+ try {
+ const result: string[] = await citizenService.getListMaasNames('email@gmail.com');
+ expect(result).to.deepEqual([]);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check addSheetSubscriptions: success', async () => {
+ const workbook = new Excel.Workbook();
+ const userDemandes: Subscription[] = [mockSubscription];
+ const userAides: Incentive[] = [mockAideCollectivite];
+ try {
+ citizenService.addSheetSubscriptions(workbook, userDemandes, userAides);
+ const result = await workbook.xlsx.writeBuffer();
+ expect(result).to.be.instanceof(Buffer);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check Buffer issue de la generation excel : success', async () => {
+ const userDemandes: Subscription[] = [];
+ const incentives: Incentive[] = [];
+ const listMaas: string[] = [];
+ const companyName = 'companyName';
+ try {
+ const result = await citizenService.generateExcelRGPD(
+ citizen,
+ companyName,
+ userDemandes,
+ incentives,
+ listMaas,
+ );
+ expect(result).to.be.instanceof(Buffer);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('sendDeletionMail: successfull', () => {
+ citizenService.sendDeletionMail(mailService, mockCitizen, '23/07/2022 à 12:12');
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ mockCitizen.email,
+ 'Votre compte a bien été supprimé',
+ 'deletion-account-citizen',
+ sinon.match.any,
+ ),
+ ).true();
+ });
+
+ it('checkProEmailExistence : successful', () => {
+ citizenRepository.stubs.findOne.resolves(null);
+ try {
+ citizenService.checkProEmailExistence('test@test.com');
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('checkProEmailExistence : fail', () => {
+ citizenRepository.stubs.findOne.resolves(mockCitizen);
+ try {
+ citizenService.checkProEmailExistence('test@outlook.com');
+ } catch (error) {
+ expect(error).to.deepEqual(expectedErrorEmailUnique);
+ }
+ });
+
+ it('findEmployees: successfull', async () => {
+ userEntityRepository.stubs.findOne.resolves(userEntity);
+ userRepository.stubs.findById.resolves(mockUserWithCom);
+
+ const employees = [{employees: [mockCitizen, mockCitizen2], employeesCount: 2}];
+
+ citizenRepository.stubs.execute.resolves(employees);
+
+ const result = citizenService
+ .findEmployees(AFFILIATION_STATUS.AFFILIATED, 'lastName', 0, 10)
+ .then((res: any) => res)
+ .catch((err: any) => err);
+ expect(result).deepEqual(
+ new Promise(() => {
+ return employees;
+ }),
+ );
+ });
+
+ it('check getClientList : success', async () => {
+ clientRepository.stubs.find.resolves(clients);
+ try {
+ const result: string[] = await citizenService.getClientList(
+ 'simulation-maas-client',
+ );
+ expect(result).to.deepEqual(clients);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check getClientList : fail', async () => {
+ clientRepository.stubs.find.resolves(clients);
+ try {
+ citizenService.getClientList('simulation');
+ } catch (error) {
+ expect(error).to.deepEqual(expectedErrorClientId);
+ }
+ });
+
+ it('sendNonActivatedAccountDeletionMail: successfull', () => {
+ citizenService.sendNonActivatedAccountDeletionMail(mailService, mockCitizen);
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ mockCitizen.email,
+ 'Votre compte moB vient d’être supprimé',
+ 'nonActivated-account-deletion',
+ sinon.match.any,
+ ),
+ ).true();
+ });
+
+ it('SendManualAffiliationMail: successfull', () => {
+ userRepository.stubs.find.resolves([mockUserWithCom]);
+ keycloakService.stubs.getUser.resolves(userEntity);
+ citizenService.sendManualAffiliationMail(mockCitizen, mockEnterprise);
+
+ [mockUserWithCom].map(async singleFunder => {
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ singleFunder.email,
+ `Vous avez une nouvelle demande d'affiliation !`,
+ 'manual-affiliation',
+ sinon.match.any,
+ ),
+ ).true();
+ });
+ });
+
+ it('CreateCitizen Service : fails because of create repository error', async () => {
+ const errorRepository = 'can not add data in database';
+ try {
+ keycloakService.stubs.createUserKc.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.rejects(errorRepository);
+ enterpriseRepository.stubs.findById.resolves(enterprise);
+ keycloakService.stubs.deleteUserKc.resolves();
+
+ await citizenService.createCitizen(salarie);
+ } catch (err) {
+ expect(err.name).to.equal(errorRepository);
+ }
+
+ keycloakService.stubs.createUserKc.restore();
+ citizenRepository.stubs.create.restore();
+ enterpriseRepository.stubs.findById.restore();
+ keycloakService.stubs.deleteUserKc.restore();
+ });
+
+ it('CreateCitizen Service create salarie : successful', async () => {
+ enterpriseRepository.stubs.findById.resolves(enterprise);
+ keycloakService.stubs.createUserKc.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.resolves(createdSalarie);
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves();
+
+ const result = await citizenService.createCitizen(salarie);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ sinon.assert.calledWithExactly(
+ citizenRepository.stubs.create,
+ sinon.match(createdSalarie),
+ );
+
+ enterpriseRepository.stubs.findById.restore();
+ keycloakService.stubs.createUserKc.restore();
+ citizenRepository.stubs.create.restore();
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.restore();
+ });
+
+ it('CreateCitizen Service create salarie with manual affiliation : successful', async () => {
+ enterpriseRepository.stubs.findById.resolves(mockEnterprise);
+ keycloakService.stubs.createUserKc.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.resolves(createdSalarieNoProEmail);
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves();
+ const sendManualAff = sinon
+ .stub(citizenService, 'sendManualAffiliationMail')
+ .resolves(null);
+
+ const result = await citizenService.createCitizen(salarieNoProEmail);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ sinon.assert.calledWithExactly(
+ citizenRepository.stubs.create,
+ sinon.match(createdSalarieNoProEmail),
+ );
+
+ enterpriseRepository.stubs.findById.restore();
+ keycloakService.stubs.createUserKc.restore();
+ citizenRepository.stubs.create.restore();
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.restore();
+ sendManualAff.restore();
+ });
+
+ it('CreateCitizen Service create salarie no enterprise : successful', async () => {
+ keycloakService.stubs.createUserKc.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.resolves(createdSalarieNoEnterprise);
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves();
+
+ const result = await citizenService.createCitizen(salarieNoEnterprise);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ sinon.assert.calledWithExactly(
+ citizenRepository.stubs.create,
+ sinon.match(createdSalarieNoEnterprise),
+ );
+
+ keycloakService.stubs.createUserKc.restore();
+ citizenRepository.stubs.create.restore();
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.restore();
+ });
+
+ it('CreateCitizen Service create student : successful', async () => {
+ keycloakService.stubs.createUserKc.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.resolves(createdStudent);
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves();
+
+ const result = await citizenService.createCitizen(student);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ const arg: any = {
+ email: 'email@gmail.com',
+ identity: {
+ gender: {
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ firstName: {
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ lastName: {
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ birthDate: {
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ },
+ city: 'test',
+ postcode: '31000',
+ status: CITIZEN_STATUS.STUDENT,
+ tos1: true,
+ tos2: true,
+ id: 'randomInputId',
+ affiliation: {
+ enterpriseId: null,
+ enterpriseEmail: null,
+ affiliationStatus: AFFILIATION_STATUS.UNKNOWN,
+ },
+ };
+
+ sinon.assert.notCalled(enterpriseRepository.stubs.findById);
+ keycloakService.stubs.createUserKc.restore();
+ sinon.assert.calledWithExactly(citizenRepository.stubs.create, sinon.match(arg));
+ citizenRepository.stubs.create.restore();
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.restore();
+ });
+
+ it('CreateCitizen Service createCitizenFc salarie : successful', async () => {
+ enterpriseRepository.stubs.findById.resolves(enterprise);
+ keycloakService.stubs.updateCitizenRole.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.resolves(createdSalarie);
+
+ const result = await citizenService.createCitizen(salarie, 'randomInputId');
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ sinon.assert.calledWithExactly(
+ citizenRepository.stubs.create,
+ sinon.match(createdSalarie),
+ );
+
+ enterpriseRepository.stubs.findById.restore();
+ keycloakService.stubs.updateCitizenRole.restore();
+ citizenRepository.stubs.create.restore();
+ });
+
+ it('CreateCitizenFc salarie no enterprise : successful', async () => {
+ keycloakService.stubs.updateCitizenRole.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.resolves(createdSalarieNoEnterprise);
+ keycloakService.stubs.sendExecuteActionsEmailUserKc.resolves();
+
+ const result = await citizenService.createCitizen(
+ salarieNoEnterprise,
+ 'randomInputId',
+ );
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ sinon.assert.calledWithExactly(
+ citizenRepository.stubs.create,
+ sinon.match(createdSalarieNoEnterprise),
+ );
+
+ keycloakService.stubs.updateCitizenRole.restore();
+ citizenRepository.stubs.create.restore();
+ });
+
+ it('CreateCitizen Service createCitizenFc student : successful', async () => {
+ keycloakService.stubs.updateCitizenRole.resolves({
+ id: 'randomInputId',
+ });
+ citizenRepository.stubs.create.resolves(createdStudent);
+
+ const result = await citizenService.createCitizen(student, 'randomInputId');
+
+ expect(result).to.deepEqual({
+ id: 'randomInputId',
+ });
+
+ const arg: any = {
+ email: 'email@gmail.com',
+ identity: {
+ firstName: {
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ lastName: {
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ birthDate: {
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ },
+ city: 'test',
+ postcode: '31000',
+ status: CITIZEN_STATUS.STUDENT,
+ tos1: true,
+ tos2: true,
+ id: 'randomInputId',
+ affiliation: {
+ enterpriseId: null,
+ enterpriseEmail: null,
+ affiliationStatus: AFFILIATION_STATUS.UNKNOWN,
+ },
+ };
+
+ sinon.assert.notCalled(enterpriseRepository.stubs.findById);
+ keycloakService.stubs.updateCitizenRole.restore();
+ sinon.assert.calledWithExactly(citizenRepository.stubs.create, sinon.match(arg));
+ citizenRepository.stubs.create.restore();
+ });
+
+ it('CreateCitizen Service createCitizenFc etudiant keycloakResult undefined', async () => {
+ keycloakService.stubs.updateCitizenRole.resolves({
+ id: 'undefined',
+ });
+
+ const result = await citizenService.createCitizen(student);
+
+ expect(result).to.deepEqual(undefined);
+
+ keycloakService.stubs.updateCitizenRole.restore();
+ });
+});
+
+const expectedErrorNotValid = new ValidationError(
+ 'citizens.affiliation.not.valid',
+ '/citizensAffiliationNotValid',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Affiliation,
+);
+
+const expectedErrorBadStatus = new ValidationError(
+ 'citizens.affiliation.bad.status',
+ '/citizensAffiliationBadStatus',
+ StatusCode.PreconditionFailed,
+ ResourceName.AffiliationBadStatus,
+);
+
+const expectedErrorEmailFormat = new ValidationError(
+ 'citizen.email.professional.error.format',
+ '/professionnalEmailBadFormat',
+ StatusCode.PreconditionFailed,
+ ResourceName.ProfessionalEmail,
+);
+
+const expectedErrorDisaffiliation = new ValidationError(
+ 'citizens.disaffiliation.impossible',
+ '/citizensDisaffiliationImpossible',
+ StatusCode.PreconditionFailed,
+ ResourceName.Disaffiliation,
+);
+const expectedErrorClientId = new ValidationError(
+ 'client.id.notFound',
+ '/clientIdNotFound',
+ StatusCode.NotFound,
+ ResourceName.Client,
+);
+
+const expectedErrorEmailUnique = new ValidationError(
+ 'citizen.email.error.unique',
+ '/affiliation.enterpriseEmail',
+ StatusCode.UnprocessableEntity,
+ ResourceName.UniqueProfessionalEmail,
+);
+
+const mockedToken = 'montoken';
+
+const mockedDecodedToken = {
+ id: 'randomInputId',
+ enterpriseId: 'randomInputEnterpriseId',
+};
+
+const mockCitizen2 = new Citizen({
+ id: 'randomInputId',
+ identity: {
+ gender: {
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ firstName: {
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ lastName: {
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ birthDate: {
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'funderId',
+ enterpriseEmail: 'test@outlook.com',
+ affiliationStatus: AFFILIATION_STATUS.AFFILIATED,
+ }),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+});
+
+const mockCitizen = new Citizen({
+ id: 'randomInputId',
+ identity: {
+ gender: {
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ firstName: {
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ lastName: {
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ birthDate: {
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+ },
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'randomInputEnterpriseId',
+ enterpriseEmail: 'test@outlook.com',
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ }),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+});
+
+const mockEnterprise = new Enterprise({
+ id: 'randomInputIdEntreprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'nameEntreprise',
+ siretNumber: 50,
+ budgetAmount: 102,
+ employeesCount: 100,
+ hasManualAffiliation: true,
+});
+
+const mockUserWithCom = new User({
+ id: 'idUser',
+ email: 'random@random.fr',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ funderId: 'funderId',
+ roles: ['gestionnaires'],
+ communityIds: ['id1'],
+});
+
+const mockSubscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ communityId: 'id1',
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+});
+
+const mockDemandeWithJustificatifs = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: 'AideEmployeur',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ incentiveTransportList: ['velo'],
+ communityId: 'id1',
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ attachments: [
+ {
+ originalName: 'originalName',
+ mimeType: '',
+ uploadDate: new Date(),
+ proofType: '',
+ },
+ ],
+});
+
+const mockDemandeWithSpecefiqueFields = new Subscription({
+ ...mockSubscription,
+ specificFields: {
+ specificField: 'value',
+ },
+});
+
+const incentive = {
+ id: 'incentiveId',
+ specificFields: [{title: 'specificField', inputFormat: 'Numerique'}],
+};
+
+const tabs: Tab[] = [
+ {
+ title: 'incentiveId',
+ header: [
+ 'Date de la demande',
+ "Nom de l'aide",
+ 'Financeur',
+ 'Statut',
+ 'Nom des justificatifs transmis',
+ 'specificField',
+ ],
+ rows: [['06/04/2021', 'incentiveTitle', 'funderName', 'à traiter', '', 'value']],
+ },
+];
+const tabsWithoutSF: Tab[] = [
+ {
+ title: 'incentiveId',
+ header: [
+ 'Date de la demande',
+ "Nom de l'aide",
+ 'Financeur',
+ 'Statut',
+ 'Nom des justificatifs transmis',
+ ],
+ rows: [['06/04/2021', 'incentiveTitle', 'funderName', 'à traiter', '']],
+ },
+];
+
+const userEntity: UserEntity = {
+ id: 'idUser',
+ email: 'email@gmail.com',
+ username: '',
+ emailVerified: true,
+ enabled: true,
+ notBefore: 0,
+ getId: () => {},
+ getIdObject: () => new Object(),
+ toObject: () => new Object(),
+ toJSON: () => new Object(),
+ keycloakGroups: [],
+};
+
+const offlineUserSession: OfflineUserSession = {
+ userId: 'userId',
+ userSessionId: 'sessionId',
+ realmId: 'null',
+ createdOn: 1,
+ offlineFlag: '',
+ lastSessionRefresh: 0,
+ getId: () => {},
+ getIdObject: () => new Object(),
+ toObject: () => new Object(),
+ toJSON: () => new Object(),
+};
+
+const offlineClientSession: OfflineClientSession = {
+ clientId: 'clientId',
+ userSessionId: 'sessionId',
+ offlineFlag: '',
+ getId: () => {},
+ getIdObject: () => new Object(),
+ toObject: () => new Object(),
+ toJSON: () => new Object(),
+ externalClientId: '',
+ clientStorageProvider: '',
+};
+
+const client: Client = {
+ id: 'clientId',
+ name: 'name maas',
+ clientId: 'clientIdMaas',
+ alwaysDisplayInConsole: false,
+ bearerOnly: false,
+ consentRequired: false,
+ enabled: false,
+ fullScopeAllowed: false,
+ publicClient: false,
+ surrogateAuthRequired: false,
+ frontchannelLogout: false,
+ serviceAccountsEnabled: false,
+ standardFlowEnabled: false,
+ implicitFlowEnabled: false,
+ getId: () => {},
+ getIdObject: () => new Object(),
+ toObject: () => new Object(),
+ toJSON: () => new Object(),
+ directAccessGrantsEnabled: false,
+};
+
+const clients: Client[] = [
+ {
+ id: 'clientId',
+ name: 'simulation maas client',
+ clientId: 'simulation-maas-client',
+ alwaysDisplayInConsole: false,
+ bearerOnly: false,
+ consentRequired: false,
+ enabled: false,
+ fullScopeAllowed: false,
+ publicClient: false,
+ surrogateAuthRequired: false,
+ frontchannelLogout: false,
+ serviceAccountsEnabled: false,
+ standardFlowEnabled: false,
+ implicitFlowEnabled: false,
+ getId: () => {},
+ getIdObject: () => new Object(),
+ toObject: () => new Object(),
+ toJSON: () => new Object(),
+ directAccessGrantsEnabled: false,
+ },
+ {
+ id: 'clientId2',
+ name: 'mulhouse maas client',
+ clientId: 'mulhouse-maas-client',
+ alwaysDisplayInConsole: false,
+ bearerOnly: false,
+ consentRequired: false,
+ enabled: false,
+ fullScopeAllowed: false,
+ publicClient: false,
+ surrogateAuthRequired: false,
+ frontchannelLogout: false,
+ serviceAccountsEnabled: false,
+ standardFlowEnabled: false,
+ implicitFlowEnabled: false,
+ getId: () => {},
+ getIdObject: () => new Object(),
+ toObject: () => new Object(),
+ toJSON: () => new Object(),
+ directAccessGrantsEnabled: false,
+ },
+ {
+ id: 'clientId3',
+ name: 'paris maas client',
+ clientId: 'paris-maas-client',
+ alwaysDisplayInConsole: false,
+ bearerOnly: false,
+ consentRequired: false,
+ enabled: false,
+ fullScopeAllowed: false,
+ publicClient: false,
+ surrogateAuthRequired: false,
+ frontchannelLogout: false,
+ serviceAccountsEnabled: false,
+ standardFlowEnabled: false,
+ implicitFlowEnabled: false,
+ getId: () => {},
+ getIdObject: () => new Object(),
+ toObject: () => new Object(),
+ toJSON: () => new Object(),
+ directAccessGrantsEnabled: false,
+ },
+];
+
+const mockAideCollectivite = new Incentive({
+ territory: {name: 'Toulouse', id: 'randomTerritoryId'} as Territory,
+ additionalInfos: 'test',
+ funderName: 'Mairie',
+ allocatedAmount: '200 €',
+ description: 'test',
+ title: 'Aide pour acheter vélo électrique',
+ incentiveType: 'AideTerritoire',
+ createdAt: new Date('2021-04-06T09:01:30.747Z'),
+ transportList: ['velo'],
+ validityDate: '2022-04-06T09:01:30.778Z',
+ minAmount: 'A partir de 100 €',
+ contact: 'Mr le Maire',
+ validityDuration: '1 an',
+ paymentMethod: 'En une seule fois',
+ attachments: ['RIB'],
+ id: 'incentiveId',
+ conditions: 'Vivre à TOulouse',
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ isMCMStaff: true,
+});
+
+const salarie = Object.assign(new Citizen(), {
+ identity: {
+ gender: {
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ firstName: {
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ lastName: {
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ birthDate: {
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ },
+ id: 'randomInputId',
+ email: 'email@gmail.com',
+ city: 'test',
+
+ status: CITIZEN_STATUS.EMPLOYEE,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: 'enterpriseEmail@gmail.com',
+ }),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+});
+
+const enterprise: Enterprise = new Enterprise({
+ name: 'enterprise',
+ emailFormat: ['@gmail.com', 'rr'],
+});
+
+const createdSalarie = Object.assign(new Citizen(), {
+ id: 'randomInputId',
+ email: 'email@gmail.com',
+ identity: {
+ gender: {
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ firstName: {
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ lastName: {
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ birthDate: {
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ },
+ },
+ city: 'test',
+ postcode: '31000',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: 'enterpriseEmail@gmail.com',
+ affiliationStatus: AFFILIATION_STATUS.TO_AFFILIATE,
+ }),
+});
+
+const createdSalarieNoEnterprise = Object.assign(new Citizen(), {
+ id: 'randomInputId',
+ email: 'email@gmail.com',
+ identity: Object.assign({
+ gender: Object.assign({
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ }),
+ city: 'test',
+ postcode: '31000',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: null,
+ enterpriseEmail: null,
+ affiliationStatus: AFFILIATION_STATUS.UNKNOWN,
+ }),
+});
+
+const salarieNoEnterprise = Object.assign(new Citizen(), {
+ id: 'randomInputId',
+ identity: Object.assign({
+ gender: Object.assign({
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ }),
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: '',
+ enterpriseEmail: '',
+ }),
+});
+
+const student = Object.assign(new Citizen(), {
+ id: 'randomInputId',
+ identity: Object.assign({
+ gender: Object.assign({
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ }),
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.STUDENT,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: '',
+ enterpriseEmail: '',
+ }),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+});
+
+const createdStudent = Object.assign(new Citizen(), {
+ id: 'randomInputId',
+ identity: Object.assign({
+ gender: Object.assign({
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ }),
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.STUDENT,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: null,
+ enterpriseEmail: null,
+ affiliationStatus: AFFILIATION_STATUS.UNKNOWN,
+ }),
+ getId: () => {},
+ getIdObject: () => ({id: 'random'}),
+ toJSON: () => ({id: 'random'}),
+ toObject: () => ({id: 'random'}),
+});
+
+const createdSalarieNoProEmail = new Citizen({
+ id: 'randomInputId',
+ identity: Object.assign({
+ gender: Object.assign({
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ }),
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: null,
+ }),
+});
+
+const salarieNoProEmail = new Citizen({
+ id: 'randomInputId',
+ identity: Object.assign({
+ gender: Object.assign({
+ value: 1,
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ firstName: Object.assign({
+ value: 'firstName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ lastName: Object.assign({
+ value: 'lastName',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ birthDate: Object.assign({
+ value: '1991-11-17',
+ source: 'moncomptemobilite.fr',
+ certificationDate: new Date('2022-10-24'),
+ }),
+ }),
+ email: 'email@gmail.com',
+ city: 'test',
+ status: CITIZEN_STATUS.EMPLOYEE,
+ postcode: '31000',
+ tos1: true,
+ tos2: true,
+ affiliation: Object.assign({
+ enterpriseId: 'enterpriseId',
+ enterpriseEmail: null,
+ affiliationStatus: AFFILIATION_STATUS.UNKNOWN,
+ }),
+});
diff --git a/api/src/__tests__/services/clamav.service.test.ts b/api/src/__tests__/services/clamav.service.test.ts
new file mode 100644
index 0000000..007086b
--- /dev/null
+++ b/api/src/__tests__/services/clamav.service.test.ts
@@ -0,0 +1,85 @@
+import {expect, StubbedInstanceWithSinonAccessor} from '@loopback/testlab';
+import sinon from 'sinon';
+import {Readable} from 'stream';
+import NodeClam from 'clamscan';
+import {ClamavService} from '../../services';
+import {ValidationError} from '../../validationError';
+
+describe('Clamav ', async () => {
+ let clamav: ClamavService, nodeClamStub: StubbedInstanceWithSinonAccessor;
+ beforeEach(() => {
+ clamav = new ClamavService();
+ });
+ afterEach(() => {
+ nodeClamStub.restore();
+ });
+ it('clamavService: no virus', async () => {
+ nodeClamStub = sinon.stub(NodeClam.prototype, 'init').resolves(
+ Promise.resolve({
+ scanStream: () => {
+ return Promise.resolve(response);
+ },
+ }),
+ );
+ const res = await clamav.checkCorruptedFiles(fileList);
+ expect(res).to.equal(true);
+ });
+
+ it('clamavService: virus', async () => {
+ nodeClamStub = sinon.stub(NodeClam.prototype, 'init').resolves(
+ Promise.resolve({
+ scanStream: () => {
+ return Promise.resolve(responseVirus);
+ },
+ }),
+ );
+ const res = await clamav.checkCorruptedFiles(fileList);
+ expect(res).to.equal(false);
+ });
+
+ it('clamavService: init fail', async () => {
+ try {
+ nodeClamStub = sinon.stub(NodeClam.prototype, 'init').rejects({});
+ await clamav.checkCorruptedFiles(fileList);
+ } catch (err) {
+ expect(err.message).to.equal(errorUrl.message);
+ }
+ });
+
+ it('clamavService: scan stream fail', async () => {
+ try {
+ nodeClamStub = sinon.stub(NodeClam.prototype, 'init').resolves(
+ Promise.resolve({
+ scanStream: () => {
+ return Promise.reject({});
+ },
+ }),
+ );
+ await clamav.checkCorruptedFiles(fileList);
+ } catch (err) {
+ expect(err.message).to.equal(errorUrl.message);
+ }
+ });
+
+ const file: any = {
+ originalname: 'test',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ };
+ const fileList: any[] = [file, file, file];
+});
+const errorUrl: any = new ValidationError('Error during file list check', '/antivirus');
+
+const response = {
+ isInfected: false,
+};
+const responseVirus = {
+ isInfected: true,
+};
diff --git a/api/src/__tests__/services/contact.service.test.ts b/api/src/__tests__/services/contact.service.test.ts
new file mode 100644
index 0000000..a96d80b
--- /dev/null
+++ b/api/src/__tests__/services/contact.service.test.ts
@@ -0,0 +1,38 @@
+import {createStubInstance, expect, sinon} from '@loopback/testlab';
+import {ContactService, MailService} from '../../services';
+import {USERTYPE} from '../../utils';
+
+describe('contact service', () => {
+ let contactService: any = null;
+ let mailService: any = null;
+
+ beforeEach(() => {
+ mailService = createStubInstance(MailService);
+ contactService = new ContactService();
+ contactService._to = 'to';
+ });
+
+ it('sendContactMail: successfull', async () => {
+ contactService.sendMailClient(mailService, mockContact);
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ 'test@test.com',
+ 'Nous traitons votre demande !',
+ 'client-contact',
+ sinon.match.any,
+ ),
+ ).true();
+ });
+});
+
+const mockContact = {
+ firstName: 'firstName',
+ lastName: 'lastName',
+ email: 'test@test.com',
+ userType: USERTYPE.CITIZEN,
+ postcode: '55555',
+ message: 'Message test',
+ tos: true,
+};
diff --git a/api/src/__tests__/services/cronJobs.service.test.ts b/api/src/__tests__/services/cronJobs.service.test.ts
new file mode 100644
index 0000000..9fa1f4b
--- /dev/null
+++ b/api/src/__tests__/services/cronJobs.service.test.ts
@@ -0,0 +1,63 @@
+import {
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+} from '@loopback/testlab';
+import {AnyObject} from '@loopback/repository';
+
+import {CronJobRepository} from '../../repositories';
+import {CronJobService} from '../../services';
+import {CronJob} from '../../models';
+
+describe('CronJob services', () => {
+ let cronJobService: any = null;
+ let cronJobRepository: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ cronJobRepository = createStubInstance(CronJobRepository);
+ cronJobService = new CronJobService(cronJobRepository);
+ });
+
+ it('getCronsLog: successfull', async () => {
+ cronJobRepository.stubs.find.resolves([mockCronJobs]);
+
+ const result = cronJobService
+ .getCronsLog()
+ .then((res: any) => res)
+ .catch((err: any) => err);
+ expect(result).deepEqual(mockCronJobsReturn);
+ });
+
+ it('createCronLog: successfull', async () => {
+ cronJobRepository.stubs.create.resolves(mockCronJobs);
+
+ const result = cronJobService
+ .createCronLog()
+ .then((res: any) => res)
+ .catch((err: any) => err);
+ expect(result).deepEqual(mockCronJobsReturn);
+ });
+
+ it('delete by id: successfull', async () => {
+ const deleteCronByID = cronJobRepository.stubs.deleteById.resolves();
+ await cronJobService.delCronLogById('123');
+ sinon.assert.calledOnce(deleteCronByID);
+ });
+
+ const mockCronJobs = new CronJob({
+ id: '12345',
+ type: 'cronType',
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+
+ const mockCronJobsReturn: Promise = new Promise(() => {
+ return [
+ {
+ id: '12345',
+ type: 'cronType',
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ },
+ ];
+ });
+});
diff --git a/api/src/__tests__/services/funder.service.test.ts b/api/src/__tests__/services/funder.service.test.ts
new file mode 100644
index 0000000..4711ea1
--- /dev/null
+++ b/api/src/__tests__/services/funder.service.test.ts
@@ -0,0 +1,84 @@
+import {
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+ expect,
+} from '@loopback/testlab';
+
+import {FunderService} from '../../services';
+import {Collectivity, Enterprise} from '../../models';
+import {CollectivityRepository, EnterpriseRepository} from '../../repositories';
+import {FUNDER_TYPE} from '../../utils';
+
+describe('Funder services', () => {
+ let collectivityRepository: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ funderService: FunderService;
+
+ beforeEach(() => {
+ collectivityRepository = createStubInstance(CollectivityRepository);
+ (enterpriseRepository = createStubInstance(EnterpriseRepository)),
+ (funderService = new FunderService(collectivityRepository, enterpriseRepository));
+ });
+
+ it('funderService: successfull', async () => {
+ collectivityRepository.stubs.find.resolves([mockCollectivity]);
+ enterpriseRepository.stubs.find.resolves([mockEnterprise]);
+ const result = await funderService.getFunders();
+
+ expect(result).to.deepEqual(mockReturnFunder);
+ });
+
+ it('funderService getFunderByName collectivity: successfull', async () => {
+ collectivityRepository.stubs.find.resolves([
+ new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ }),
+ ]);
+
+ const result = await funderService.getFunderByName('name', FUNDER_TYPE.collectivity);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ funderType: FUNDER_TYPE.collectivity,
+ });
+ });
+
+ it('funderService getFunderByName enterprises: successfull', async () => {
+ enterpriseRepository.stubs.find.resolves([
+ new Enterprise({
+ id: 'randomInputEnterpriseId',
+ name: 'nameenterprises',
+ }),
+ ]);
+ const result = await funderService.getFunderByName('name', FUNDER_TYPE.enterprise);
+
+ expect(result).to.deepEqual({
+ id: 'randomInputEnterpriseId',
+ name: 'nameenterprises',
+ funderType: FUNDER_TYPE.enterprise,
+ });
+ });
+
+ const mockCollectivity = new Collectivity({
+ id: 'randomInputIdCollectivity',
+ name: 'nameCollectivity',
+ citizensCount: 10,
+ mobilityBudget: 12,
+ });
+
+ const mockEnterprise = new Enterprise({
+ id: 'randomInputIdEnterprise',
+ emailFormat: ['test@outlook.com', 'test@outlook.fr', 'test@outlook.xxx'],
+ name: 'nameEnterprise',
+ siretNumber: 50,
+ employeesCount: 2345,
+ budgetAmount: 102,
+ });
+
+ const mockReturnFunder = [
+ {...mockCollectivity, funderType: FUNDER_TYPE.collectivity},
+ {...mockEnterprise, funderType: FUNDER_TYPE.enterprise},
+ ];
+});
diff --git a/api/src/__tests__/services/incentive.service.test.ts b/api/src/__tests__/services/incentive.service.test.ts
new file mode 100644
index 0000000..bbea3a8
--- /dev/null
+++ b/api/src/__tests__/services/incentive.service.test.ts
@@ -0,0 +1,57 @@
+import {IncentiveService} from '../../services';
+import {expect} from '@loopback/testlab';
+
+describe('Incentive services', () => {
+ let as: any = null;
+
+ beforeEach(() => {
+ as = new IncentiveService();
+ });
+
+ it('convertSpecificFields : successful', () => {
+ const specificFields = [
+ {title: 'un nombre', inputFormat: 'Numerique'},
+ {title: 'un text', inputFormat: 'Texte'},
+ {title: 'une date', inputFormat: 'Date'},
+ {
+ title: 'une liste de choix',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [{inputChoice: 'banane'}, {inputChoice: 'cerise'}],
+ },
+ },
+ ];
+ const incentiveTitle = '1';
+ const response = as.convertSpecificFields(incentiveTitle, specificFields);
+ // expect(response["$schema"]).to.equal("http://json-schema.org/draft-07/schema#");
+ // expect(response["$id"]).to.equal("http://yourdomain.com/schemas/myschema.json");
+ expect(response['title']).to.equal(incentiveTitle);
+ expect(response['type']).to.equal('object');
+ expect(response['required']).to.deepEqual([
+ 'un nombre',
+ 'un text',
+ 'une date',
+ 'une liste de choix',
+ ]);
+ expect(response['properties']).to.deepEqual({
+ 'un nombre': {type: 'number'},
+ 'un text': {type: 'string', minLength: 1},
+ 'une date': {type: 'string', format: 'date'},
+ 'une liste de choix': {
+ type: 'array',
+ maxItems: 2,
+ items: [{enum: ['banane', 'cerise']}],
+ },
+ });
+ });
+
+ it('convertSpecificFields : successful return empty object', () => {
+ const specificFields: any[] = [];
+ const incentiveTitle = '1';
+ const response = as.convertSpecificFields(incentiveTitle, specificFields);
+ // expect(response["$schema"]).to.equal("http://json-schema.org/draft-07/schema#");
+ // expect(response["$id"]).to.equal("http://yourdomain.com/schemas/myschema.json");
+ expect(response).to.deepEqual({});
+ });
+});
diff --git a/api/src/__tests__/services/jwt.service.test.ts b/api/src/__tests__/services/jwt.service.test.ts
new file mode 100644
index 0000000..7cf36b2
--- /dev/null
+++ b/api/src/__tests__/services/jwt.service.test.ts
@@ -0,0 +1,101 @@
+import {expect, sinon} from '@loopback/testlab';
+import {JwtService} from '../../services';
+import jwt from 'jsonwebtoken';
+
+import {ValidationError} from '../../validationError';
+import {StatusCode} from '../../utils';
+
+const expectedError = new ValidationError(
+ 'jwt.error.no.affiliation',
+ '/jwtNoAffiliation',
+ StatusCode.PreconditionFailed,
+);
+
+describe('jwt service', () => {
+ let jwtService: any = null;
+
+ beforeEach(() => {
+ jwtService = new JwtService();
+ });
+
+ it('should generateAffiliationAccessToken: OK', () => {
+ const signStub = sinon.stub(jwt, 'sign').returns(mockToken as any);
+ const token = jwtService.generateAffiliationAccessToken(mockCitizen);
+ expect(token).to.equal(mockToken);
+ signStub.restore();
+ });
+
+ it('should generateAffiliationAccessToken: KO for no affiliation', () => {
+ try {
+ jwtService.generateAffiliationAccessToken(mockCitizenWithoutAffiliation);
+ } catch (err: any) {
+ expect(err.message).to.equal(expectedError.message);
+ }
+ });
+
+ it('should verifyAffiliationAccessToken: OK true', () => {
+ const verifyStub = sinon.stub(jwt, 'verify').returns(mockVerifyResult as any);
+ const verifyResult = jwtService.verifyAffiliationAccessToken(mockToken);
+ expect(verifyResult).to.equal(true);
+ verifyStub.restore();
+ });
+
+ it('should verifyAffiliationAccessToken: OK false', () => {
+ const verifyStub = sinon.stub(jwt, 'verify').returns(mockVerifyResultKO as any);
+ const verifyResult = jwtService.verifyAffiliationAccessToken(mockToken);
+ expect(verifyResult).to.equal(false);
+ verifyStub.restore();
+ });
+
+ it('should verifyAffiliationAccessToken: KO jwt', () => {
+ try {
+ jwtService.verifyAffiliationAccessToken(mockToken);
+ } catch (err) {
+ expect(err).to.deepEqual(new Error('JsonWebTokenError: jwt malformed'));
+ }
+ });
+
+ it('should decodeAffiliationAccessToken: OK', () => {
+ const verifyStub = sinon.stub(jwt, 'verify').returns(mockVerifyResult as any);
+ const decodeResult = jwtService.decodeAffiliationAccessToken(mockToken);
+ expect(decodeResult).to.deepEqual(mockVerifyResult);
+ verifyStub.restore();
+ });
+
+ it('should decodeAffiliationAccessToken: KO jwt', () => {
+ try {
+ jwtService.decodeAffiliationAccessToken(mockToken);
+ } catch (err) {
+ expect(err).to.deepEqual(new Error('JsonWebTokenError: jwt malformed'));
+ }
+ });
+});
+
+const mockCitizen = {
+ id: 'randomInputId',
+ affiliation: {
+ enterpriseId: 'randomInputEnterpriseId',
+ },
+};
+
+const mockCitizenKO = {
+ id: 'randomInputId',
+ affiliation: {
+ enterpriseId: 'randomInputEnterpriseId',
+ },
+};
+
+const mockCitizenWithoutAffiliation = {
+ id: 'randomInputId',
+};
+
+const mockToken = 'token';
+
+const mockVerifyResult = {
+ id: 'randomInputId',
+ enterpriseId: 'randomInputEnterpriseId',
+};
+
+const mockVerifyResultKO = {
+ id: 'randomInputId',
+};
diff --git a/api/src/__tests__/services/keycloak.service.test.ts b/api/src/__tests__/services/keycloak.service.test.ts
new file mode 100644
index 0000000..5474936
--- /dev/null
+++ b/api/src/__tests__/services/keycloak.service.test.ts
@@ -0,0 +1,519 @@
+import {
+ createStubInstance,
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import {KeycloakService} from '../../services';
+import {ValidationError} from '../../validationError';
+import {GROUPS, StatusCode} from '../../utils';
+import {KeycloakGroupRepository} from '../../repositories';
+
+describe('keycloak services', () => {
+ let kc: any = null;
+ const errorMessageUser = new ValidationError('cannot connect to IDP or add user', '');
+
+ const errorMessageGroup = new ValidationError('cannot connect to IDP or add group', '');
+
+ let keycloakGroupRepository: StubbedInstanceWithSinonAccessor;
+
+ beforeEach(() => {
+ keycloakGroupRepository = createStubInstance(KeycloakGroupRepository);
+ kc = new KeycloakService(keycloakGroupRepository);
+ });
+
+ it('deleteUserKc : successful', async () => {
+ const message = 'user supprimé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'del').resolves(message);
+
+ const result = await kc.deleteUserKc('randomId');
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.del.restore();
+
+ expect(result).equal(message);
+ });
+
+ it('deleteUserKc fail : connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.deleteUserKc('randomId').catch((error: any) => {
+ expect(error.errors).to.equal(errorMessage);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('createUserKc Citizen: successful', async () => {
+ const message = 'user créé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'create').resolves(message);
+
+ const result = await kc.createUserKc({
+ email: 'test@gmail.com',
+ lastName: 'testLName',
+ firstName: 'testFName',
+ group: [GROUPS.citizens],
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.create.restore();
+
+ expect(result).equal(message);
+ });
+
+ it('createUserKc Funder: successful', async () => {
+ const message = 'user créé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'create').resolves(message);
+
+ const result = await kc.createUserKc({
+ email: 'test@gmail.com',
+ lastName: 'testLName',
+ firstName: 'testFName',
+ funderName: 'Funder',
+ group: ['collectivités'],
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.create.restore();
+
+ expect(result).equal(message);
+ });
+
+ it('createUserKc fail : email not unique', async () => {
+ const errorKc = new ValidationError('email.error.unique', '/email');
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon
+ .stub(kc.keycloakAdmin.users, 'create')
+ .rejects({response: {status: StatusCode.Conflict}});
+
+ await kc
+ .createUserKc({
+ email: 'test@gmail.com',
+ lastName: 'testLName',
+ firstName: 'testFName',
+ group: ['collectivités', 'financeurs'],
+ })
+ .catch((error: any) => {
+ expect(error.message).to.equal(errorKc.message);
+ });
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.create.restore();
+ });
+
+ it('createUserKc fail : password does not met policies', async () => {
+ const errorKc = new ValidationError('password.error.format', '/password');
+
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'create').rejects({
+ response: {
+ status: 400,
+ data: {errorMessage: 'Password policy not met'},
+ },
+ });
+
+ await kc
+ .createUserKc({
+ email: 'test@gmail.com',
+ lastName: 'testLName',
+ firstName: 'testFName',
+ group: ['collectivités'],
+ })
+ .catch((error: any) => {
+ expect(error.message).to.equal(errorKc.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.create.restore();
+ });
+
+ it('createUserKc fail : missing properties', async () => {
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'create').rejects('test');
+
+ await kc
+ .createUserKc({
+ email: 'test@gmail.com',
+ lastName: 'testLName',
+ firstName: 'testFName',
+ })
+ .catch((error: any) => {
+ expect(error.message).to.equal(errorMessageUser.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.create.restore();
+ });
+
+ it('createUserKc fail : connection fails', async () => {
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects('connexion échoue');
+
+ await kc
+ .createUserKc({
+ email: 'test@gmail.com',
+ lastName: 'testLName',
+ firstName: 'testFName',
+ group: ['collectivités'],
+ })
+ .catch((error: any) => {
+ expect(error.message).to.equal(errorMessageUser.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('createGroupKc fail: no top group found', async () => {
+ const errorKc = new ValidationError('collectivités.error.topgroup', '/collectivités');
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+ sinon.stub(kc.keycloakAdmin.groups, 'find').resolves([]);
+
+ await kc.createGroupKc('group', 'collectivités').catch((error: any) => {
+ expect(error.message).to.equal(errorKc.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.groups.find.restore();
+ });
+
+ it('createGroupKc fail : funder not unique', async () => {
+ const errorKc = new ValidationError(
+ 'collectivités.error.name.unique',
+ '/collectivités',
+ );
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+ sinon.stub(kc.keycloakAdmin.groups, 'find').resolves([{id: 'someWeirdId'}]);
+ sinon
+ .stub(kc.keycloakAdmin.groups, 'setOrCreateChild')
+ .rejects({response: {status: StatusCode.Conflict}});
+
+ await kc.createGroupKc('group', 'collectivités').catch((error: any) => {
+ expect(error.message).to.equal(errorKc.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.groups.find.restore();
+ kc.keycloakAdmin.groups.setOrCreateChild.restore();
+ });
+
+ it('createGroupKc : successful', async () => {
+ const messageSucces = 'funder créé';
+
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+ sinon.stub(kc.keycloakAdmin.groups, 'find').resolves([{id: 'someWeirdId'}]);
+ sinon.stub(kc.keycloakAdmin.groups, 'setOrCreateChild').resolves(messageSucces);
+
+ const result = await kc.createGroupKc('group', 'collectivités');
+ expect(result).to.equal(messageSucces);
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.groups.find.restore();
+ kc.keycloakAdmin.groups.setOrCreateChild.restore();
+ });
+
+ it('createGroupKc fail : connection fails', async () => {
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects('connexion échoue');
+
+ await kc.createGroupKc('group', 'collectivités').catch((error: any) => {
+ expect(error.message).to.equal(errorMessageGroup.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('deleteGroupKc : successful', async () => {
+ const message = 'groupe supprimé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.groups, 'del').resolves(message);
+
+ const result = await kc.deleteGroupKc('randomId');
+
+ expect(result).equal(message);
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.groups.del.restore();
+ });
+
+ it('deleteGroupKc fail : connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.deleteGroupKc('randomId').catch((error: any) => {
+ expect(error.errors).to.equal(errorMessage);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('sendExecuteActionsEmailUserKc fails : can not send email', async () => {
+ const message = 'email non envoyé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+ sinon.stub(kc.keycloakAdmin.users, 'executeActionsEmail').rejects(message);
+
+ await kc.sendExecuteActionsEmailUserKc('randomId').catch((error: any) => {
+ expect(error.errors).to.equal(message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.executeActionsEmail.restore();
+ });
+
+ it('sendExecuteActionsEmailUserKc : Successful', async () => {
+ const message = 'email envoyé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+ sinon.stub(kc.keycloakAdmin.users, 'executeActionsEmail').resolves(message);
+
+ const result = await kc.sendExecuteActionsEmailUserKc('randomId');
+
+ expect(result).equal(message);
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.executeActionsEmail.restore();
+ });
+
+ it('updateUser fail : connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc
+ .updateUser('randomId', {
+ firstName: 'firstName',
+ lastName: 'lastName',
+ })
+ .catch((error: any) => {
+ expect(error.message).to.equal('cannot connect to IDP or add user');
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('updateUser succes', async () => {
+ const messageSuccess = 'modification reussie';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+ sinon.stub(kc.keycloakAdmin.users, 'update').resolves(messageSuccess);
+ const result = await kc.updateUser('randomId', {
+ firstName: 'firstName',
+ lastName: 'lastName',
+ });
+ sinon.assert.calledWithExactly(
+ kc.keycloakAdmin.users.update,
+ {id: 'randomId'},
+ {firstName: 'firstName', lastName: 'lastName'},
+ );
+ expect(result).to.equal(messageSuccess);
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.update.restore();
+ });
+
+ it('sendExecuteActionsEmailUserKc fail : connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.sendExecuteActionsEmailUserKc('randomId').catch((error: any) => {
+ expect(error.errors).to.equal(errorMessage);
+ });
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('updateUserGroupsKc success :', async () => {
+ const groups = [
+ {id: '123', name: 'superviseurs'},
+ {id: '345', name: 'gestionnaires'},
+ ];
+ keycloakGroupRepository.stubs.getSubGroupFunder.resolves(groups);
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'delFromGroup').resolves('groupe supprimé');
+ sinon.stub(kc.keycloakAdmin.users, 'addToGroup').resolves('groupe ajouté');
+
+ await kc.updateUserGroupsKc('id', ['superviseurs']);
+ sinon.assert.calledWithExactly(kc.keycloakAdmin.users.delFromGroup, {
+ id: 'id',
+ groupId: '123',
+ });
+ sinon.assert.calledWithExactly(kc.keycloakAdmin.users.delFromGroup, {
+ id: 'id',
+ groupId: '345',
+ });
+ sinon.assert.calledWithExactly(kc.keycloakAdmin.users.addToGroup, {
+ id: 'id',
+ groupId: '123',
+ });
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.delFromGroup.restore();
+ kc.keycloakAdmin.users.addToGroup.restore();
+ });
+
+ it('updateUserGroupsKc fail : connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ const groups = [
+ {id: '123', name: 'superviseurs'},
+ {id: '345', name: 'gestionnaires'},
+ ];
+ keycloakGroupRepository.stubs.getSubGroupFunder.resolves(groups);
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.updateUserGroupsKc('randomId', ['superviseurs']).catch((error: any) => {
+ expect(error.message).to.equal('cannot connect to IDP or add user');
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('disableUserKc : successful', async () => {
+ const message = 'compte désactivé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'update').resolves(message);
+
+ const result = await kc.disableUserKc('randomId');
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.update.restore();
+
+ expect(result).equal(message);
+ });
+
+ it('disableUserKc fail : connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.disableUserKc('randomId').catch((error: any) => {
+ expect(error.message).to.equal(errorMessageUser.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('listConsents : successful', async () => {
+ const consentList = [
+ {
+ clientName: 'simulation maas client',
+ theClientId: 'simulation-maas-client',
+ },
+ {
+ clientName: 'mulhouse maas client',
+ theClientId: 'mulhouse-maas-client',
+ },
+ {
+ clientName: 'paris maas client',
+ theClientId: 'paris-maas-client',
+ },
+ ];
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'listConsents').resolves(consentList);
+
+ const result = await kc.listConsents('randomId');
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.listConsents.restore();
+
+ expect(result).equal(consentList);
+ });
+
+ it('listConsents fail: connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.listConsents('randomId').catch((error: any) => {
+ expect(error.errors).to.equal(errorMessage);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('deleteConsent : successful', async () => {
+ const message = 'Grant revoked successfully';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'revokeConsent').resolves(message);
+ const result = await kc.deleteConsent('randomId');
+
+ expect(result).to.equal(message);
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.revokeConsent.restore();
+ });
+
+ it('deleteConsent fail: connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.deleteConsent('randomId', 'simulation-maas-client').catch((error: any) => {
+ expect(error.errors).to.equal(errorMessage);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('listUsers : successful', async () => {
+ const MockUsers = [
+ {
+ username: 'bob@capgemini.com',
+ emailVerified: false,
+ firstName: 'bob',
+ lastName: 'l’éponge',
+ email: 'bob@capgemini.com',
+ },
+ {
+ username: 'bob1@capgemini.com',
+ emailVerified: false,
+ firstName: 'bob1',
+ lastName: 'l’éponge1',
+ email: 'bob1@capgemini.com',
+ },
+ ];
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+
+ sinon.stub(kc.keycloakAdmin.users, 'find').resolves(MockUsers);
+ const result = await kc.listUsers();
+
+ expect(result).to.equal(MockUsers);
+
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.find.restore();
+ });
+
+ it('listUsers fail: connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.listUsers().catch((error: any) => {
+ expect(error.errors).to.equal(errorMessage);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+
+ it('getUser : successful', async () => {
+ const message = 'user renvoyé';
+ sinon.stub(kc.keycloakAdmin, 'auth').resolves('connexion réussie');
+ sinon.stub(kc.keycloakAdmin.users, 'findOne').resolves(message);
+
+ const result = await kc.getUser('randomId');
+ kc.keycloakAdmin.auth.restore();
+ kc.keycloakAdmin.users.findOne.restore();
+
+ expect(result).equal(message);
+ });
+
+ it('getUser fails : connection fails', async () => {
+ const errorMessage = 'connexion échoue';
+ sinon.stub(kc.keycloakAdmin, 'auth').rejects(errorMessage);
+
+ await kc.getUser('randomId').catch((error: any) => {
+ expect(error.message).to.equal(errorMessageUser.message);
+ });
+
+ kc.keycloakAdmin.auth.restore();
+ });
+});
diff --git a/api/src/__tests__/services/maas.authorizor.test.ts b/api/src/__tests__/services/maas.authorizor.test.ts
new file mode 100644
index 0000000..90bd57f
--- /dev/null
+++ b/api/src/__tests__/services/maas.authorizor.test.ts
@@ -0,0 +1,71 @@
+import {expect} from '@loopback/testlab';
+import {AuthorizationContext, AuthorizationDecision} from '@loopback/authorization';
+import {securityId} from '@loopback/security';
+
+import {checkMaas} from '../../services';
+import {Roles} from '../../utils';
+
+describe('maas authorizor', () => {
+ it('should checkMaas: OK ALLOW', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ clientName: 'maas',
+ membership: ['membership'],
+ roles: ['roles'],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: null as any,
+ };
+ const result = await checkMaas(authContext, null as any);
+ expect(result).to.deepEqual(AuthorizationDecision.ALLOW);
+ });
+
+ it('should checkMaas: OK DENY maas backend', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ clientName: undefined,
+ membership: ['membership'],
+ roles: [Roles.MAAS_BACKEND],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: null as any,
+ };
+ const result = await checkMaas(authContext, null as any);
+ expect(result).to.deepEqual(AuthorizationDecision.DENY);
+ });
+
+ it('should checkMaas: OK DENY maas', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ clientName: undefined,
+ membership: ['membership'],
+ roles: [Roles.MAAS],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: null as any,
+ };
+ const result = await checkMaas(authContext, null as any);
+ expect(result).to.deepEqual(AuthorizationDecision.DENY);
+ });
+});
diff --git a/api/src/__tests__/services/mail.service.test.ts b/api/src/__tests__/services/mail.service.test.ts
new file mode 100644
index 0000000..e70fcfc
--- /dev/null
+++ b/api/src/__tests__/services/mail.service.test.ts
@@ -0,0 +1,40 @@
+import {expect, sinon} from '@loopback/testlab';
+import {MailService} from '../../services';
+import ejs from 'ejs';
+import nodemailer from 'nodemailer';
+import {MailConfig} from '../../config';
+
+describe('mail service', () => {
+ let mailService: any = null;
+ let mailConfig: any = null;
+
+ beforeEach(() => {
+ mailService = new MailService();
+ mailConfig = new MailConfig();
+ });
+
+ const transport: any = {
+ sendMail: () => {
+ const err = new Error('No recipients defined');
+ return Promise.reject(err);
+ },
+ };
+
+ it('sendMailAsHtml should return error', async () => {
+ const ejsStub = sinon.stub(ejs, 'renderFile').resolves('mymailtemplate');
+ const nodemailerStub = sinon.stub(nodemailer, 'createTransport').returns(transport);
+ const mailer = {
+ mailer: nodemailerStub,
+ from: 'test@gmal.com',
+ };
+ const mailerInfos = sinon.stub(mailConfig, 'configMailer').returns(mailer);
+
+ try {
+ await mailService.sendMailAsHtml('', '', '', '');
+ } catch (error) {
+ expect(error.message).to.equal('email.server.error');
+ }
+ ejsStub.restore();
+ mailerInfos.restore();
+ });
+});
diff --git a/api/src/__tests__/services/parentProcess.service.test.ts b/api/src/__tests__/services/parentProcess.service.test.ts
new file mode 100644
index 0000000..22c7fae
--- /dev/null
+++ b/api/src/__tests__/services/parentProcess.service.test.ts
@@ -0,0 +1,78 @@
+import {expect} from '@loopback/testlab';
+
+import sinon from 'sinon';
+import path from 'path';
+import child_process from 'child_process';
+
+import {ParentProcessService} from '../../services';
+import {EventEmitter} from 'events';
+import {EVENT_MESSAGE, IMessage} from '../../utils';
+
+describe('ParentProcessService', () => {
+ let pathStub: any = null;
+ let forkStub: any = null;
+
+ beforeEach(() => {
+ const cp = new EventEmitter();
+ pathStub = sinon.stub(path, 'join').returns('file.js');
+ forkStub = sinon.stub(child_process, 'fork').returns(cp);
+ });
+
+ afterEach(() => {
+ pathStub.restore();
+ forkStub.restore();
+ });
+
+ it('ParentProcessService child process : error', async () => {
+ try {
+ const parentProcessService: any = new ParentProcessService();
+ parentProcessService.child.emit('error', 'error');
+ } catch (err) {
+ expect(err.message).to.equal('A problem occurred');
+ }
+ });
+
+ it('ParentProcessService child process : close', async () => {
+ try {
+ const parentProcessService: any = new ParentProcessService();
+ parentProcessService.child.emit('close', 0);
+ } catch (err) {
+ expect(err.message).to.equal('A problem occurred');
+ }
+ });
+
+ it('ParentProcessService child process : message', async () => {
+ const message: IMessage = {type: EVENT_MESSAGE.READY};
+ const parentProcessService: any = new ParentProcessService();
+ sinon.spy(parentProcessService, 'emit');
+ parentProcessService.child.emit('message', message);
+ sinon.assert.calledWithExactly(
+ parentProcessService.emit,
+ EVENT_MESSAGE.READY,
+ undefined,
+ );
+ });
+
+ it('ParentProcessService fork child process : error', async () => {
+ forkStub.restore();
+ forkStub = sinon.stub(child_process, 'fork').rejects();
+ try {
+ new ParentProcessService();
+ } catch (err) {
+ expect(err.message).to.equal('A problem occurred');
+ }
+ });
+
+ it('ParentProcessService send message to child : success', async () => {
+ forkStub.restore();
+ const message: IMessage = {type: EVENT_MESSAGE.UPDATE, data: ['test']};
+ const cpStub = Object.assign({
+ send: sinon.stub().returns(true),
+ on: sinon.stub().returnsThis(),
+ });
+ forkStub = sinon.stub(child_process, 'fork').returns(cpStub);
+ const parentProcessService: any = new ParentProcessService();
+ parentProcessService.sendMessageToChild(message);
+ sinon.assert.calledWithExactly(cpStub.send, message);
+ });
+});
diff --git a/api/src/__tests__/services/rabbitmq.service.test.ts b/api/src/__tests__/services/rabbitmq.service.test.ts
new file mode 100644
index 0000000..64424c7
--- /dev/null
+++ b/api/src/__tests__/services/rabbitmq.service.test.ts
@@ -0,0 +1,324 @@
+import {
+ createStubInstance,
+ expect,
+ StubbedInstanceWithSinonAccessor,
+} from '@loopback/testlab';
+
+import amqp, {Channel} from 'amqplib';
+import KeycloakAdminClient from 'keycloak-admin';
+import sinon from 'sinon';
+import {EventEmitter} from 'stream';
+
+import {Enterprise} from '../../models';
+import {EnterpriseRepository} from '../../repositories';
+import {RabbitmqService, SubscriptionService} from '../../services';
+import {KeycloakService} from '../../services/keycloak.service';
+import {EVENT_MESSAGE, ISubscriptionPublishPayload} from '../../utils';
+
+describe('Rabbitmq service', () => {
+ let rabbitmqService: any = null;
+ let amqpTest: any = null;
+ let kcAdminAuth: any = null;
+ let spy: any,
+ subscriptionService: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ keycloakService: StubbedInstanceWithSinonAccessor,
+ parentProcessService: any;
+
+ beforeEach(() => {
+ subscriptionService = createStubInstance(SubscriptionService);
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ keycloakService = createStubInstance(KeycloakService);
+
+ keycloakService.keycloakAdmin = new KeycloakAdminClient();
+ keycloakService.keycloakAdmin.accessToken = 'test';
+
+ parentProcessService = new EventEmitter();
+
+ rabbitmqService = new RabbitmqService(
+ subscriptionService,
+ enterpriseRepository,
+ parentProcessService,
+ keycloakService,
+ );
+ spy = sinon.spy(rabbitmqService);
+ });
+
+ it('RabbitMQService event EVENT_MESSAGE.CONSUME : error', async () => {
+ sinon.spy(parentProcessService, 'on');
+ const rabbitStub = sinon.stub(RabbitmqService.prototype, 'consumeMessage').resolves();
+ parentProcessService.emit(EVENT_MESSAGE.CONSUME, {test: 'test'});
+ sinon.assert.calledOnceWithExactly(rabbitmqService.consumeMessage, {test: 'test'});
+ rabbitStub.restore();
+ });
+
+ it('RabbitMQService getHRISEnterpriseNameList : error', async () => {
+ try {
+ enterpriseRepository.stubs.getHRISEnterpriseNameList.rejects();
+ await rabbitmqService.getHRISEnterpriseNameList();
+ } catch (error) {
+ expect(error.message).to.equal('rabbitmq error getting HRIS enterprises');
+ enterpriseRepository.stubs.getHRISEnterpriseNameList.restore();
+ }
+ });
+
+ it('RabbitMQService getHRISEnterpriseNameList : success', async () => {
+ const enterprise: Pick = new Enterprise({
+ name: 'enterpriseName',
+ });
+ const enterpriseRepositoryList: Pick[] = [enterprise];
+ const enterpriseRepositoryResult: string[] = [enterprise.name.toLowerCase()];
+ enterpriseRepository.stubs.getHRISEnterpriseNameList.resolves(
+ enterpriseRepositoryList,
+ );
+ const enterpriseNameList: string[] =
+ await rabbitmqService.getHRISEnterpriseNameList();
+ expect(enterpriseNameList).to.deepEqual(enterpriseRepositoryResult);
+ enterpriseRepository.stubs.getHRISEnterpriseNameList.restore();
+ });
+
+ it('RabbitMQService connect : error', async () => {
+ try {
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ amqpTest = sinon.stub(amqp, 'connect').rejects('err');
+ await rabbitmqService.connect();
+ } catch (error) {
+ expect(error.message).to.equal('rabbitmq init connection error');
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ }
+ });
+
+ it('RabbitMQService connect : success', async () => {
+ const connection: any = {
+ createChannel: () => {},
+ };
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ await rabbitmqService.connect();
+ expect(rabbitmqService.connection).to.deepEqual(connection);
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ });
+
+ it('RabbitMQService disconnect : error', async () => {
+ try {
+ const connection: any = {
+ close: () => {
+ const err = new Error();
+ return Promise.reject(err);
+ },
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ await rabbitmqService.disconnect();
+ } catch (error) {
+ expect(error.message).to.equal('rabbitmq disconnect error');
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ }
+ });
+
+ it('RabbitMQService disconnect : success', async () => {
+ const connection: any = {
+ close: () => {},
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ await rabbitmqService.disconnect();
+ expect(rabbitmqService.connection).to.deepEqual(connection);
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ });
+
+ it('RabbitMQService open channel : error', async () => {
+ try {
+ const connection: any = {
+ createChannel: () => {
+ const err = new Error();
+ return Promise.reject(err);
+ },
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ await rabbitmqService.openConnectionChannel();
+ } catch (error) {
+ expect(error.message).to.equal(`rabbitmq connect to channel error`);
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ }
+ });
+
+ it('RabbitMQService open channel : successful', async () => {
+ const channel: Channel = Object.assign({name: 'channel'});
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ const connectionOpen = await rabbitmqService.openConnectionChannel();
+ expect(connectionOpen).to.deepEqual(channel);
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ });
+
+ it('RabbitMQService close channel : error', async () => {
+ try {
+ const channel: Channel = Object.assign({
+ close: () => {
+ const err = new Error();
+ return Promise.reject(err);
+ },
+ });
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ await rabbitmqService.openConnectionChannel();
+ await rabbitmqService.closeConnectionChannel(channel);
+ } catch (error) {
+ expect(error.message).to.equal('rabbitmq close channel error');
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ }
+ });
+
+ it('RabbitMQService close channel : success', async () => {
+ const channel: Channel = Object.assign({
+ close: () => {},
+ });
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ await rabbitmqService.openConnectionChannel();
+ await rabbitmqService.closeConnectionChannel(channel);
+ expect(rabbitmqService.connection).to.deepEqual(connection);
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ });
+
+ it('RabbitMQService publishMessage : error', async () => {
+ try {
+ const connection: any = {
+ createChannel: () => {
+ const err = new Error();
+ return Promise.reject(err);
+ },
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.publishMessage('test');
+ } catch (error) {
+ expect(error.message).to.equal('rabbitmq publish message error');
+ expect(spy.openConnectionChannel.calledOnce).true();
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ }
+ });
+
+ it('RabbitMQService publishMessage : success', async () => {
+ const channel: any = {
+ publish: () => {
+ return true;
+ },
+ close: () => {},
+ };
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ close: () => {},
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ await rabbitmqService.publishMessage('test', 'enterpriseName');
+ expect(spy.openConnectionChannel.calledOnce).true();
+ expect(spy.closeConnectionChannel.calledOnce).true();
+ expect(spy.disconnect.calledOnce).true();
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ });
+
+ it('RabbitMQService consume : error', async () => {
+ try {
+ const buf = JSON.stringify(Buffer.from('test').toString());
+ const message = {
+ content: buf,
+ };
+ subscriptionService.stubs.handleMessage.rejects();
+ await rabbitmqService.consumeMessage(message);
+ } catch (error) {
+ expect(error.message).to.equal('rabbitmq consume message error');
+ subscriptionService.stubs.handleMessage.restore();
+ }
+ });
+
+ it('RabbitMQService consume : success', async () => {
+ sinon.spy(parentProcessService, 'emit');
+ const buf = JSON.stringify(Buffer.from('test').toString());
+ const message = {
+ content: buf,
+ };
+ subscriptionService.stubs.handleMessage.resolves();
+ await rabbitmqService.consumeMessage(message);
+ sinon.assert.calledWithExactly(parentProcessService.emit, EVENT_MESSAGE.ACK, {
+ type: EVENT_MESSAGE.ACK,
+ data: {content: '"test"'},
+ });
+ subscriptionService.stubs.handleMessage.restore();
+ });
+
+ it('RabbitMQService consume inside the catch 412: fail', async () => {
+ try {
+ const message = {
+ content: '{"citizenId": "id","subscriptionId": "id"}',
+ };
+ const err = {
+ statusCode: 412,
+ };
+ const channel: any = {
+ publish: () => {
+ return true;
+ },
+ close: () => {},
+ };
+ const connection: any = {
+ createChannel: () => {
+ return channel;
+ },
+ close: () => {},
+ };
+ amqpTest = sinon.stub(amqp, 'connect').resolves(connection);
+ kcAdminAuth = sinon.stub(keycloakService.keycloakAdmin, 'auth').resolves();
+ await rabbitmqService.connect();
+ subscriptionService.stubs.handleMessage.rejects(err);
+ subscriptionService.stubs.getSubscriptionPayload.resolves({
+ subscription: {lastName: 'tst'} as ISubscriptionPublishPayload,
+ enterprise: 'test',
+ });
+ await rabbitmqService.consumeMessage(message);
+ } catch (err) {
+ expect(err.message).to.equal('rabbitmq consume message error');
+ subscriptionService.stubs.handleMessage.restore();
+ subscriptionService.stubs.getSubscriptionPayload.restore();
+ amqpTest.restore();
+ kcAdminAuth.restore();
+ }
+ });
+});
diff --git a/api/src/__tests__/services/s3.service.test.ts b/api/src/__tests__/services/s3.service.test.ts
new file mode 100644
index 0000000..3bce0a7
--- /dev/null
+++ b/api/src/__tests__/services/s3.service.test.ts
@@ -0,0 +1,293 @@
+/* eslint-disable max-len */
+import {S3Service} from '../../services';
+import {expect, sinon} from '@loopback/testlab';
+import {S3Client} from '@aws-sdk/client-s3';
+import {Readable} from 'stream';
+import {Express} from 'express';
+import {ValidationError} from '../../validationError';
+import {StatusCode} from '../../utils';
+
+describe('S3Service ', async () => {
+ let s3: S3Service;
+ const deleteError: Error = new Error('Could not delete folder from S3');
+ const downloadError: Error = new Error('Filename does not exist');
+ const errorMessage = 'Error';
+ const errorRejectMessage: Error = new Error('An error occurred: Error');
+
+ const fileList: any[] = [
+ {
+ originalname: 'test1.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ {
+ originalname: 'test2.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ ];
+
+ const file = {
+ originalname: 'test1.txt',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'image/png',
+ fieldname: 'test',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ };
+
+ beforeEach(() => {
+ s3 = new S3Service();
+ });
+
+ it('uploadFileListIntoBucket(bucketName, fileDirectory, fileList) success', async () => {
+ const checkBucketExistsStub = sinon.stub(s3, 'checkBucketExists').resolves(true);
+ const uploadFileListIntoBucketStub = sinon
+ .stub(s3, 'uploadFile')
+ .resolves(['test-directory/test1.txt', 'test-directory/test2.txt']);
+
+ const result = await s3.uploadFileListIntoBucket(
+ 'test-bucket',
+ 'test-directory',
+ fileList,
+ );
+ expect(result).to.deepEqual(['test-directory/test1.txt', 'test-directory/test2.txt']);
+ uploadFileListIntoBucketStub.restore();
+ checkBucketExistsStub.restore();
+ });
+
+ it('uploadFileListIntoBucket(bucketName, fileDirectory, fileList) bucket not created', async () => {
+ const checkBucketExistsStub = sinon.stub(s3, 'checkBucketExists').resolves(false);
+ const createBucketStub = sinon
+ .stub(s3, 'createBucket')
+ .resolves(mockCreateBucketResponse);
+ const uploadFileListIntoBucketStub = sinon
+ .stub(s3, 'uploadFile')
+ .resolves(['test-directory/test1.txt', 'test-directory/test2.txt']);
+ const result = await s3.uploadFileListIntoBucket(
+ 'test-bucket',
+ 'test-directory',
+ fileList,
+ );
+ expect(result).to.deepEqual(['test-directory/test1.txt', 'test-directory/test2.txt']);
+ createBucketStub.restore();
+ uploadFileListIntoBucketStub.restore();
+ checkBucketExistsStub.restore();
+ });
+
+ it('uploadFileListIntoBucket(bucketName, fileDirectory, fileList) error', async () => {
+ const checkBucketExistsStub = sinon.stub(S3Client.prototype, 'send').resolves(true);
+ const uploadFileListIntoBucketStubError = sinon
+ .stub(s3, 'uploadFile')
+ .rejects(errorMessage);
+ try {
+ await s3.uploadFileListIntoBucket('test-bucket', 'test-directory', fileList);
+ } catch (error) {
+ expect(error).to.deepEqual(errorRejectMessage);
+ uploadFileListIntoBucketStubError.restore();
+ checkBucketExistsStub.restore();
+ }
+ });
+
+ it('checkBucketExists(bucketName) success', async () => {
+ const checkBucketExistsStub = sinon.stub(S3Client.prototype, 'send').resolves(true);
+ const result = await s3.checkBucketExists('test-bucket');
+ expect(result).to.equal(true);
+ checkBucketExistsStub.restore();
+ });
+
+ it('checkBucketExists(bucketName) error', async () => {
+ const checkBucketExistsStub = sinon
+ .stub(S3Client.prototype, 'send')
+ .rejects(errorMessage);
+ const result = await s3.checkBucketExists('test-bucket');
+ expect(result).to.equal(false);
+ checkBucketExistsStub.restore();
+ });
+
+ it('createBucket(bucketName) success', async () => {
+ const createBucketStub = sinon
+ .stub(S3Client.prototype, 'send')
+ .resolves(mockCreateBucketResponse);
+ const result = await s3.createBucket('test-bucket');
+ expect(result).to.deepEqual(mockCreateBucketResponse);
+ createBucketStub.restore();
+ });
+
+ it('createBucket(bucketName) error', async () => {
+ const createBucketStub = sinon.stub(S3Client.prototype, 'send').rejects(errorMessage);
+ try {
+ await s3.createBucket('test-bucket');
+ } catch (error) {
+ expect(error).to.deepEqual(errorRejectMessage);
+ createBucketStub.restore();
+ }
+ });
+
+ it('uploadFile(bucketName, filePath, file) success', async () => {
+ const uploadFileStub = sinon
+ .stub(S3Client.prototype, 'send')
+ .resolves(mockUploadFileResponse);
+ const result = await s3.uploadFile(
+ 'test-bucket',
+ 'test2.txt',
+ Buffer.from(
+ 'UEsDBBQABgAIAAAAIQBSlIoMAQIAADUQAAATAAgCW0NvbnRlbnRfVHlwZXNdLnhtbCCiBAIooAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMl99u0zAUxu',
+ ),
+ );
+ expect(result).to.deepEqual(mockUploadFileResponse);
+ uploadFileStub.restore();
+ });
+
+ it('uploadFile(bucketName, filePath, file) error', async () => {
+ const uploadFileStub = sinon.stub(S3Client.prototype, 'send').rejects(errorMessage);
+ try {
+ await s3.uploadFile('test-bucket', 'hello.txt', Buffer.from('test de buffer'));
+ } catch (error) {
+ expect(error).to.deepEqual(errorRejectMessage);
+ uploadFileStub.restore();
+ }
+ });
+
+ it('downloadFiles(bucket, fileDirectory, file) success', async () => {
+ const downloadStub = sinon
+ .stub(s3, 'downloadFileBuffer')
+ .returns(mockDownloadFileResponse as any);
+
+ const downloadResult = await s3.downloadFileBuffer(
+ 'testr-bucket',
+ 'testo-directory',
+ 'test4.txt',
+ );
+ expect(downloadResult).to.equal(mockDownloadFileResponse);
+ downloadStub.restore();
+ });
+
+ it('downloadFiles(bucket, fileDirectory, file) error', async () => {
+ const downloadFileStub = sinon.stub(S3Client.prototype, 'send').rejects(errorMessage);
+ const expectedError = new ValidationError(
+ 'Filename does not exist',
+ '/attachments',
+ StatusCode.NotFound,
+ );
+ try {
+ await s3.downloadFileBuffer('test-bucket', 'test-directory', 'test1.txt');
+ } catch (err: any) {
+ expect(err).to.deepEqual(expectedError);
+ downloadFileStub.restore();
+ }
+ });
+
+ it('deleteFolder(bucketName, filePath) success', async () => {
+ const deleteFolderStub = sinon
+ .stub(S3Client.prototype, 'send')
+ .resolves(mockUploadFileResponse);
+ const result = await s3.deleteObjectFile('test-bucket', 'test-directory');
+ expect(result).to.deepEqual(mockUploadFileResponse);
+ deleteFolderStub.restore();
+ });
+
+ it('deleteFolder(bucketName, filePath) error', async () => {
+ const deleteFolderStub = sinon.stub(S3Client.prototype, 'send').rejects(deleteError);
+ try {
+ await s3.deleteObjectFile('test-bucke2t', 'test-directory2');
+ } catch (error) {
+ expect(error).to.deepEqual(deleteError);
+ deleteFolderStub.restore();
+ }
+ });
+
+ it('hasCorrectNumberOfFiles(fileList) success return true', () => {
+ const hasCorrectNumberOfFiles = s3.hasCorrectNumberOfFiles(fileList);
+ expect(hasCorrectNumberOfFiles).to.equal(true);
+ });
+
+ it('hasCorrectNumberOfFiles(fileList) success return false', () => {
+ const fileListNbExceeded = [
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ file,
+ ];
+ const hasCorrectNumberOfFiles = s3.hasCorrectNumberOfFiles(fileListNbExceeded);
+ expect(hasCorrectNumberOfFiles).to.equal(false);
+ });
+
+ it('hasValidMimeType(fileList) success return true', () => {
+ const hasValidMimeType = s3.hasValidMimeType(fileList);
+ expect(hasValidMimeType).to.equal(true);
+ });
+
+ it('hasValidMimeType(fileList) success return false', () => {
+ const originalMimeType = fileList[0].mimetype;
+ fileList[0].mimetype = 'image/gif';
+ const hasValidMimeType = s3.hasValidMimeType(fileList);
+ expect(hasValidMimeType).to.equal(false);
+ fileList[0].mimetype = originalMimeType;
+ });
+
+ it('hasValidFileSize(fileList) success return true', () => {
+ const hasValidFileSize = s3.hasValidFileSize(fileList);
+ expect(hasValidFileSize).to.equal(true);
+ });
+
+ it('hasValidFileSize(fileList) success return false', () => {
+ const originalFileSize = fileList[0].size;
+ fileList[0].size = 80000000;
+ const hasValidFileSize = s3.hasValidFileSize(fileList);
+ expect(hasValidFileSize).to.equal(false);
+ fileList[0].size = originalFileSize;
+ });
+
+ const mockCreateBucketResponse = {
+ $metadata: {
+ httpStatusCode: 200,
+ attempts: 1,
+ totalRetryDelay: 0,
+ },
+ Location: '/test-bucket',
+ };
+
+ const mockUploadFileResponse = {
+ $metadata: {
+ httpStatusCode: 200,
+ attempts: 1,
+ totalRetryDelay: 0,
+ },
+ ETag: '"etag"',
+ };
+
+ const mockDownloadFileResponse = {
+ $metadata: {
+ httpStatusCode: 200,
+ attempts: 1,
+ totalRetryDelay: 0,
+ },
+ ETag: '"etag"',
+ };
+});
diff --git a/api/src/__tests__/services/subscription.service.test.ts b/api/src/__tests__/services/subscription.service.test.ts
new file mode 100644
index 0000000..301664b
--- /dev/null
+++ b/api/src/__tests__/services/subscription.service.test.ts
@@ -0,0 +1,980 @@
+import {
+ expect,
+ sinon,
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+} from '@loopback/testlab';
+import {AnyObject} from '@loopback/repository';
+import {Express} from 'express';
+
+import {SubscriptionService, MailService, S3Service} from '../../services';
+import {
+ CitizenRepository,
+ CommunityRepository,
+ EnterpriseRepository,
+ SubscriptionRepository,
+} from '../../repositories';
+import {
+ Subscription,
+ AttachmentType,
+ Enterprise,
+ Community,
+ Citizen,
+ Affiliation,
+} from '../../models';
+import {ValidationError} from '../../validationError';
+import {
+ INCENTIVE_TYPE,
+ ISubscriptionBusError,
+ REJECTION_REASON,
+ SEND_MODE,
+ StatusCode,
+ SUBSCRIPTION_STATUS,
+} from '../../utils';
+import {Readable} from 'stream';
+import {
+ ValidationMultiplePayment,
+ ValidationNoPayment,
+ ValidationSinglePayment,
+} from '../../models/subscription/subscriptionValidation.model';
+import {NoReason} from '../../models/subscription/subscriptionRejection.model';
+
+const expectedErrorPayment = new ValidationError(
+ 'is not allowed to have the additional property "frequency"',
+ '/subscriptionBadPayment',
+ StatusCode.PreconditionFailed,
+);
+
+const expectedErrorPayment2 = new ValidationError(
+ 'is not allowed to have the additional property "lastPayment"',
+ '/subscriptionBadPayment',
+ StatusCode.PreconditionFailed,
+);
+
+const expectedErrorBuffer = new ValidationError(
+ 'subscriptions.error.bad.buffer',
+ '/subscriptionBadBuffer',
+ StatusCode.PreconditionFailed,
+);
+
+describe('Subscriptions service', () => {
+ let subscriptionService: any = null;
+ let subscriptionRepository: StubbedInstanceWithSinonAccessor,
+ citizenRepository: StubbedInstanceWithSinonAccessor,
+ s3Service: StubbedInstanceWithSinonAccessor,
+ enterpriseRepository: StubbedInstanceWithSinonAccessor,
+ communityRepository: StubbedInstanceWithSinonAccessor;
+
+ let mailService: any = null;
+
+ beforeEach(() => {
+ subscriptionRepository = createStubInstance(SubscriptionRepository);
+ enterpriseRepository = createStubInstance(EnterpriseRepository);
+ citizenRepository = createStubInstance(CitizenRepository);
+ communityRepository = createStubInstance(CommunityRepository);
+ s3Service = createStubInstance(S3Service);
+ subscriptionService = new SubscriptionService(
+ s3Service,
+ subscriptionRepository,
+ citizenRepository,
+ mailService,
+ communityRepository,
+ enterpriseRepository,
+ );
+ mailService = createStubInstance(MailService);
+ });
+
+ it('check versement : aucun versement', () => {
+ const payment = {
+ mode: 'aucun',
+ } as ValidationNoPayment;
+
+ try {
+ subscriptionService.checkPayment(payment);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check versement : versement unique ok', () => {
+ const payment = {
+ mode: 'unique',
+ } as ValidationSinglePayment;
+
+ // Absence du montant
+ try {
+ const result = subscriptionService.checkPayment(payment);
+ expect(result).deepEqual(payment);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check versement : versement unique avec des informations du versement multiple', () => {
+ // Presence de la frequency
+ const payment = {
+ mode: 'unique',
+ frequency: undefined,
+ };
+
+ try {
+ subscriptionService.checkPayment(payment);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal(expectedErrorPayment.message);
+ }
+
+ // Presence de la date
+ const paymentSecond = {
+ mode: 'unique',
+ lastPayment: '2021-01-01',
+ };
+
+ try {
+ subscriptionService.checkPayment(paymentSecond);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal(expectedErrorPayment2.message);
+ }
+ });
+
+ it('check versement : versement multiple ok', () => {
+ try {
+ const payment = {
+ mode: 'multiple',
+ lastPayment: '2025-01-01',
+ frequency: 'mensuelle',
+ amount: 1,
+ } as ValidationMultiplePayment;
+ const result = subscriptionService.checkPayment(payment);
+ expect(result).deepEqual(payment);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check versement : versement multiple - absence information', () => {
+ const payment = {
+ mode: 'multiple',
+ } as ValidationMultiplePayment;
+
+ try {
+ subscriptionService.checkPayment(payment);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal('requires property "frequency"');
+ }
+
+ payment.lastPayment = '2025-01-01';
+ try {
+ subscriptionService.checkPayment(payment);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal('requires property "frequency"');
+ }
+
+ payment.frequency = 'mensuelle';
+ payment.amount = 1;
+
+ try {
+ const result = subscriptionService.checkPayment(payment);
+ expect(result).deepEqual(payment);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check versement : versement multiple avec des informations du versement unique', () => {
+ try {
+ const payment = {
+ mode: 'multiple',
+ lastPayment: '2021-01-01',
+ frequency: 'mensuelle',
+ amount: 3,
+ } as ValidationMultiplePayment;
+ subscriptionService.checkPayment(payment);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal(
+ 'The date of the last payment must be greater than two months from the validation date',
+ );
+ }
+ });
+
+ it('check versement : versement multiple avec une date de dernier versement au mauvais format', () => {
+ try {
+ const payment = {
+ mode: 'multiple',
+ lastPayment: '0101/2021',
+ frequency: 'mensuelle',
+ amount: 1,
+ } as ValidationMultiplePayment;
+ subscriptionService.checkPayment(payment);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal('does not conform to the "date" format');
+ }
+ });
+
+ it('check versement : versement multiple avec une date de dernier versement incorrecte', () => {
+ try {
+ const payment = {
+ mode: 'multiple',
+ lastPayment: '2021-01-01',
+ frequency: 'mensuelle',
+ amount: 3,
+ } as ValidationMultiplePayment;
+ subscriptionService.checkPayment(payment);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal(
+ 'The date of the last payment must be greater than two months from the validation date',
+ );
+ }
+ });
+
+ it('check motif : ConditionsNonRespectees motif: success', () => {
+ const reason = {
+ type: 'ConditionsNonRespectees',
+ } as NoReason;
+
+ try {
+ const result = subscriptionService.checkRefusMotif(reason);
+ expect(result).deepEqual(reason);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check motif : Autre motif without autre text : error', () => {
+ const reason = {
+ type: 'Autre',
+ };
+
+ try {
+ subscriptionService.checkRefusMotif(reason);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal('requires property "other"');
+ }
+ });
+
+ it('check Buffer genereation: generation excel : success', async () => {
+ const input: Subscription[] = [];
+ input.push(firstSubscription);
+ try {
+ const result = await subscriptionService.generateExcelValidatedIncentives(input);
+ expect(result).to.be.instanceof(Buffer);
+ } catch (error) {
+ sinon.assert.fail();
+ }
+ });
+
+ it('check Buffer genereation: generation excel avec une liste vide : error', async () => {
+ const input: Subscription[] = [];
+ try {
+ await subscriptionService.generateExcelValidatedIncentives(input);
+ sinon.assert.fail();
+ } catch (error) {
+ expect(error.message).to.equal(expectedErrorBuffer.message);
+ }
+ });
+
+ it('check Buffer generation: generation excel with specificFields : success', async () => {
+ const input = [];
+ input.push(secondDemande);
+ const result = await subscriptionService.generateExcelValidatedIncentives(input);
+ expect(result).to.be.instanceof(Buffer);
+ });
+
+ it('get citizens with subscriptions: success', () => {
+ subscriptionRepository.stubs.execute.resolves(mockSubscriptions);
+ const match = [
+ {funderType: 'AideNationale'},
+ {funderName: 'simulation-maas'},
+ {status: {$ne: 'BROUILLON'}},
+ ];
+ const result = subscriptionService
+ .getCitizensWithSubscription(match, 0)
+ .then((res: any) => res)
+ .catch((err: any) => err);
+ expect(result).deepEqual(mockCitizens);
+ });
+
+ const sendMode = [SEND_MODE.VALIDATION, SEND_MODE.REJECTION];
+ sendMode.forEach(mode => {
+ it('sendValidationOrRejectionMail: successfull', () => {
+ subscriptionService.sendValidationOrRejectionMail(
+ mode,
+ mailService,
+ 'Aide 1',
+ '23/05/2022',
+ 'Capgemini',
+ 'entreprise',
+ 'email@email.com',
+ 'subscriptionRejectionMessage',
+ 'comments',
+ );
+ mailService.stubs.sendMailAsHtml.resolves('success');
+ expect(mailService.sendMailAsHtml.calledOnce).true();
+ expect(
+ mailService.sendMailAsHtml.calledWith(
+ 'email@email.com',
+ `Votre demande d’aide a été ${mode}`,
+ mode === SEND_MODE.VALIDATION
+ ? 'subscription-validation'
+ : 'subscription-rejection',
+ ),
+ ).true();
+ });
+ });
+
+ it('handleMessage citizenId not matching: Error', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(citizenSubscription);
+ await subscriptionService.handleMessage(objError);
+ } catch (error) {
+ expect(error.message).to.deepEqual('CitizenID does not match');
+ }
+ });
+
+ it('check formatAttachments: success', async () => {
+ const result = await subscriptionService.formatAttachments(attachmentFiles);
+ const formattedAttachemnts = attachmentFiles;
+ formattedAttachemnts[1].originalname = 'file(1).pdf';
+ formattedAttachemnts[2].originalname = 'file(2).pdf';
+ expect(result).deepEqual(formattedAttachemnts);
+ });
+ it('check formatAttachments no file: fail', async () => {
+ const result = await subscriptionService.formatAttachments([]);
+ expect(result).to.Null;
+ });
+ it('check formatAttachments no extension: fail', async () => {
+ const result = await subscriptionService.formatAttachments(
+ attachmentWithoutExtension,
+ );
+ expect(result).to.Null;
+ });
+ it('handleMessage REJECTED payload : success', async () => {
+ subscriptionRepository.stubs.findById.resolves(RejectSubscription);
+ s3Service.stubs.deleteObjectFile.resolves('any');
+
+ const result = await subscriptionService.handleMessage(objReject1);
+ expect(result).to.Null;
+ });
+
+ it('handleMessage VALIDATED payload : success', async () => {
+ subscriptionRepository.stubs.findById.resolves(validateSubscription);
+ const result = await subscriptionService.handleMessage(objValidated);
+ expect(result).to.Null;
+ });
+
+ it('handleMessage VALIDATED status fail : Error', async () => {
+ try {
+ subscriptionRepository.stubs.findById.resolves(errorSubscription);
+ await subscriptionService.handleMessage(objValidated);
+ } catch (error) {
+ expect(error.message).to.deepEqual('subscriptions.error.bad.status');
+ }
+ });
+ it('sendPublishPayload test : Success', async () => {
+ subscriptionRepository.stubs.findById.resolves(RejectSubscription);
+ enterpriseRepository.stubs.findById.resolves(enterprise);
+ communityRepository.stubs.findById.resolves(community);
+ citizenRepository.stubs.findById.resolves(citizen);
+
+ const result = await subscriptionService.getSubscriptionPayload('randomInputId', {
+ message: 'string',
+ property: 'string',
+ code: 'string',
+ } as ISubscriptionBusError);
+ expect(result).deepEqual(subscriptionCompare);
+ });
+ it('rejectSubscription test : Success', async () => {
+ const objValidated = {
+ status: SUBSCRIPTION_STATUS.REJECTED,
+ type: REJECTION_REASON.CONDITION,
+ comment: 'test',
+ };
+ s3Service.stubs.deleteObjectFile.resolves('any');
+ const result = await subscriptionService.rejectSubscription(
+ objValidated,
+ RejectWithAttachments,
+ );
+ expect(result).to.Null;
+ });
+ it('rejectSubscription without aide type : Success', async () => {
+ const objValidated = {
+ status: SUBSCRIPTION_STATUS.REJECTED,
+ type: REJECTION_REASON.CONDITION,
+ comment: 'test',
+ };
+ s3Service.stubs.deleteObjectFile.resolves('any');
+ citizenRepository.stubs.findById.resolves(citizenNoAffilation);
+ const result = await subscriptionService.rejectSubscription(
+ objValidated,
+ RejectWithoutIncentive,
+ );
+ expect(result).to.Null;
+ });
+ it('rejectSubscription test : fail', async () => {
+ try {
+ const objValidated = {
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ type: 'Error',
+ comment: 'test',
+ };
+ s3Service.stubs.deleteObjectFile.resolves('any');
+ citizenRepository.stubs.findById.resolves(citizen);
+ await subscriptionService.rejectSubscription(objValidated, RejectSubscription);
+ } catch (error) {
+ expect(error.message).to.deepEqual('subscriptionRejection.type.not.found');
+ }
+ });
+ it('validateSubscription test : success', async () => {
+ const objValidated = {
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ mode: 'aucun',
+ };
+ citizenRepository.stubs.findById.resolves(citizen);
+ subscriptionRepository.stubs.updateById.resolves();
+ await subscriptionService.validateSubscription(
+ objValidated,
+ validateSubscriptionTest,
+ );
+ });
+ it('validateSubscription test diffrent type : success', async () => {
+ const objValidated = {
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ mode: 'aucun',
+ };
+ citizenRepository.stubs.findById.resolves(citizen);
+ subscriptionRepository.stubs.updateById.resolves();
+ await subscriptionService.validateSubscription(
+ objValidated,
+ validateSubscriptionOtherStatus,
+ );
+ });
+ it('handleMessage Error status payload : success', async () => {
+ subscriptionRepository.stubs.findById.resolves(validateSubscription);
+ const result = await subscriptionService.handleMessage(objValidatedError);
+ expect(result).to.Null;
+ });
+ it('preparePayLoad payload : success', async () => {
+ communityRepository.stubs.findById.resolves(community);
+ citizenRepository.stubs.findById.resolves(citizen);
+ const result = await subscriptionService.preparePayLoad(RejectWithAttachment, {
+ message: 'string',
+ property: 'string',
+ code: 'string',
+ } as ISubscriptionBusError);
+ expect(result).to.Null;
+ });
+ it('preparePayLoad no attachments payload : success', async () => {
+ communityRepository.stubs.findById.resolves(community2);
+ citizenRepository.stubs.findById.resolves(citizenNoEnterpriseMail);
+ await subscriptionService.preparePayLoad(RejectSubscriptionNoAttachment, {
+ message: 'string',
+ property: 'string',
+ code: 'string',
+ } as ISubscriptionBusError);
+ });
+ it('checkRefusMotif error payload : fail', async () => {
+ try {
+ await subscriptionService.checkRefusMotif({
+ comment: 'test',
+ });
+ } catch (error) {
+ expect(error.message).to.deepEqual('requires property "type"');
+ }
+ });
+
+ it('checkPayment error payload : fail', async () => {
+ try {
+ await subscriptionService.checkPayment({
+ comment: 'test',
+ });
+ } catch (error) {
+ expect(error.message).to.deepEqual('requires property "mode"');
+ }
+ });
+
+ const subscriptionCompare = {
+ subscription: {
+ lastName: 'lastName',
+ firstName: 'firstName',
+ birthdate: undefined,
+ citizenId: 'email@gmail.com',
+ incentiveId: 'incentiveId',
+ subscriptionId: 'randomInputId',
+ email: 'test@gmail.com',
+ status: 'A_TRAITER',
+ communityName: '',
+ specificFields: '',
+ attachments: [],
+ error: {message: 'string', property: 'string', code: 'string'},
+ encryptedAESKey: undefined,
+ encryptedIV: undefined,
+ encryptionKeyId: undefined,
+ encryptionKeyVersion: undefined,
+ },
+ enterprise: 'enterprise',
+ };
+
+ const enterprise = new Enterprise({
+ id: 'incentiveId',
+ name: 'enterprise',
+ emailFormat: ['@gmail.com'],
+ });
+ const community2 = new Community({
+ id: 'incentiveId',
+ funderId: 'incentiveId',
+ });
+ const community = {
+ id: 'incentiveId',
+ name: 'enterprise',
+ funderId: 'incentiveId',
+ } as Community;
+ const citizen = new Citizen({
+ id: 'email@gmail.com',
+ affiliation: {
+ enterpriseId: 'incentiveId',
+ enterpriseEmail: 'test@gmail.com',
+ affiliationStatus: 'AFFILIE',
+ } as Affiliation,
+ });
+ const citizenNoAffilation = new Citizen({
+ id: 'email@gmail.com',
+ });
+ const citizenNoEnterpriseMail = new Citizen({
+ id: 'email@gmail.com',
+ affiliation: {
+ enterpriseId: 'incentiveId',
+ affiliationStatus: 'AFFILIE',
+ } as Affiliation,
+ });
+ const objReject1 = {
+ citizenId: 'email@gmail.com',
+ subscriptionId: 'randomInputId',
+ status: SUBSCRIPTION_STATUS.REJECTED,
+ type: 'ConditionsNonRespectees',
+ };
+
+ const objValidated = {
+ citizenId: 'email@gmail.com',
+ subscriptionId: 'randomInputId',
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ mode: 'unique',
+ amount: 1,
+ };
+ const objValidatedError = {
+ citizenId: 'email@gmail.com',
+ subscriptionId: 'randomInputId',
+ status: 'ERREUR',
+ mode: 'unique',
+ amount: 1,
+ };
+ const objError = {
+ citizenId: 'random',
+ subscriptionId: 'randomInputId',
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ mode: 'aucun',
+ };
+ const firstSubscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+
+ const citizenSubscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+ const RejectSubscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+ const RejectWithAttachment = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ communityId: 'incentiveId',
+ attachments: [
+ {
+ originalName: 'uploadedAttachment.pdf',
+ uploadDate: new Date('2022-01-01 00:00:00.000Z'),
+ proofType: 'Passport',
+ mimeType: 'application/pdf',
+ } as AttachmentType,
+ ],
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ specificFields: [
+ {
+ title: 'newField1',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'newField1',
+ },
+ {
+ inputChoice: 'newField11',
+ },
+ ],
+ },
+ },
+ {
+ title: 'newField2',
+ inputFormat: 'Texte',
+ },
+ ],
+ });
+
+ const RejectWithAttachments = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ communityId: 'incentiveId',
+ attachments: [
+ {
+ originalName: 'uploadedAttachment.pdf',
+ uploadDate: new Date('2022-01-01 00:00:00.000Z'),
+ proofType: 'Passport',
+ mimeType: 'application/pdf',
+ } as AttachmentType,
+ ],
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ specificFields: [
+ {
+ title: 'newField1',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'newField1',
+ },
+ {
+ inputChoice: 'newField11',
+ },
+ ],
+ },
+ },
+ {
+ title: 'newField2',
+ inputFormat: 'Texte',
+ },
+ ],
+ });
+ const RejectWithoutIncentive = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+ const RejectSubscriptionNoAttachment = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+ const validateSubscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+
+ const validateSubscriptionTest = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+ const validateSubscriptionOtherStatus = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: 'test',
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.REJECTED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+
+ const errorSubscription = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ });
+
+ const secondDemande = new Subscription({
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: 'email@gmail.com',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ specificFields: [
+ {
+ title: 'newField1',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 2,
+ inputChoiceList: [
+ {
+ inputChoice: 'newField1',
+ },
+ {
+ inputChoice: 'newField11',
+ },
+ ],
+ },
+ },
+ {
+ title: 'newField2',
+ inputFormat: 'Texte',
+ },
+ ],
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ subscriptionValidation: {
+ mode: 'multipe',
+ amount: 11,
+ frequency: 'test',
+ lastPayment: '11-11-2022',
+ } as ValidationMultiplePayment,
+ });
+
+ const mockSubscriptions: Record[] = [
+ {
+ id: 'randomInputId',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: '260a6356-3261-4335-bca8-4c1f8257613d',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ },
+ {
+ id: 'randomInputId1',
+ incentiveId: 'incentiveId',
+ funderName: 'funderName',
+ incentiveType: INCENTIVE_TYPE.TERRITORY_INCENTIVE,
+ incentiveTitle: 'incentiveTitle',
+ citizenId: '260a6356-3261-4335-bca8-4c1f8257613d',
+ lastName: 'lastName',
+ firstName: 'firstName',
+ email: 'email@gmail.com',
+ consent: true,
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ createdAt: new Date('2021-04-06T09:01:30.778Z'),
+ updatedAt: new Date('2021-04-06T09:01:30.778Z'),
+ },
+ ];
+
+ const mockCitizens: Promise = new Promise(() => {
+ return [
+ {
+ citizensData: [
+ {
+ citizenId: '260a6356-3261-4335-bca8-4c1f8257613d',
+ lastName: 'leYellow',
+ firstName: 'Bob',
+ },
+ ],
+ totalCitizens: 1,
+ },
+ ];
+ });
+
+ const attachmentFiles: Express.Multer.File[] = [
+ {
+ originalname: 'file.pdf',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'application/pdf/png',
+ fieldname: 'fieldname',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ {
+ originalname: 'file.pdf',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'application/pdf/png',
+ fieldname: 'fieldname',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ {
+ originalname: 'file.pdf',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'application/pdf/png',
+ fieldname: 'fieldname',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ ];
+
+ const attachmentWithoutExtension: Express.Multer.File[] = [
+ {
+ originalname: 'file',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'application/pdf/png',
+ fieldname: 'fieldname',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ {
+ originalname: 'file',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'application/pdf/png',
+ fieldname: 'fieldname',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ {
+ originalname: 'file',
+ buffer: Buffer.from('test de buffer'),
+ mimetype: 'application/pdf/png',
+ fieldname: 'fieldname',
+ size: 4000,
+ encoding: '7bit',
+ stream: new Readable(),
+ destination: 'string',
+ filename: 'fileName',
+ path: 'test',
+ },
+ ];
+});
diff --git a/api/src/__tests__/services/territory.service.test.ts b/api/src/__tests__/services/territory.service.test.ts
new file mode 100644
index 0000000..b7e1a4f
--- /dev/null
+++ b/api/src/__tests__/services/territory.service.test.ts
@@ -0,0 +1,54 @@
+import {
+ StubbedInstanceWithSinonAccessor,
+ createStubInstance,
+ expect,
+} from '@loopback/testlab';
+import {Territory} from '../../models';
+
+import {TerritoryRepository} from '../../repositories';
+import {TerritoryService} from '../../services/territory.service';
+import {ResourceName, StatusCode} from '../../utils';
+import {ValidationError} from '../../validationError';
+
+describe('Territory service', () => {
+ let territoryRepository: StubbedInstanceWithSinonAccessor,
+ territoryService: TerritoryService;
+
+ beforeEach(() => {
+ territoryRepository = createStubInstance(TerritoryRepository);
+ territoryService = new TerritoryService(territoryRepository);
+ });
+
+ it('TerritoryService createTerritory: successfull', async () => {
+ territoryRepository.stubs.findOne.resolves(null);
+ territoryRepository.stubs.create.resolves(territoryMock);
+
+ const result = await territoryService.createTerritory(territoryPayload);
+ expect(result).to.deepEqual(territoryMock);
+ });
+
+ it('TerritoryService createTerritory: unique name', async () => {
+ try {
+ territoryRepository.stubs.findOne.resolves(territoryMock);
+ await territoryService.createTerritory(territoryPayload);
+ } catch (error) {
+ expect(error).to.deepEqual(territoryNameUnique);
+ }
+ });
+
+ const territoryMock = new Territory({
+ name: 'Toulouse',
+ id: '634c83b994f56f610415f9c6',
+ });
+
+ const territoryPayload = new Territory({
+ name: 'Toulouse',
+ });
+
+ const territoryNameUnique = new ValidationError(
+ 'territory.name.error.unique',
+ '/territoryName',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Territory,
+ );
+});
diff --git a/api/src/__tests__/services/user.authorizor.test.ts b/api/src/__tests__/services/user.authorizor.test.ts
new file mode 100644
index 0000000..0f82090
--- /dev/null
+++ b/api/src/__tests__/services/user.authorizor.test.ts
@@ -0,0 +1,52 @@
+import {expect} from '@loopback/testlab';
+import {canAccessHisOwnData} from '../../services';
+import {securityId} from '@loopback/security';
+import {AuthorizationContext, AuthorizationDecision} from '@loopback/authorization';
+import {InvocationContext} from '@loopback/core';
+
+describe('user authorizor', () => {
+ it('should canAccessHisOwnData: OK ALLOW', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ membership: ['membership'],
+ roles: ['roles'],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: {
+ args: ['id'],
+ } as InvocationContext,
+ };
+ const result = await canAccessHisOwnData(authContext, null as any);
+ expect(result).to.deepEqual(AuthorizationDecision.ALLOW);
+ });
+
+ it('should checkMaas: OK DENY', async () => {
+ const authContext: AuthorizationContext = {
+ principals: [
+ {
+ [securityId]: 'id',
+ id: 'id',
+ emailVerified: true,
+ maas: undefined,
+ membership: ['membership'],
+ roles: ['roles'],
+ },
+ ],
+ roles: [],
+ scopes: [],
+ resource: '',
+ invocationContext: {
+ args: ['wrongId'],
+ } as InvocationContext,
+ };
+ const result = await canAccessHisOwnData(authContext, null as any);
+ expect(result).to.deepEqual(AuthorizationDecision.DENY);
+ });
+});
diff --git a/api/src/__tests__/strategies/keycloak.strategy.test.ts b/api/src/__tests__/strategies/keycloak.strategy.test.ts
new file mode 100644
index 0000000..eb4b815
--- /dev/null
+++ b/api/src/__tests__/strategies/keycloak.strategy.test.ts
@@ -0,0 +1,76 @@
+import {expect, sinon} from '@loopback/testlab';
+import {Request} from 'express';
+import 'regenerator-runtime/runtime';
+import {securityId} from '@loopback/security';
+import {KeycloakAuthenticationStrategy} from '../../strategies';
+import {AuthenticationService} from '../../services';
+import {ValidationError} from '../../validationError';
+import {StatusCode} from '../../utils';
+
+describe('Keycloak strategy', () => {
+ let keycloakStrategy: KeycloakAuthenticationStrategy,
+ authenticationService: AuthenticationService;
+
+ beforeEach(() => {
+ authenticationService = new AuthenticationService();
+ keycloakStrategy = new KeycloakAuthenticationStrategy(authenticationService);
+ });
+
+ it('should authenticate: OK', async () => {
+ const user = {
+ sub: 'id',
+ email_verified: true,
+ clientName: undefined,
+ membership: ['/collectivités/Mulhouse'],
+ realm_access: {
+ roles: ['roles'],
+ },
+ };
+ const userResult = {
+ [securityId]: user.sub,
+ id: user.sub,
+ emailVerified: user.email_verified,
+ clientName: user.clientName,
+ funderType: 'collectivités',
+ funderName: 'Mulhouse',
+ groups: ['Mulhouse'],
+ incentiveType: 'AideTerritoire',
+ roles: user.realm_access.roles,
+ };
+ sinon.stub(authenticationService, 'verifyToken').resolves(user);
+ const request = {
+ headers: {
+ authorization: 'Bearer xxx.yyy.zzz',
+ },
+ };
+ const result = await keycloakStrategy.authenticate(request as Request);
+ expect(result).to.deepEqual(userResult);
+ });
+
+ it('should authenticate: KO emailverified false', async () => {
+ const user = {
+ sub: 'id',
+ email_verified: false,
+ membership: ['membership'],
+ realm_access: {
+ roles: ['roles'],
+ },
+ };
+ sinon.stub(authenticationService, 'verifyToken').resolves(user);
+ const request = {
+ headers: {
+ authorization: 'Bearer xxx.yyy.zzz',
+ },
+ };
+ const expectedError = new ValidationError(
+ `Email not verified`,
+ '/authorization',
+ StatusCode.Unauthorized,
+ );
+ try {
+ await keycloakStrategy.authenticate(request as Request);
+ } catch (err) {
+ expect(err).to.deepEqual(expectedError);
+ }
+ });
+});
diff --git a/api/src/__tests__/utils/affiliation.test.ts b/api/src/__tests__/utils/affiliation.test.ts
new file mode 100644
index 0000000..6ddf20e
--- /dev/null
+++ b/api/src/__tests__/utils/affiliation.test.ts
@@ -0,0 +1,33 @@
+import {expect} from '@loopback/testlab';
+
+import {Affiliation, Citizen} from '../../models';
+import {AFFILIATION_STATUS, FUNDER_TYPE, isEnterpriseAffilitation} from '../../utils';
+
+describe('affiliation functions', () => {
+ it('isEnterpriseAffilitation: KO : citizen not found', async () => {
+ const result = isEnterpriseAffilitation({
+ inputFunderId: 'dunderId',
+ citizen: null,
+ funderMatch: {funderType: FUNDER_TYPE.collectivity, name: 'maasName'},
+ });
+ expect(result).to.equal(false);
+ });
+
+ it('isEnterpriseAffilitation: OK', async () => {
+ const affiliation = new Affiliation(
+ Object.assign({enterpriseId: 'funderId', enterpriseEmail: 'test@test.com'}),
+ );
+ affiliation.affiliationStatus = AFFILIATION_STATUS.AFFILIATED;
+
+ const citizen = new Citizen({
+ affiliation,
+ });
+ const result = isEnterpriseAffilitation({
+ inputFunderId: 'funderId',
+ citizen,
+ funderMatch: {funderType: FUNDER_TYPE.enterprise, name: 'maasName'},
+ });
+
+ expect(result).to.equal(true);
+ });
+});
diff --git a/api/src/__tests__/utils/date.test.ts b/api/src/__tests__/utils/date.test.ts
new file mode 100644
index 0000000..bbb900b
--- /dev/null
+++ b/api/src/__tests__/utils/date.test.ts
@@ -0,0 +1,18 @@
+import {expect} from '@loopback/testlab';
+import {isExpired} from '../../utils';
+
+describe('date functions', () => {
+ it('isExpired : returns true when date is expired', async () => {
+ const today = new Date();
+ const expiredDate = new Date(today.setMonth(today.getMonth() - 3));
+ const check2 = isExpired(expiredDate, new Date());
+ expect(check2).to.deepEqual(true);
+ });
+
+ it('isExpired : returns false when date is not expired', async () => {
+ const today = new Date();
+ const expiredDate = new Date(today.setMonth(today.getMonth() + 3));
+ const check2 = isExpired(expiredDate, new Date());
+ expect(check2).to.deepEqual(false);
+ });
+});
diff --git a/api/src/__tests__/utils/encryption.test.ts b/api/src/__tests__/utils/encryption.test.ts
new file mode 100644
index 0000000..49fd7c7
--- /dev/null
+++ b/api/src/__tests__/utils/encryption.test.ts
@@ -0,0 +1,38 @@
+import * as crypto from 'crypto';
+import {expect} from '@loopback/testlab';
+import {encryptAESKey, encryptFileHybrid, generateAESKey} from '../../utils/encryption';
+
+describe('encryption service', () => {
+ it('should generate AES key', function () {
+ const checkKey = crypto.randomBytes(32);
+ const checkIV = crypto.randomBytes(16);
+ const generateKey = generateAESKey();
+ expect(generateKey).to.not.equal({key: checkKey, iv: checkIV});
+ });
+
+ it('should encrypt AES key', function () {
+ const checkKey = crypto.randomBytes(32);
+ const checkIV = crypto.randomBytes(16);
+ const encryptedKey = encryptAESKey(publicKey, checkKey, checkIV);
+ expect(encryptedKey).to.not.equal({key: checkKey, iv: checkIV});
+ });
+
+ it('should encrypt the file', function () {
+ const file = Buffer.from('File test');
+ const key = crypto.randomBytes(32);
+ const iv = crypto.randomBytes(16);
+ const encryptedFile = encryptFileHybrid(file, key, iv);
+ expect(encryptedFile).to.not.equal(file);
+ });
+});
+
+const publicKey = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkUKTww771tjeFsYFCZq
+n76SSpOzolmtf9VntGlPfbP5j1dEr6jAuTthQPoIDaEed6P44yyL3/1GqWJMgRbf
+n8qqvnu8dH8xB+c9+er0tNezafK9eK37RqzsTj7FNW2Dpk70nUYncTiXxjf+ofLq
+sokEIlp2zHPEZce2o6jAIoFOV90MRhJ4XcCik2w3IljxdJSIfBYX2/rDgEVN0T85
+OOd9ChaYpKCPKKfnpvhjEw+KdmzUFP1u8aao2BNKyI2C+MHuRb1wSIu2ZAYfHgoG
+X6FQc/nXeb1cAY8W5aUXOP7ITU1EtIuCD8WuxXMflS446vyfCmJWt+OFyveqgJ4n
+owIDAQAB
+-----END PUBLIC KEY-----
+`;
diff --git a/api/src/__tests__/utils/file-conversion.test.ts b/api/src/__tests__/utils/file-conversion.test.ts
new file mode 100644
index 0000000..d5fc180
--- /dev/null
+++ b/api/src/__tests__/utils/file-conversion.test.ts
@@ -0,0 +1,110 @@
+import {expect, sinon} from '@loopback/testlab';
+import pdf from 'html-pdf';
+import ejs from 'ejs';
+import {generatePdfBufferFromHtml, generateTemplateAsHtml} from '../../utils';
+import {Invoice} from '../../models';
+import {formatDateInTimezone} from '../../utils/date';
+
+const mockInvoice: Invoice = Object.assign(new Invoice(), {
+ enterprise: {
+ enterpriseName: 'IDF Mobilités',
+ sirenNumber: '362521879',
+ siretNumber: '36252187900034',
+ apeCode: '4711D',
+ enterpriseAddress: {
+ zipCode: 75018,
+ city: 'Paris',
+ street: '6 rue Lepic',
+ },
+ },
+ customer: {
+ customerId: '123789',
+ customerName: 'NEYMAR',
+ customerSurname: 'Jean',
+ customerAddress: {
+ zipCode: 75018,
+ city: 'Paris',
+ street: '15 rue Veron',
+ },
+ },
+ transaction: {
+ orderId: '30723',
+ purchaseDate: new Date('2021-03-03T14:54:18+01:00'),
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ },
+ products: [
+ {
+ productName: 'Forfait Navigo Mois',
+ quantity: 1,
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ percentTaxes: 10,
+ productDetails: {
+ periodicity: 'Mensuel',
+ zoneMin: 1,
+ zoneMax: 5,
+ validityStart: new Date('2021-03-01T00:00:00+01:00'),
+ validityEnd: new Date('2021-03-31T00:00:00+01:00'),
+ },
+ },
+ ],
+});
+const mockHtml: string = `Text Content
`;
+const mockBuffer: Buffer = Buffer.from(mockHtml);
+
+describe('File conversion functions', () => {
+ it('generateTemplateAsHtml success', async () => {
+ const html = await generateTemplateAsHtml('invoice', {
+ invoice: mockInvoice,
+ formatDate: formatDateInTimezone,
+ });
+ expect(html).to.containEql(mockInvoice.customer.customerName);
+ expect(html).to.containEql(mockInvoice.customer.customerSurname);
+ expect(html).to.containEql(mockInvoice.enterprise.enterpriseName);
+ expect(html).to.containEql(mockInvoice.enterprise.siretNumber);
+ expect(html).to.containEql(
+ (mockInvoice.transaction.amountInclTaxes / 100).toFixed(2),
+ );
+ expect(html).to.containEql(mockInvoice.transaction.orderId);
+ expect(html).to.containEql(
+ formatDateInTimezone(mockInvoice.transaction.purchaseDate, 'dd/MM/yyyy'),
+ );
+ expect(html).to.containEql(mockInvoice.products[0].productName);
+ });
+
+ it('generateTemplateAsHtml should throw error', async () => {
+ const ejsStub = sinon.stub(ejs, 'renderFile').rejects('HtmlTemplateError');
+ try {
+ await generateTemplateAsHtml('invoice');
+ } catch (error) {
+ expect(error.message).to.equal('HtmlTemplateError');
+ }
+ ejsStub.restore();
+ });
+
+ it('generatePdfBufferFromHtml success', async () => {
+ const pdfStub = sinon.stub(pdf, 'create').returns({
+ toBuffer: sinon.stub().yields(null, Buffer.from(mockHtml)),
+ toFile: sinon.stub().returnsThis(),
+ toStream: sinon.stub().returnsThis(),
+ });
+ const pdfBuffer = await generatePdfBufferFromHtml(mockHtml);
+ expect(pdfBuffer).to.eql(mockBuffer);
+ pdfStub.restore();
+ });
+
+ it('generatePdfBufferFromHtml should throw error', async () => {
+ const pdfStub = sinon.stub(pdf, 'create').returns({
+ toBuffer: sinon.stub().yields('generatePdfError', null),
+ toFile: sinon.stub().returnsThis(),
+ toStream: sinon.stub().returnsThis(),
+ });
+ try {
+ await generatePdfBufferFromHtml(mockHtml);
+ } catch (error) {
+ expect(error).to.equal('generatePdfError');
+ }
+ pdfStub.restore();
+ });
+});
diff --git a/api/src/__tests__/utils/invoice.test.ts b/api/src/__tests__/utils/invoice.test.ts
new file mode 100644
index 0000000..8a6588a
--- /dev/null
+++ b/api/src/__tests__/utils/invoice.test.ts
@@ -0,0 +1,116 @@
+import {expect, sinon} from '@loopback/testlab';
+import ejs from 'ejs';
+import {generatePdfInvoices} from '../../utils/invoice';
+import * as fileConversion from '../../utils/file-conversion';
+import {Invoice} from '../../models';
+
+const invoice1: Invoice = Object.assign(new Invoice(), {
+ enterprise: {
+ enterpriseName: 'IDF Mobilités',
+ sirenNumber: '362521879',
+ siretNumber: '36252187900034',
+ apeCode: '4711D',
+ enterpriseAddress: {
+ zipCode: 75018,
+ city: 'Paris',
+ street: '6 rue Lepic',
+ },
+ },
+ customer: {
+ customerId: '123789',
+ customerName: 'NEYMAR',
+ customerSurname: 'Jean',
+ customerAddress: {
+ zipCode: 75018,
+ city: 'Paris',
+ street: '15 rue Veron',
+ },
+ },
+ transaction: {
+ orderId: '30723',
+ purchaseDate: new Date('2021-03-03T14:54:18+01:00'),
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ },
+ products: [
+ {
+ productName: 'Forfait Navigo Mois',
+ quantity: 1,
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ percentTaxes: 10,
+ productDetails: {
+ periodicity: 'Mensuel',
+ zoneMin: 1,
+ zoneMax: 5,
+ validityStart: new Date('2021-03-01T00:00:00+01:00'),
+ validityEnd: new Date('2021-03-31T00:00:00+01:00'),
+ },
+ },
+ ],
+});
+
+const invoice2: Invoice = Object.assign(new Invoice(), {
+ enterprise: {
+ enterpriseName: 'IDF Mobilités',
+ sirenNumber: '362521879',
+ siretNumber: '36252187900034',
+ apeCode: '4711D',
+ enterpriseAddress: {
+ zipCode: 75018,
+ city: 'Paris',
+ street: '6 rue Lepic',
+ },
+ },
+ customer: {
+ customerId: '123789',
+ customerName: 'DELOIN',
+ customerSurname: 'Alain',
+ },
+ transaction: {
+ orderId: '30723',
+ purchaseDate: new Date('2021-03-03T14:54:18+01:00'),
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ },
+ products: [
+ {
+ productName: 'Forfait Navigo Mois',
+ quantity: 1,
+ amountInclTaxes: 7520,
+ amountExclTaxes: 7520,
+ percentTaxes: 10,
+ productDetails: {
+ periodicity: 'Mensuel',
+ zoneMin: 1,
+ zoneMax: 5,
+ },
+ },
+ ],
+});
+
+const mockInvoices: Invoice[] = [invoice1, invoice2];
+const files: Record[] = [
+ {key: 'file.txt'},
+ {key: 'file.txt'},
+ {key: 'file.txt'},
+];
+const mockHtml: string = `Text Content
`;
+const mockBuffer: Buffer = Buffer.from(mockHtml);
+describe('Invoice', () => {
+ it('generatePdfInvoices success', async () => {
+ const ejsStub = sinon.stub(ejs, 'renderFile').resolves(mockHtml);
+ sinon
+ .stub(fileConversion, 'generatePdfBufferFromHtml')
+ .resolves(Buffer.from(await ejsStub('')));
+ const invoicesPdf = await generatePdfInvoices(mockInvoices);
+ expect(invoicesPdf.length).to.equal(2);
+ expect(invoicesPdf[0].originalname).to.equal(
+ '03-03-2021_Forfait_Navigo_Mois_Jean_NEYMAR.pdf',
+ );
+ expect(invoicesPdf[1].originalname).to.equal(
+ '03-03-2021_Forfait_Navigo_Mois_Alain_DELOIN.pdf',
+ );
+ expect(invoicesPdf[0].buffer).to.eql(mockBuffer);
+ });
+});
diff --git a/api/src/application.ts b/api/src/application.ts
new file mode 100644
index 0000000..57437e8
--- /dev/null
+++ b/api/src/application.ts
@@ -0,0 +1,115 @@
+import {BootMixin} from '@loopback/boot';
+import {ApplicationConfig, createBindingFromClass} from '@loopback/core';
+import {RestExplorerBindings, RestExplorerComponent} from '@loopback/rest-explorer';
+import {RepositoryMixin} from '@loopback/repository';
+import {RestApplication, RestBindings} from '@loopback/rest';
+import {ServiceMixin} from '@loopback/service-proxy';
+import path from 'path';
+import {MySequence} from './sequence';
+import {
+ AuthenticationComponent,
+ registerAuthenticationStrategy,
+} from '@loopback/authentication';
+import {AuthorizationComponent, AuthorizationTags} from '@loopback/authorization';
+export {ApplicationConfig};
+import {InfoObject, mergeOpenAPISpec} from '@loopback/openapi-v3';
+import {CronComponent} from '@loopback/cron';
+import {MigrationBindings, MigrationComponent} from 'loopback4-migration';
+
+import {SECURITY_SCHEME_SPEC} from './utils/security-spec';
+import {OPENAPI_CONFIG} from './constants';
+import {ApiKeyAuthenticationStrategy} from './strategies';
+import {KeycloakAuthenticationStrategy} from './strategies/keycloak.strategy';
+import {AuthorizationProvider} from './providers/authorization.provider';
+import {RabbitmqCronJob} from './cronjob/rabbitmqCronJob';
+import {SubscriptionCronJob} from './cronjob/subscriptionCronJob';
+import {NonActivatedAccountDeletionCronJob} from './cronjob/nonActivatedAccountDeletionCronJob';
+import {ParentProcessService} from './services';
+import {MongoDsDataSource} from './datasources';
+import {MigrationScript111} from './migrations/1.11.0.migration';
+
+export class App extends BootMixin(ServiceMixin(RepositoryMixin(RestApplication))) {
+ constructor(options: ApplicationConfig = {}) {
+ super(options);
+ this.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS).to({
+ $data: true,
+ validation: {
+ keywords: ['example'],
+ },
+ });
+
+ const infoObject: InfoObject = {
+ title: OPENAPI_CONFIG.title,
+ version: OPENAPI_CONFIG.version,
+ contact: OPENAPI_CONFIG.contact,
+ };
+
+ this.api(
+ mergeOpenAPISpec(this.getSync(RestBindings.API_SPEC), {
+ info: infoObject,
+ components: {
+ securitySchemes: SECURITY_SCHEME_SPEC,
+ },
+ }),
+ );
+
+ // Set up the custom sequence
+ this.sequence(MySequence);
+
+ // Set up default home page
+ this.static('/', path.join(__dirname, '../public'));
+ // Configure migration component
+ this.bind(MigrationBindings.CONFIG).to({
+ appVersion: '1.11.0',
+ dataSourceName: MongoDsDataSource.dataSourceName,
+ modelName: 'Migration',
+ migrationScripts: [MigrationScript111],
+ });
+ // Bind migration component related elements
+ this.component(MigrationComponent);
+
+ // Customize @loopback/rest-explorer configuration here
+ this.configure(RestExplorerBindings.COMPONENT).to({
+ path: '/explorer',
+ indexTemplatePath: path.resolve(__dirname, '../explorer/index.html.ejs'),
+ indexTitle: 'API Explorer | Mon Compte Mobilité',
+ });
+
+ this.component(RestExplorerComponent);
+
+ this.bind(RestBindings.ERROR_WRITER_OPTIONS).to({
+ debug: false,
+ safeFields: ['path', 'resourceName'],
+ });
+ this.component(AuthenticationComponent);
+ this.component(AuthorizationComponent);
+ this.bind('authorizationProviders.authorization-provider')
+ .toProvider(AuthorizationProvider)
+ .tag(AuthorizationTags.AUTHORIZER);
+
+ registerAuthenticationStrategy(this, KeycloakAuthenticationStrategy);
+ registerAuthenticationStrategy(this, ApiKeyAuthenticationStrategy);
+
+ // Create binding for ParentProcessService to be used as unique instance
+ // Please use @inject('services.ParentProcessService') if injection is needed in other classes
+ this.add(createBindingFromClass(ParentProcessService));
+
+ // Init cron component and RabbitmqCronJob
+ this.component(CronComponent);
+ this.add(createBindingFromClass(RabbitmqCronJob));
+ // clean subscription collection
+ this.add(createBindingFromClass(SubscriptionCronJob));
+ this.add(createBindingFromClass(NonActivatedAccountDeletionCronJob));
+
+ this.projectRoot = __dirname;
+ // Customize @loopback/boot Booter Conventions here
+ this.bootOptions = {
+ controllers: {
+ // Customize ControllerBooter Conventions here
+ dirs: ['controllers'],
+ extensions: ['.controller.js', '.controller.ts'],
+ nested: true,
+ },
+ };
+ }
+}
diff --git a/api/src/busError.ts b/api/src/busError.ts
new file mode 100644
index 0000000..16fd343
--- /dev/null
+++ b/api/src/busError.ts
@@ -0,0 +1,18 @@
+import {logger} from './utils';
+import {ValidationError} from './validationError';
+
+export class BusError extends ValidationError {
+ property?: string;
+
+ constructor(
+ message: string,
+ property: string,
+ path: string,
+ statusCode = 500,
+ resourceName = '',
+ ) {
+ super(message, path, statusCode, resourceName);
+ this.property = property;
+ logger.error(message);
+ }
+}
diff --git a/api/src/config/clamAvConfig.ts b/api/src/config/clamAvConfig.ts
new file mode 100644
index 0000000..132e113
--- /dev/null
+++ b/api/src/config/clamAvConfig.ts
@@ -0,0 +1,25 @@
+export class ClamAvConfig {
+ private host: string = process.env.CLAMAV_HOST
+ ? `${process.env.CLAMAV_HOST}`
+ : `localhost`;
+
+ private port: number | undefined = process.env.CLAMAV_PORT
+ ? Number(process.env.CLAMAV_PORT)
+ : 3310;
+
+ /**
+ * Get ClamAv configuration to init
+ * @returns clamav configuration
+ */
+ getClamAvConfiguration() {
+ return {
+ debugMode: false, // This will put some debug info in your js console
+ clamdscan: {
+ host: this.host, // IP of host to connect to TCP interface
+ port: this.port, // Port of host to use when connecting via TCP interface
+ multiscan: true, // Scan using all available cores! Yay!
+ },
+ preference: 'clamdscan', // If clamdscan is found and active, it will be used by default
+ };
+ }
+}
diff --git a/api/src/config/index.ts b/api/src/config/index.ts
new file mode 100644
index 0000000..bee3ceb
--- /dev/null
+++ b/api/src/config/index.ts
@@ -0,0 +1,4 @@
+export * from './clamAvConfig';
+export * from './mailConfig';
+export * from './s3Config';
+export * from './rabbitmqConfig';
diff --git a/api/src/config/mailConfig.ts b/api/src/config/mailConfig.ts
new file mode 100644
index 0000000..40e7bda
--- /dev/null
+++ b/api/src/config/mailConfig.ts
@@ -0,0 +1,40 @@
+import nodemailer from 'nodemailer';
+export class MailConfig {
+ /**
+ * Check env before sending the email.
+ */
+ configMailer() {
+ let mailer, from;
+ const mailHog = {
+ host: process.env.MAILHOG_HOST,
+ port: 1025,
+ };
+ const sendGrid = {
+ host: process.env.SENDGRID_HOST,
+ port: 587,
+ auth: {
+ user: process.env.SENDGRID_USER,
+ pass: process.env.SENDGRID_API_KEY,
+ },
+ };
+ const fromLocal = 'Mon Compte Mobilité ';
+
+ // check is FQDN is set
+ if (process.env.IDP_FQDN) {
+ // check landscape
+ if (process.env.LANDSCAPE === 'preview' || process.env.LANDSCAPE === 'testing') {
+ mailer = nodemailer.createTransport(mailHog);
+ from = process.env.MAILHOG_EMAIL_FROM;
+ } else {
+ mailer = nodemailer.createTransport(sendGrid);
+ from = process.env.SENDGRID_EMAIL_FROM;
+ }
+ } else {
+ mailer = nodemailer.createTransport({
+ port: 1025,
+ });
+ from = fromLocal;
+ }
+ return {mailer, from};
+ }
+}
diff --git a/api/src/config/rabbitmqConfig.ts b/api/src/config/rabbitmqConfig.ts
new file mode 100644
index 0000000..7f8ffe3
--- /dev/null
+++ b/api/src/config/rabbitmqConfig.ts
@@ -0,0 +1,74 @@
+import {BindingScope, injectable} from '@loopback/core';
+import {credentials} from 'amqplib';
+@injectable({scope: BindingScope.TRANSIENT})
+export class RabbitmqConfig {
+ private amqpUrl = 'amqp://' + (process.env.BUS_HOST ?? 'localhost');
+ private apiKey = process.env.CLIENT_SECRET_KEY_KEYCLOAK_API ?? 'MOB_SECRET_KEY';
+
+ private user = process.env.BUS_MCM_CONSUME_USER ?? 'mob';
+ private password = process.env.BUS_MCM_CONSUME_PASSWORD ?? 'mob';
+
+ private exchangeValue = process.env.BUS_MCM_HEADERS ?? 'mob.headers';
+ private publishMessageType = process.env.BUS_MCM_MESSAGE_TYPE ?? 'subscriptions.put';
+
+ private consumerMessageType =
+ process.env.BUS_CONSUMER_QUEUE ?? 'mob.subscriptions.status';
+
+ /**
+ * Return the message type (put) and the secret key of the client
+ * @param enterpriseName
+ */
+ getPublishQueue(enterpriseName: string): {
+ headers: {message_type: string; secret_key: string};
+ } {
+ const publishQueue = {
+ headers: {
+ message_type: `${this.publishMessageType}.${enterpriseName}`,
+ secret_key: this.apiKey,
+ },
+ };
+ return publishQueue;
+ }
+
+ /**
+ * Generate login credentials with KC token from api service account
+ * @returns { credentials: any }
+ */
+ public getLogin(accessToken: string): {credentials: any} {
+ return {
+ credentials: credentials.plain('client_id', accessToken),
+ };
+ }
+
+ /**
+ * Get login with user/password
+ * @returns { credentials: any }
+ */
+ public getUserLogin(): {credentials: any} {
+ return {
+ credentials: credentials.plain(this.user, this.password),
+ };
+ }
+
+ /**
+ * Return the message type (status) for the consumer
+ * @param enterpriseName
+ */
+ getConsumerQueue(enterpriseName: string): string {
+ return `${this.consumerMessageType}.${enterpriseName}`;
+ }
+
+ /**
+ * Return the url to connect to
+ */
+ getAmqpUrl(): string {
+ return this.amqpUrl;
+ }
+
+ /**
+ * Return the exchange headers to publish
+ */
+ getExchange(): string {
+ return this.exchangeValue;
+ }
+}
diff --git a/api/src/config/s3Config.ts b/api/src/config/s3Config.ts
new file mode 100644
index 0000000..179267e
--- /dev/null
+++ b/api/src/config/s3Config.ts
@@ -0,0 +1,18 @@
+import {S3ClientConfig} from '@aws-sdk/client-s3';
+
+export class S3Config {
+ protected getConfiguration(): S3ClientConfig {
+ return {
+ credentials: {
+ accessKeyId: process.env.S3_SERVICE_USER ?? 'minioadmin',
+ secretAccessKey: process.env.S3_SERVICE_PASSWORD ?? 'minioadmin',
+ },
+ region: 'us-east-1',
+ endpoint:
+ process.env.S3_HOST && process.env.S3_PORT
+ ? `http://${process.env.S3_HOST}:${process.env.S3_PORT}`
+ : 'http://localhost:9001',
+ forcePathStyle: true,
+ };
+ }
+}
diff --git a/api/src/constants.ts b/api/src/constants.ts
new file mode 100644
index 0000000..415fc64
--- /dev/null
+++ b/api/src/constants.ts
@@ -0,0 +1,51 @@
+import {Credentials} from 'keycloak-admin/lib/utils/auth';
+import {InfoObject} from '@loopback/openapi-v3';
+
+export const OPENAPI_CONFIG: InfoObject = {
+ title: 'moB - Mon Compte Mobilité',
+ version: `${process.env.PACKAGE_VERSION || '1.0.0'}`,
+ contact: {
+ email: 'mcm_admin@moncomptemobilite.org',
+ name: 'Support technique',
+ },
+};
+
+export const emailRegexp = '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$';
+
+export const IDP_FQDN = process.env.IDP_FQDN
+ ? `https://${process.env.IDP_FQDN}`
+ : process.env.IDP_URL
+ ? `${process.env.IDP_URL}`
+ : `http://localhost:9000`;
+
+export const WEBSITE_FQDN = process.env.WEBSITE_FQDN
+ ? `https://${process.env.WEBSITE_FQDN}`
+ : process.env.WEBSITE_URL
+ ? `${process.env.WEBSITE_URL}`
+ : 'http://localhost:8000';
+
+export const API_FQDN = process.env.API_FQDN
+ ? `https://${process.env.API_FQDN}`
+ : process.env.API_URL
+ ? `${process.env.API_URL}`
+ : 'http://localhost:3000';
+
+export const baseUrl = `${IDP_FQDN}/auth`;
+
+export const realmName = 'mcm';
+
+export const IDP_SUFFIX_CLIENT = 'client';
+
+export const IDP_SUFFIX_BACKEND = 'backend';
+
+export const IDP_SUFFIX = [IDP_SUFFIX_CLIENT, IDP_SUFFIX_BACKEND];
+
+export const TAG_MAAS = 'MaaS';
+
+export const credentials: Credentials = {
+ grantType: 'client_credentials',
+ clientId: 'api',
+ clientSecret: process.env.CLIENT_SECRET_KEY_KEYCLOAK_API
+ ? `${process.env.CLIENT_SECRET_KEY_KEYCLOAK_API}`
+ : '${IDP_API_CLIENT_SECRET}',
+};
diff --git a/api/src/controllers/README.md b/api/src/controllers/README.md
new file mode 100644
index 0000000..ad4c4cc
--- /dev/null
+++ b/api/src/controllers/README.md
@@ -0,0 +1,9 @@
+# Controllers
+
+This directory contains source files for the controllers exported by this app.
+
+To add a new empty controller, type in `lb4 controller []` from the
+command-line of your application's root directory.
+
+For more information, please visit
+[Controller generator](http://loopback.io/doc/en/lb4/Controller-generator.html).
diff --git a/api/src/controllers/citizen.controller.ts b/api/src/controllers/citizen.controller.ts
new file mode 100644
index 0000000..33e4bd7
--- /dev/null
+++ b/api/src/controllers/citizen.controller.ts
@@ -0,0 +1,1025 @@
+import * as Excel from 'exceljs';
+import {inject, intercept, service} from '@loopback/core';
+import {FilterExcludingWhere, repository, AnyObject} from '@loopback/repository';
+import {
+ post,
+ param,
+ get,
+ getModelSchemaRef,
+ put,
+ patch,
+ requestBody,
+ Response,
+ RestBindings,
+ del,
+} from '@loopback/rest';
+import {SecurityBindings} from '@loopback/security';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+import {
+ CitizenRepository,
+ EnterpriseRepository,
+ CommunityRepository,
+ UserRepository,
+ SubscriptionRepository,
+ IncentiveRepository,
+} from '../repositories';
+import {
+ CitizenService,
+ KeycloakService,
+ MailService,
+ JwtService,
+ FunderService,
+ SubscriptionService,
+} from '../services';
+import {CitizenInterceptor} from '../interceptors';
+import {
+ CitizenUpdate,
+ Affiliation,
+ Incentive,
+ Citizen,
+ Subscription,
+ Enterprise,
+ Error,
+} from '../models';
+import {ValidationError} from '../validationError';
+import {
+ ResourceName,
+ StatusCode,
+ AFFILIATION_STATUS,
+ Roles,
+ SUBSCRIPTION_STATUS,
+ SECURITY_SPEC_API_KEY,
+ SECURITY_SPEC_KC_PASSWORD,
+ SECURITY_SPEC_JWT_KC_PASSWORD,
+ SECURITY_SPEC_JWT,
+ AUTH_STRATEGY,
+ Consent,
+ ClientOfConsent,
+ CITIZEN_STATUS,
+ IUser,
+ GENDER,
+} from '../utils';
+import {emailRegexp} from '../constants';
+
+import {canAccessHisOwnData} from '../services/user.authorizor';
+import {formatDateInTimezone} from '../utils/date';
+@intercept(CitizenInterceptor.BINDING_KEY)
+export class CitizenController {
+ constructor(
+ @inject('services.MailService')
+ public mailService: MailService,
+ @repository(CitizenRepository)
+ public citizenRepository: CitizenRepository,
+ @repository(CommunityRepository)
+ public communityRepository: CommunityRepository,
+ @inject('services.KeycloakService')
+ public kcService: KeycloakService,
+ @inject('services.FunderService')
+ public funderService: FunderService,
+ @repository(EnterpriseRepository)
+ public enterpriseRepository: EnterpriseRepository,
+ @inject('services.CitizenService')
+ public citizenService: CitizenService,
+ @inject('services.SubscriptionService')
+ public subscriptionService: SubscriptionService,
+ @inject('services.JwtService')
+ public jwtService: JwtService,
+ @service(UserRepository)
+ private userRepository: UserRepository,
+ @inject(SecurityBindings.USER, {optional: true})
+ private currentUser: IUser,
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ ) {}
+
+ /**
+ * Create a new user
+ * @param register the form or user object
+ * @returns an object with new user id + firstName + lastName
+ */
+ @authenticate(AUTH_STRATEGY.API_KEY)
+ @authorize({allowedRoles: [Roles.API_KEY]})
+ @post('v1/citizens', {
+ 'x-controller-name': 'Citizens',
+ summary: 'Crée un citoyen',
+ security: SECURITY_SPEC_API_KEY,
+ responses: {
+ [StatusCode.Created]: {
+ description: 'Le citoyen est enregistré',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ example: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: "L'entreprise du salarié est inconnue",
+ },
+ [StatusCode.PreconditionFailed]: {
+ description:
+ "L'email professionnel du salarié n'est pas du domaine de son entreprise",
+ },
+ [StatusCode.Conflict]: {
+ description: "L'email personnel du salarié existe déjà",
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "L'email professionnel du salarié existe déjà",
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Citizen),
+ },
+ },
+ })
+ register: Omit,
+ ): Promise<{id: string} | undefined> {
+ const result: {id: string} | undefined = await this.citizenService.createCitizen(
+ register,
+ );
+ return result;
+ }
+
+ /**
+ * @param [status] status in `status`
+ * @param [lastName] Search in `lastName`
+ * @param [skip] records.
+ * @returns {Citizen[], number} List of salaries sorted by `lastName` and the total
+ * number of employees based on their status or lastName
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({
+ allowedRoles: [Roles.SUPERVISORS, Roles.MANAGERS],
+ })
+ @get('/v1/citizens', {
+ 'x-controller-name': 'Citizens',
+ summary: "Retourne les salariés d'une entreprise",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Search filter employees ',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: getModelSchemaRef(Citizen),
+ },
+ },
+ },
+ },
+ },
+ })
+ async findSalaries(
+ @param.query.string('status', {
+ description: `Filtre sur le statut de l'aide: AFFILIE | DESAFFILIE | A_AFFILIER`,
+ })
+ status?: string,
+ @param.query.string('lastName', {
+ description: `Filtre sur le nom de famille du citoyen`,
+ })
+ lastName?: string,
+ @param.query.number('skip', {
+ description: `Nombre d'éléments à sauter lors de la pagination`,
+ })
+ skip?: number,
+ ): Promise<{employees: Citizen[] | undefined; employeesCount: number}> {
+ const result = await this.citizenService.findEmployees({
+ status,
+ lastName,
+ skip,
+ limit: 10,
+ });
+
+ return result;
+ }
+
+ /**
+ * get citizens with at least one subscription
+ * @param lastName search params
+ * @param skip number of element to skip
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({
+ allowedRoles: [Roles.FUNDERS],
+ })
+ @get('/v1/collectivitiesCitizens', {
+ 'x-controller-name': 'Citizens',
+ summary: 'Récupère la liste des citoyens ayant au moins une demande',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of subscriptions model instance',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ },
+ },
+ },
+ },
+ },
+ })
+ async getCitizensWithSubscriptions(
+ @param.query.string('lastName', {description: 'Nom de famille du citoyen'})
+ lastName?: string,
+ @param.query.number('skip', {
+ description: "Nombre d'éléments à sauter lors de la pagination",
+ })
+ skip?: number | undefined,
+ ): Promise {
+ const {incentiveType, funderName} = this.currentUser;
+
+ const match: object[] = [
+ {incentiveType: incentiveType},
+ {funderName: funderName},
+ {status: {$ne: SUBSCRIPTION_STATUS.DRAFT}},
+ ];
+
+ if (lastName) {
+ match.push({
+ lastName: new RegExp('.*' + lastName + '.*', 'i'),
+ });
+ }
+
+ return this.subscriptionService.getCitizensWithSubscription(match, skip);
+ }
+
+ /**
+ * get citizen profile by id
+ * @param citizenId id of citizen
+ * @param filter the citizen filter
+ * @returns the profile of citizen
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({voters: [canAccessHisOwnData]})
+ @get('v1/citizens/profile/{citizenId}', {
+ 'x-controller-name': 'Citizens',
+ summary: "Retourne les informations d'un citoyen",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Citizen model instance',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Citizen),
+ },
+ },
+ },
+ },
+ })
+ async findById(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ @param.filter(Citizen, {exclude: 'where'})
+ filter?: FilterExcludingWhere,
+ ): Promise {
+ return this.citizenRepository.findById(citizenId, filter);
+ }
+
+ /**
+ * get citizen by id
+ * @param citizenId id of citizen
+ * @param filter the citizen filter
+ * @returns one citizen object
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @get('/v1/citizens/{citizenId}', {
+ 'x-controller-name': 'Citizens',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ summary: "Retourne les informations d'un citoyen",
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Citizen model instance',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Citizen),
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: "Ce citoyen n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Citizen not found',
+ path: '/citizenNotFound',
+ resourceName: 'Citizen',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findCitizenId(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ ): Promise> {
+ const user = await this.userRepository.findById(this.currentUser?.id);
+ const citizenData = await this.citizenRepository.findById(citizenId);
+ if (
+ user &&
+ this.currentUser?.roles?.includes('gestionnaires') &&
+ user.funderId === citizenData?.affiliation?.enterpriseId
+ ) {
+ return {
+ lastName: citizenData?.identity?.lastName?.value,
+ firstName: citizenData?.identity?.firstName?.value,
+ };
+ } else {
+ throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden);
+ }
+ }
+
+ /**
+ * affiliate connected citizen
+ * @param data has token needed to affiliate connected citizen
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY)
+ @authorize({
+ allowedRoles: [Roles.SUPERVISORS, Roles.MANAGERS, Roles.API_KEY, Roles.CITIZENS],
+ })
+ @put('/v1/citizens/{citizenId}/affiliate', {
+ 'x-controller-name': 'Citizens',
+ summary: 'Affilie un citoyen à une entreprise',
+ security: SECURITY_SPEC_JWT_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: "L'affiliation est validée",
+ },
+ [StatusCode.NotFound]: {
+ description: "L'affiliation n'existe pas",
+ },
+ [StatusCode.PreconditionFailed]: {
+ description: "L'affiliation n'est pas au bon status",
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "L'affiliation n'est pas valide",
+ },
+ },
+ })
+ async validateAffiliation(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ token: {
+ type: 'string',
+ example: `Un token d'affiliation`,
+ },
+ },
+ },
+ },
+ },
+ })
+ data: {
+ token: string;
+ },
+ ): Promise {
+ const user = this.currentUser;
+
+ let citizen: Citizen | null;
+
+ if (user.id) {
+ citizen = await this.citizenRepository.findOne({
+ where: {id: citizenId},
+ });
+ citizen!.affiliation!.affiliationStatus = AFFILIATION_STATUS.AFFILIATED;
+ } else {
+ citizen = await this.citizenService.checkAffiliation(data.token);
+ citizen.affiliation!.affiliationStatus = AFFILIATION_STATUS.AFFILIATED;
+ }
+
+ await this.citizenRepository.updateById(citizenId, {
+ affiliation: {...citizen!.affiliation},
+ });
+ if (user.id && user.funderName && citizen) {
+ await this.citizenService.sendValidatedAffiliation(citizen, user.funderName);
+ }
+ }
+
+ /**
+ * disaffiliate citizen by id
+ * @param citizenId id citizen
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({
+ allowedRoles: [Roles.MANAGERS, Roles.SUPERVISORS],
+ })
+ @put('/v1/citizens/{citizenId}/disaffiliate', {
+ 'x-controller-name': 'Citizens',
+ summary: "Désaffilie un citoyen d'une entreprise",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'La désaffiliation est validée',
+ },
+ [StatusCode.NotFound]: {
+ description: "Ce citoyen n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Citizen not found',
+ path: '/citizenNotFound',
+ resourceName: 'Citizen',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.PreconditionFailed]: {
+ description: 'La désaffiliation est impossible',
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "La désaffiliation n'est pas valide",
+ },
+ },
+ })
+ async disaffiliation(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ ): Promise {
+ const user = this.currentUser;
+ const citizen: Citizen = await this.citizenRepository.findById(citizenId);
+ const citizenInitialStatus: string = citizen.affiliation!.affiliationStatus;
+ citizen.affiliation!.affiliationStatus = AFFILIATION_STATUS.DISAFFILIATED;
+
+ await this.citizenRepository.updateById(citizen.id, {
+ affiliation: {...citizen.affiliation},
+ });
+
+ if (
+ user &&
+ user?.funderName &&
+ citizenInitialStatus === AFFILIATION_STATUS.TO_AFFILIATE
+ ) {
+ await this.citizenService.sendRejectedAffiliation(citizen, user?.funderName);
+ } else {
+ await this.citizenService.sendDisaffiliationMail(this.mailService, citizen);
+ }
+ }
+
+ /**
+ * Update citizen by id
+ * @param citizenId id of citizen
+ * @param citizen schema
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({voters: [canAccessHisOwnData]})
+ @patch('/v1/citizens/{citizenId}', {
+ 'x-controller-name': 'Citizens',
+ summary: "Modifie des informations d'un citoyen",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Citizen PATCH success',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ example: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "L'email professionnel du salarié existe déjà",
+ },
+ },
+ })
+ async updateById(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(CitizenUpdate),
+ },
+ },
+ })
+ citizen: CitizenUpdate,
+ ): Promise<{id: string}> {
+ /**
+ * init a new citizen data for handling
+ */
+ let affiliationEnterprise = new Enterprise();
+ const newCitizen: {
+ id?: string;
+ city: string;
+ postcode: string;
+ status: CITIZEN_STATUS;
+ affiliation: Affiliation;
+ } = {
+ ...citizen,
+ };
+
+ // Enterprise Verification
+ if (citizen.affiliation?.enterpriseId) {
+ affiliationEnterprise = await this.enterpriseRepository.findById(
+ citizen.affiliation.enterpriseId,
+ );
+ }
+ /**
+ * set a new affiliation object state depending on enterpriseId and enterpriseEmail existence and not empty strings
+ */
+ if (
+ newCitizen?.affiliation?.enterpriseId &&
+ newCitizen?.affiliation?.enterpriseEmail
+ ) {
+ newCitizen.affiliation.affiliationStatus = AFFILIATION_STATUS.TO_AFFILIATE;
+ }
+
+ /**
+ * set or not the new affiliation object state depending on what the form returns
+ */
+ if (
+ (newCitizen?.affiliation &&
+ !newCitizen?.affiliation?.enterpriseId &&
+ newCitizen?.affiliation?.enterpriseEmail) ||
+ (newCitizen?.affiliation &&
+ newCitizen?.affiliation?.enterpriseId &&
+ !newCitizen?.affiliation?.enterpriseEmail &&
+ !affiliationEnterprise?.hasManualAffiliation) ||
+ (newCitizen?.affiliation &&
+ !newCitizen?.affiliation?.enterpriseId &&
+ !newCitizen?.affiliation?.enterpriseEmail)
+ ) {
+ newCitizen.affiliation.enterpriseId || null;
+ newCitizen.affiliation.enterpriseEmail || null;
+ newCitizen.affiliation.affiliationStatus = AFFILIATION_STATUS.UNKNOWN;
+ }
+
+ if (
+ (newCitizen?.affiliation &&
+ newCitizen?.affiliation?.enterpriseId &&
+ newCitizen?.affiliation?.enterpriseEmail) ||
+ (newCitizen?.affiliation &&
+ newCitizen?.affiliation?.enterpriseId &&
+ !newCitizen?.affiliation?.enterpriseEmail &&
+ affiliationEnterprise?.hasManualAffiliation)
+ ) {
+ newCitizen.affiliation.affiliationStatus = AFFILIATION_STATUS.TO_AFFILIATE;
+ }
+ let enterprise: Enterprise;
+
+ /**
+ * get citizen data
+ */
+ const oldCitizen: Citizen = await this.citizenRepository.findById(citizenId);
+
+ if (newCitizen?.affiliation?.enterpriseId) {
+ /**
+ * get the related company by id
+ */
+ enterprise = await this.enterpriseRepository.findById(
+ newCitizen.affiliation.enterpriseId,
+ );
+
+ /**
+ * pre-check and validation of the enterprise data
+ */
+ if (newCitizen?.affiliation?.enterpriseEmail) {
+ /**
+ * Check if the professional email is unique
+ */
+ if (
+ newCitizen?.affiliation?.enterpriseEmail !==
+ oldCitizen?.affiliation?.enterpriseEmail
+ ) {
+ await this.citizenService.checkProEmailExistence(
+ newCitizen?.affiliation?.enterpriseEmail,
+ );
+ }
+
+ /**
+ * validate the professional email pattern
+ */
+ const companyEmail = newCitizen.affiliation.enterpriseEmail;
+ this.citizenService.validateEmailPattern(companyEmail, enterprise?.emailFormat);
+ }
+
+ /**
+ * Send a manual affiliaton mail to the company's funders accepting the manual affiliation
+ */
+ if (!newCitizen?.affiliation.enterpriseEmail && enterprise.hasManualAffiliation) {
+ await this.citizenService.sendManualAffiliationMail(oldCitizen, enterprise);
+ }
+ }
+
+ /**
+ * update the citizen data
+ */
+ await this.citizenRepository.updateById(citizenId, newCitizen);
+
+ /**
+ * proceed to send the affiliation email to the citizen's professional email
+ */
+ if (
+ newCitizen?.affiliation?.affiliationStatus === AFFILIATION_STATUS.TO_AFFILIATE &&
+ newCitizen?.affiliation.enterpriseEmail
+ ) {
+ /**
+ * init the affiliation email payload
+ */
+ const affiliationEmailData: any = {
+ ...newCitizen,
+ id: citizenId,
+ identity: {...oldCitizen.identity},
+ };
+
+ /**
+ * send the affiliation email
+ */
+ await this.citizenService.sendAffiliationMail(
+ this.mailService,
+ affiliationEmailData,
+ enterprise!.name,
+ );
+ }
+
+ return {id: citizenId};
+ }
+
+ /**
+ * Update citizen linked to FC by id
+ * @param citizenId id of citizen
+ * @param citizen schema
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CITIZENS_FC], voters: [canAccessHisOwnData]})
+ @post('/v1/citizens/{citizenId}/complete', {
+ 'x-controller-name': 'Citizens',
+ summary: "Complète le profil d'un citoyen lié à France Connect",
+ security: SECURITY_SPEC_JWT,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Citizen POST success',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ example: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: "L'entreprise du salarié est inconnue",
+ },
+ [StatusCode.PreconditionFailed]: {
+ description:
+ "L'email professionnel du salarié n'est pas du domaine de son entreprise",
+ },
+ [StatusCode.Conflict]: {
+ description: "L'email personnel du salarié existe déjà",
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "L'email professionnel du salarié existe déjà",
+ },
+ },
+ })
+ async createCitizenFc(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Citizen, {
+ title: 'CompleteProfile',
+ exclude: ['password', 'id'],
+ }),
+ },
+ },
+ })
+ register: Omit,
+ ): Promise<{id: string} | undefined> {
+ const result: {id: string} | undefined = await this.citizenService.createCitizen(
+ register,
+ citizenId,
+ );
+ return result;
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({voters: [canAccessHisOwnData]})
+ @get('/v1/citizens/{citizenId}/export', {
+ 'x-controller-name': 'Citizens',
+ summary: 'Exporte les données personnelles du citoyen connecté',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Downloadable .xlsx file with validated aides list',
+ content: {
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
+ schema: {type: 'string', format: 'base64'},
+ },
+ },
+ },
+ },
+ })
+ async generateUserRGPDExcelFile(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ @inject(RestBindings.Http.RESPONSE) resp: Response,
+ ): Promise> {
+ try {
+ const citizen: Citizen = await this.citizenRepository.findById(citizenId);
+
+ const listMaas: string[] = await this.citizenService.getListMaasNames(
+ citizen?.email,
+ );
+
+ // get company name & company email from user affiliation
+ let companyName: string = '';
+ if (citizen?.affiliation?.enterpriseId) {
+ const enterprise: Enterprise = await this.enterpriseRepository.findById(
+ citizen?.affiliation?.enterpriseId,
+ );
+ companyName = enterprise.name;
+ }
+
+ // get all user subscriptions
+ const subscriptions: Subscription[] = await this.subscriptionRepository.find({
+ order: ['updatedAT ASC'],
+ where: {citizenId: this.currentUser.id, status: {neq: SUBSCRIPTION_STATUS.DRAFT}},
+ });
+
+ // get all aides {id, title, specificFields}
+ const incentives: Incentive[] = await this.incentiveRepository.find({
+ fields: {id: true, title: true, specificFields: true},
+ });
+
+ // generate Excel RGPD file that contains all user private data
+ const excelBufferRGPD: Excel.Buffer = await this.citizenService.generateExcelRGPD(
+ citizen,
+ companyName,
+ subscriptions,
+ incentives,
+ listMaas,
+ );
+
+ // send the file to user
+ return resp
+ .status(200)
+ .contentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+ .send(excelBufferRGPD);
+ } catch (error) {
+ throw new ValidationError(
+ 'Le téléchargement a échoué, veuillez réessayer',
+ '/downloadXlsx',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+ );
+ }
+ }
+
+ /**
+ * Delete citizen account
+ * @param citizenId id citizen
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({voters: [canAccessHisOwnData]})
+ @put('/v1/citizens/{citizenId}/delete', {
+ 'x-controller-name': 'Citizens',
+ summary: "Supprimer le compte d'un citoyen",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'La suppression est effectuée',
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur n'est pas connecté",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 401,
+ name: 'Error',
+ message: 'Authorization header not found',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description:
+ "Vous n'avez pas les droits pour supprimer le compte de cet utilisateur",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async deleteCitizenAccount(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ ): Promise {
+ const citizen: Citizen = await this.citizenRepository.findById(citizenId);
+
+ // Delete citizen from MongoDB
+ await this.citizenRepository.deleteById(citizen.id);
+
+ // Delete citizen from Keycloak
+ await this.kcService.deleteUserKc(citizen.id);
+
+ // ADD Flag "Compte Supprimé" to citizen Subscription
+ const citizenSubscriptions = await this.subscriptionRepository.find({
+ where: {citizenId: citizen.id},
+ });
+
+ citizenSubscriptions?.forEach(async citizenSubscription => {
+ citizenSubscription.isCitizenDeleted = true;
+ await this.subscriptionRepository.updateById(
+ citizenSubscription.id,
+ citizenSubscription,
+ );
+ });
+
+ const date = formatDateInTimezone(new Date(), "dd/MM/yyyy à H'h'mm");
+ await this.citizenService.sendDeletionMail(this.mailService, citizen, date);
+ }
+
+ /**
+ * get consents by citizen id
+ * @param citizenId id of citizen
+ * @returns liste des clients en consentement avec le citoyen
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({voters: [canAccessHisOwnData]})
+ @get('/v1/citizens/{citizenId}/linkedAccounts', {
+ 'x-controller-name': 'Citizens',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ summary: "Retourne le nom et l'ID des clients en consentement avec le citoyen",
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Modèle des informations du consentement',
+ content: {
+ 'application/json': {
+ example: {
+ clientInfo: {
+ clientName: 'Mulhouse',
+ clientId: 'mulhouse-maas-client',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description: "Vous n'êtes autorisé à consulter que vos propres consentements",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findConsentsById(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ ): Promise<(ClientOfConsent | undefined)[]> {
+ const citizenConsentsList = await this.kcService.listConsents(citizenId);
+ const clientsList = await this.citizenService.getClientList();
+
+ const consentListData = citizenConsentsList.map((element: Consent) =>
+ clientsList.find(clt => element.clientId === clt.clientId),
+ );
+
+ return consentListData;
+ }
+
+ /**
+ * delete consent by id
+ * @param citizenId id of citizen
+ * @param clientId id du client en consentement avec le citoyen
+ */
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({voters: [canAccessHisOwnData]})
+ @del('/v1/citizens/{citizenId}/linkedAccounts/{clientId}', {
+ 'x-controller-name': 'Citizens',
+ summary: 'Supprime un consentement',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'Consentement supprimé',
+ },
+ [StatusCode.Forbidden]: {
+ description: 'Vous ne pouvez supprimer que vos propres consentements',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: 'Consentement introuvable',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'consent not found',
+ path: '/consentNotFound',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async deleteConsentById(
+ @param.path.string('citizenId', {
+ description: `L'identifiant du citoyen`,
+ })
+ citizenId: string,
+ @param.path.string('clientId', {
+ description: `L'identifiant du client en consentement avec le citoyen`,
+ })
+ clientId: string,
+ ): Promise {
+ await this.kcService.deleteConsent(citizenId, clientId);
+ }
+}
diff --git a/api/src/controllers/collectivity.controller.ts b/api/src/controllers/collectivity.controller.ts
new file mode 100644
index 0000000..5a849ba
--- /dev/null
+++ b/api/src/controllers/collectivity.controller.ts
@@ -0,0 +1,135 @@
+import {inject} from '@loopback/core';
+import {Count, CountSchema, repository, Where} from '@loopback/repository';
+import {post, param, get, getModelSchemaRef, requestBody} from '@loopback/rest';
+import {pick} from 'lodash';
+import {authorize} from '@loopback/authorization';
+import {authenticate} from '@loopback/authentication';
+
+import {Collectivity} from '../models';
+import {CollectivityRepository} from '../repositories';
+import {KeycloakService} from '../services';
+import {
+ GROUPS,
+ Roles,
+ StatusCode,
+ SECURITY_SPEC_KC_PASSWORD,
+ AUTH_STRATEGY,
+} from '../utils';
+
+@authenticate(AUTH_STRATEGY.KEYCLOAK)
+@authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+export class CollectivityController {
+ constructor(
+ @repository(CollectivityRepository)
+ public collectivityRepository: CollectivityRepository,
+ @inject('services.KeycloakService')
+ public kcService: KeycloakService,
+ ) {}
+
+ /**
+ * Create one collectivity
+ * @param collectivity schema
+ */
+ @post('/v1/collectivities', {
+ 'x-controller-name': 'Collectivities',
+ summary: 'Crée une collectivité',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Nouvelle collectivité',
+ content: {'application/json': {schema: getModelSchemaRef(Collectivity)}},
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Collectivity),
+ },
+ },
+ })
+ collectivity: Collectivity,
+ ): Promise {
+ let keycloakGroupCreationResult;
+
+ try {
+ const {name} = collectivity;
+ keycloakGroupCreationResult = await this.kcService.createGroupKc(
+ name,
+ GROUPS.collectivities,
+ );
+
+ if (keycloakGroupCreationResult && keycloakGroupCreationResult.id) {
+ const collectivityModel = pick(collectivity, [
+ 'name',
+ 'citizensCount',
+ 'mobilityBudget',
+ 'encryptionKey',
+ ]);
+
+ const RepositoryCollectivityCreationResult =
+ await this.collectivityRepository.create({
+ ...collectivityModel,
+ ...keycloakGroupCreationResult,
+ });
+
+ return RepositoryCollectivityCreationResult;
+ }
+ } catch (error) {
+ if (keycloakGroupCreationResult && keycloakGroupCreationResult.id)
+ await this.kcService.deleteGroupKc(keycloakGroupCreationResult.id);
+ throw error;
+ }
+ }
+
+ /**
+ * Count the collectivities
+ * @param where the collectivity where filter
+ * @returns the number the collectivities
+ */
+ @get('/v1/collectivities/count', {
+ 'x-controller-name': 'Collectivities',
+ summary: 'Retourne le nombre de collectivités',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Collectivity model count',
+ content: {
+ 'application/json': {
+ schema: {...CountSchema, ...{title: 'Count'}},
+ },
+ },
+ },
+ },
+ })
+ async count(@param.where(Collectivity) where?: Where): Promise {
+ return this.collectivityRepository.count(where);
+ }
+
+ /**
+ * Get all collectivities
+ * @returns all collectivities
+ */
+ @get('/v1/collectivities', {
+ 'x-controller-name': 'Collectivities',
+ summary: 'Retourne les collectivités',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of Collectivity model instances',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: getModelSchemaRef(Collectivity),
+ },
+ },
+ },
+ },
+ },
+ })
+ async find(): Promise {
+ return this.collectivityRepository.find();
+ }
+}
diff --git a/api/src/controllers/contact.controller.ts b/api/src/controllers/contact.controller.ts
new file mode 100644
index 0000000..d7c50d6
--- /dev/null
+++ b/api/src/controllers/contact.controller.ts
@@ -0,0 +1,72 @@
+import {inject} from '@loopback/core';
+import {getModelSchemaRef, post, requestBody} from '@loopback/rest';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+
+import {Contact} from '../models';
+import {ContactService, MailService} from '../services';
+import {formatDateInTimezone} from '../utils/date';
+import {
+ AUTH_STRATEGY,
+ ResourceName,
+ Roles,
+ SECURITY_SPEC_API_KEY,
+ StatusCode,
+} from '../utils';
+import {ValidationError} from '../validationError';
+
+export class ContactController {
+ constructor(
+ @inject('services.ContactService')
+ public contactService: ContactService,
+ @inject('services.MailService')
+ public mailService: MailService,
+ ) {}
+
+ /**
+ * Send contact form
+ * @param contact object from contact form
+ */
+ @authenticate(AUTH_STRATEGY.API_KEY)
+ @authorize({allowedRoles: [Roles.API_KEY]})
+ @post('v1/contact', {
+ 'x-controller-name': 'Contact',
+ summary: "Envoi d'un formulaire de contact",
+ security: SECURITY_SPEC_API_KEY,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Formulaire de contact',
+ content: {'application/json': {schema: getModelSchemaRef(Contact)}},
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Contact),
+ },
+ },
+ })
+ contact: Contact,
+ ): Promise {
+ if (!contact.tos) {
+ throw new ValidationError(
+ `User must agree to terms of services`,
+ '/tos',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Contact,
+ );
+ }
+ try {
+ const contactDate = formatDateInTimezone(new Date(), 'dd/MM/yyyy');
+ return await this.contactService.sendMailClient(
+ this.mailService,
+ contact,
+ contactDate,
+ );
+ } catch (error) {
+ return error;
+ }
+ }
+}
diff --git a/api/src/controllers/dashboard.controller.ts b/api/src/controllers/dashboard.controller.ts
new file mode 100644
index 0000000..0c53763
--- /dev/null
+++ b/api/src/controllers/dashboard.controller.ts
@@ -0,0 +1,438 @@
+import {AnyObject, repository} from '@loopback/repository';
+import {param, get} from '@loopback/rest';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+import {SecurityBindings} from '@loopback/security';
+import {inject} from '@loopback/core';
+
+import {SubscriptionRepository, UserRepository} from '../repositories';
+import {calculatePercentage, setValidDate, isFirstElementGreater} from './utils/helpers';
+import {
+ SECURITY_SPEC_KC_PASSWORD,
+ Roles,
+ SUBSCRIPTION_STATUS,
+ IDashboardCitizen,
+ IDashboardCitizenIncentiveList,
+ IDashboardSubscription,
+ IDashboardSubscriptionResult,
+ StatusCode,
+ AUTH_STRATEGY,
+ IUser,
+} from '../utils';
+import {Subscription, SubscriptionRelations} from '../models';
+
+/**
+ * CONTROLLERS
+ *
+ *
+ * dashboard controller function and api endpoints
+ */
+@authenticate(AUTH_STRATEGY.KEYCLOAK)
+@authorize({allowedRoles: [Roles.FUNDERS]})
+export class DashboardController {
+ constructor(
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @repository(UserRepository)
+ public userRepository: UserRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ ) {}
+
+ /**
+ * get the citizens for dashboard
+ * @param year the filter year value
+ * @param semester the filter semester value
+ * @returns an object with the incentive list + citizens statistics
+ */
+ @get('/v1/dashboards/citizens', {
+ 'x-controller-name': 'Dashboards',
+ summary:
+ 'Retourne le nombre de citoyens ayant une souscription' +
+ ' validée concernant le financeur connecté',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Count of citizen with at least one validated subscription',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ incentiveList: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ incentiveId: {
+ description: `Identifiant de l'aide`,
+ type: 'string',
+ example: '',
+ },
+ incentiveTitle: {
+ description: `Titre de l'aide`,
+ type: 'string',
+ example: 'Le vélo électrique arrive à Mulhouse !',
+ },
+ totalSubscriptionsCount: {
+ description: `Nombre de souscriptions validées selon l'aide`,
+ type: 'number',
+ example: 1,
+ },
+ validatedSubscriptionPercentage: {
+ description: `Pourcentage de souscriptions validées par citoyen`,
+ type: 'number',
+ example: 25,
+ },
+ },
+ },
+ },
+ totalCitizensCount: {
+ description: `Nombre de citoyens ayant réalisé au moins une demande`,
+ type: 'number',
+ example: 4,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findCitizen(
+ @param.query.string('year', {
+ description: `Filtre sur l'année de validation des demandes d'aide`,
+ })
+ year: string,
+ @param.query.string('semester', {
+ description: `Filtre sur le semestre de validation des demandes d'aide`,
+ })
+ semester: string,
+ ): Promise {
+ /**
+ * get he user community ids list
+ */
+ const {id, roles, funderName, incentiveType} = this.currentUser;
+ const user = await this.userRepository.findOne({where: {id}});
+ const communityIds = user?.communityIds;
+
+ /**
+ * set the search query start and end dates value
+ */
+ const searchQueryDate = setValidDate(year, semester);
+ const {startDate, endDate} = searchQueryDate;
+
+ /**
+ * define the user role persona
+ */
+ const isSupervisor = roles?.includes(Roles.SUPERVISORS);
+ const isManager = roles?.includes(Roles.MANAGERS);
+
+ /**
+ * init a basic query search params
+ */
+ let withParams: object = {
+ incentiveType: incentiveType,
+ funderName: funderName,
+ status: SUBSCRIPTION_STATUS.VALIDATED,
+ updatedAt: {between: [startDate, endDate]},
+ };
+
+ /**
+ * inject the communityId param when checking if the user is belonging
+ * to any funder communities, for now only the user with Gestionnaire role
+ * can belong to a funder community.
+ * for this condition communityId with values means it's a Gestionnaire Role
+ * or if communityId returns undefined that means it's a Supervisor
+ */
+ if (communityIds && communityIds.length > 0 && !isSupervisor && isManager) {
+ withParams = {
+ ...withParams,
+ communityId: {inq: communityIds},
+ };
+ }
+
+ /**
+ * query the subscription repository
+ */
+ const querySubscription = await this.subscriptionRepository.find({
+ where: withParams,
+ fields: {
+ id: true,
+ incentiveId: true,
+ incentiveTitle: true,
+ citizenId: true,
+ },
+ });
+
+ /**
+ * group the subscription list by incentive id
+ */
+ const groupSubscriptionsByIncentive = querySubscription.reduce(
+ (groups: AnyObject, item: Subscription & SubscriptionRelations) => {
+ const group = groups[item.incentiveId] || [];
+
+ const found = group.some(
+ (el: {citizenId: string}) => el.citizenId === item.citizenId,
+ );
+
+ if (!found) group.push(item);
+ groups[item.incentiveId] = group;
+
+ return groups;
+ },
+ {},
+ );
+
+ /**
+ * merge all the subscriptions grouped by incentive result in one list
+ */
+ const newQuerySubscription: AnyObject = Object.values(
+ groupSubscriptionsByIncentive,
+ ).flat();
+
+ /**
+ * init new subscriptions list
+ */
+ const uniqueCitizensList: string[] = [];
+ let incentiveList: IDashboardCitizenIncentiveList[] = [];
+
+ /**
+ * crete the subscriptions list and count unique subscriptions by citizen id
+ */
+ newQuerySubscription.forEach(
+ (
+ item: {citizenId: string; incentiveId: string; incentiveTitle: string},
+ index: number,
+ ) => {
+ /**
+ * set the new unique citizen list
+ */
+ const citizenIdFound = uniqueCitizensList.some(
+ el => el === newQuerySubscription[index].citizenId,
+ );
+
+ if (!citizenIdFound) uniqueCitizensList.push(item.citizenId);
+
+ /**
+ * create a new demand element
+ */
+ const elementIndex = incentiveList.findIndex(
+ (element: {incentiveId: string}) =>
+ element.incentiveId.toString() === item.incentiveId.toString(),
+ );
+
+ if (elementIndex >= 0) {
+ incentiveList[elementIndex].totalSubscriptionsCount =
+ incentiveList[elementIndex].totalSubscriptionsCount + 1;
+ } else {
+ incentiveList.push({
+ incentiveId: item.incentiveId,
+ incentiveTitle: item.incentiveTitle,
+ totalSubscriptionsCount: 1,
+ } as IDashboardCitizenIncentiveList);
+ }
+ },
+ );
+
+ /**
+ * sort the subscription list by total subscription count
+ */
+ incentiveList.sort(isFirstElementGreater);
+
+ /**
+ * set the percentage of validated subscription by group
+ */
+ incentiveList = calculatePercentage(incentiveList, uniqueCitizensList.length);
+
+ /**
+ * return subscription statistic by incentive group
+ */
+ return {
+ incentiveList: incentiveList,
+ totalCitizensCount: uniqueCitizensList.length,
+ } as IDashboardCitizen;
+ }
+
+ /**
+ * get the subscriptions for the dashboard
+ * @param year the selected year value
+ * @param semester the selected semester value
+ * @returns an object with the query result + subscriptions statistics
+ */
+ @get('/v1/dashboards/subscriptions', {
+ 'x-controller-name': 'Dashboards',
+ summary:
+ 'Retourne le nombre de souscriptions par statut concernant le financeur connecté',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Count of subscription per status',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ result: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ status: {
+ description: `Statut de la souscription`,
+ type: 'string',
+ example: SUBSCRIPTION_STATUS.TO_PROCESS,
+ },
+ count: {
+ description: `Nombre de souscriptions pour le statut`,
+ type: 'number',
+ example: 5,
+ },
+ },
+ },
+ },
+ totalCount: {
+ description: `Nombre de total de souscriptions tous status confondus`,
+ type: 'number',
+ example: 25,
+ },
+ totalPending: {
+ type: 'object',
+ properties: {
+ count: {
+ description: `Nombre de demande sur le périmètre de l'utilisateur connecté`,
+ type: 'number',
+ example: 3,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async find(
+ @param.query.string('year') year: string,
+ @param.query.string('semester') semester: string,
+ ): Promise {
+ /**
+ * get he user community ids list
+ */
+ const {id, roles, funderName, incentiveType} = this.currentUser;
+ const user = await this.userRepository.findOne({where: {id}});
+ const communityIds = user?.communityIds;
+
+ /**
+ * set the search query start and end dates value
+ */
+ const searchQueryDate = setValidDate(year, semester);
+ const {startDate, endDate} = searchQueryDate;
+
+ /**
+ * define the user role persona
+ */
+ const isSupervisor = roles?.includes(Roles.SUPERVISORS);
+ const isManager = roles?.includes(Roles.MANAGERS);
+
+ /**
+ * init a basic query search params
+ */
+ let queryParams: object = {
+ incentiveType: incentiveType,
+ funderName: funderName,
+ status: {neq: SUBSCRIPTION_STATUS.DRAFT},
+ updatedAt: {between: [startDate, endDate]},
+ };
+
+ /**
+ * inject the communityId param when checking if the user is belonging
+ * to any funder communities, for now only the user with Gestionnaire role
+ * can belong to a funder community.
+ * for this condition communityId with values means it's a Gestionnaire Role
+ * or if communityId returns undefined that means it's a Supervisor
+ */
+ if (communityIds && communityIds.length > 0 && !isSupervisor && isManager) {
+ queryParams = {
+ ...queryParams,
+ communityId: {inq: communityIds},
+ };
+ }
+
+ /**
+ * query the subscription repository
+ */
+ const querySubscription = await this.subscriptionRepository.find({
+ where: queryParams,
+ fields: {
+ status: true,
+ },
+ });
+
+ if (communityIds && communityIds.length > 0 && isManager) {
+ queryParams = {
+ ...queryParams,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ communityId: {inq: communityIds},
+ };
+ }
+
+ /**
+ * If user has no communities, inject the status param so that the returned "totalPending" handles only the total of pending subscriptions
+ */
+ if (!communityIds) {
+ queryParams = {
+ ...queryParams,
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ };
+ }
+
+ /**
+ * set the pending subscriptions count to be managed by the manager depending on his community
+ */
+ let totalPending = {count: 0};
+ if (isManager) {
+ totalPending = await this.subscriptionRepository.count(queryParams);
+ }
+
+ /**
+ * group the demands query result by status and set the count of it
+ */
+ const newDemandsGroupList: IDashboardSubscriptionResult[] = [];
+ querySubscription.map((groupEl: {status: SUBSCRIPTION_STATUS}) => {
+ const elementIndex = newDemandsGroupList.findIndex(
+ (element: IDashboardSubscriptionResult) => element.status === groupEl.status,
+ );
+
+ if (elementIndex >= 0) {
+ newDemandsGroupList[elementIndex].count =
+ newDemandsGroupList[elementIndex].count + 1;
+ } else {
+ newDemandsGroupList.push({
+ status: groupEl.status,
+ count: 1,
+ });
+ }
+ });
+
+ /**
+ * set the global count by auditioning all the demands status count
+ */
+ const totalCount: number = newDemandsGroupList.reduce(
+ (sum: number, currentValue: {count: number}) => {
+ return sum + currentValue.count;
+ },
+ 0,
+ );
+
+ /**
+ * final result return
+ */
+ return {
+ result: newDemandsGroupList,
+ totalPending,
+ totalCount,
+ };
+ }
+}
diff --git a/api/src/controllers/enterprise.controller.ts b/api/src/controllers/enterprise.controller.ts
new file mode 100644
index 0000000..f8ab48c
--- /dev/null
+++ b/api/src/controllers/enterprise.controller.ts
@@ -0,0 +1,262 @@
+import {inject, intercept} from '@loopback/core';
+import {Count, CountSchema, repository, Where} from '@loopback/repository';
+import {post, param, get, getModelSchemaRef, requestBody} from '@loopback/rest';
+import {pick} from 'lodash';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+
+import {KeycloakService} from '../services';
+import {
+ SECURITY_SPEC_KC_PASSWORD,
+ GROUPS,
+ Roles,
+ StatusCode,
+ SECURITY_SPEC_API_KEY,
+ AUTH_STRATEGY,
+ ResourceName,
+} from '../utils';
+import {ValidationError} from '../validationError';
+import {Enterprise} from '../models/enterprise';
+import {EnterpriseRepository} from '../repositories/enterprise.repository';
+import {EnterpriseInterceptor} from '../interceptors';
+import {UserEntityRepository} from '../repositories';
+
+export class EnterpriseController {
+ constructor(
+ @repository(EnterpriseRepository)
+ public enterpriseRepository: EnterpriseRepository,
+ @repository(UserEntityRepository)
+ public userEntityRepository: UserEntityRepository,
+ @inject('services.KeycloakService')
+ public kcService: KeycloakService,
+ ) {}
+
+ /**
+ * Create one enterprise
+ * @param enterprise the object to create
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @intercept(EnterpriseInterceptor.BINDING_KEY)
+ @post('/v1/enterprises', {
+ 'x-controller-name': 'Enterprises',
+ summary: 'Crée une entreprise',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Nouveau compte entreprise',
+ content: {'application/json': {schema: getModelSchemaRef(Enterprise)}},
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description:
+ "Les formats des adresses email de l'entreprise ne sont pas valides.",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 422,
+ name: 'Error',
+ message: 'Enterprise email formats are not valid',
+ path: '/enterpriseEmailBadFormat',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.PreconditionFailed]: {
+ description:
+ 'Vous nous pouvez pas activez les 2 options SI RH et Affiliation Manuelle',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 412,
+ name: 'Error',
+ message: 'enterprise.options.invalid',
+ path: '/enterpriseInvalidOptions',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Enterprise),
+ },
+ },
+ })
+ enterprise: Enterprise,
+ ): Promise {
+ let keycloakGroupCreationResult: {id: string} | undefined;
+
+ /**
+ * Throw an error if the two options are true
+ */
+ if (enterprise.hasManualAffiliation && enterprise.isHris) {
+ throw new ValidationError(
+ `enterprise.options.invalid`,
+ `/enterpriseInvalidOptions`,
+ StatusCode.PreconditionFailed,
+ ResourceName.Enterprise,
+ );
+ }
+
+ try {
+ const {name} = enterprise;
+ keycloakGroupCreationResult = await this.kcService.createGroupKc(
+ name,
+ GROUPS.enterprises,
+ );
+
+ if (keycloakGroupCreationResult && keycloakGroupCreationResult.id) {
+ const enterpriseModel = pick(enterprise, [
+ 'name',
+ 'siretNumber',
+ 'emailFormat',
+ 'employeesCount',
+ 'budgetAmount',
+ 'isHris',
+ 'hasManualAffiliation',
+ 'encryptionKey',
+ 'clientId',
+ ]);
+ if (enterprise.clientId) {
+ const serviceUser = await this.userEntityRepository.getServiceUser(
+ enterprise.clientId,
+ );
+ await this.kcService.addUserGroupMembership(
+ serviceUser!.id,
+ keycloakGroupCreationResult.id,
+ );
+ }
+ const RepositoryEnterpriseCreationResult = await this.enterpriseRepository.create(
+ {
+ ...enterpriseModel,
+ ...keycloakGroupCreationResult,
+ },
+ );
+ return RepositoryEnterpriseCreationResult;
+ }
+ } catch (error) {
+ if (keycloakGroupCreationResult && keycloakGroupCreationResult.id)
+ await this.kcService.deleteGroupKc(keycloakGroupCreationResult.id);
+ throw error;
+ }
+ }
+
+ /**
+ * Count the enterprises
+ * @param where the enterprise where filter
+ * @returns the number of enterprises
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/enterprises/count', {
+ 'x-controller-name': 'Enterprises',
+ summary: "Retourne le nombre d'entreprises",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Enterprise model count',
+ content: {
+ 'application/json': {
+ schema: {...CountSchema, ...{title: 'Count'}},
+ },
+ },
+ },
+ },
+ })
+ async count(@param.where(Enterprise) where?: Where): Promise {
+ return this.enterpriseRepository.count(where);
+ }
+
+ /**
+ * Get all enterprises
+ * @returns All enterprises
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY)
+ @authorize({
+ allowedRoles: [Roles.SUPERVISORS, Roles.MANAGERS, Roles.CONTENT_EDITOR],
+ })
+ @get('/v1/enterprises', {
+ 'x-controller-name': 'Enterprises',
+ summary: 'Retourne les entreprises',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of entreprises model instances',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: getModelSchemaRef(Enterprise),
+ },
+ },
+ },
+ },
+ },
+ })
+ async find(): Promise {
+ return this.enterpriseRepository.find();
+ }
+
+ /**
+ * Check the email format
+ * @returns if the email format is ok
+ */
+ @authenticate(AUTH_STRATEGY.API_KEY)
+ @authorize({allowedRoles: [Roles.API_KEY]})
+ @get('/v1/enterprises/email_format_list', {
+ 'x-controller-name': 'Enterprises',
+ summary: 'Retourne les informations détaillées des entreprises',
+ security: SECURITY_SPEC_API_KEY,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of entreprises model instances',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ name: {
+ description: `Nom de l'entreprise`,
+ type: 'string',
+ example: 'Capgemini',
+ },
+ id: {
+ description: `Identifiant du l'entreprise`,
+ type: 'string',
+ example: '',
+ },
+ emailFormat: {
+ description: `Modèles d'email de l'entreprise`,
+ type: 'array',
+ items: {
+ type: 'string',
+ example: '@professional.com',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findEmailFormat(): Promise[]> {
+ const result: Pick[] =
+ await this.enterpriseRepository.find({
+ fields: {name: true, id: true, emailFormat: true},
+ });
+ return result;
+ }
+}
diff --git a/api/src/controllers/external/funderV1.controller.ts b/api/src/controllers/external/funderV1.controller.ts
new file mode 100644
index 0000000..8ec587a
--- /dev/null
+++ b/api/src/controllers/external/funderV1.controller.ts
@@ -0,0 +1,62 @@
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+import {repository} from '@loopback/repository';
+import {param, get} from '@loopback/rest';
+import {intercept} from '@loopback/core';
+
+import {Community} from '../../models';
+import {CommunityRepository} from '../../repositories';
+import {checkMaas} from '../../services';
+import {AffiliationInterceptor} from '../../interceptors';
+import {StatusCode, SECURITY_SPEC_JWT, AUTH_STRATEGY} from '../../utils';
+
+export class FunderV1Controller {
+ constructor(
+ @repository(CommunityRepository)
+ public communityRepository: CommunityRepository,
+ ) {}
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: ['maas'], voters: [checkMaas]})
+ @intercept(AffiliationInterceptor.BINDING_KEY)
+ @get('v1/maas/funders/{funderId}/communities', {
+ 'x-controller-name': 'Funders',
+ summary: "Retourne les communautés d'un financeur",
+ security: SECURITY_SPEC_JWT,
+ responses: {
+ [StatusCode.Success]: {
+ description: "La liste des communautés d'un financeur.",
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ description: 'Identifiant de la communauté',
+ },
+ name: {
+ type: 'string',
+ description: 'Nom de la communauté',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findCommunitiesByFunderId(
+ @param.path.string('funderId', {
+ description: `L'identifiant du financeur`,
+ })
+ funderId: string,
+ ): Promise<{id: string | undefined; name: string}[]> {
+ const communities: Community[] = await this.communityRepository.findByFunderId(
+ funderId,
+ );
+ return communities.map(({id, name}) => ({id, name}));
+ }
+}
diff --git a/api/src/controllers/external/index.ts b/api/src/controllers/external/index.ts
new file mode 100644
index 0000000..6f7d8ec
--- /dev/null
+++ b/api/src/controllers/external/index.ts
@@ -0,0 +1,2 @@
+export * from './subscriptionV1.controller';
+export * from './funderV1.controller';
diff --git a/api/src/controllers/external/subscriptionV1.controller.ts b/api/src/controllers/external/subscriptionV1.controller.ts
new file mode 100644
index 0000000..ba96f7e
--- /dev/null
+++ b/api/src/controllers/external/subscriptionV1.controller.ts
@@ -0,0 +1,592 @@
+import {HttpErrors, param, toInterceptor} from '@loopback/rest';
+import {inject, intercept, service} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {get, post, getModelSchemaRef, requestBody} from '@loopback/rest';
+import {authorize} from '@loopback/authorization';
+import {authenticate} from '@loopback/authentication';
+import {SecurityBindings} from '@loopback/security';
+import multer from 'multer';
+import {Express} from 'express';
+import {capitalize} from 'lodash';
+import {WEBSITE_FQDN} from '../../constants';
+
+import {
+ AffiliationInterceptor,
+ SubscriptionV1Interceptor,
+ SubscriptionV1AttachmentsInterceptor,
+ SubscriptionV1FinalizeInterceptor,
+} from '../../interceptors';
+import {
+ Subscription,
+ AttachmentType,
+ Incentive,
+ CreateSubscription,
+ ValidationMultiplePayment,
+ ValidationSinglePayment,
+ ValidationNoPayment,
+ NoReason,
+ OtherReason,
+ Metadata,
+ Citizen,
+ EncryptionKey,
+ Collectivity,
+ Enterprise,
+} from '../../models';
+import {
+ IncentiveRepository,
+ CitizenRepository,
+ SubscriptionRepository,
+ CommunityRepository,
+ EnterpriseRepository,
+ MetadataRepository,
+ CollectivityRepository,
+} from '../../repositories';
+import {
+ checkMaas,
+ MailService,
+ S3Service,
+ RabbitmqService,
+ SubscriptionService,
+} from '../../services';
+import {validationErrorExternalHandler} from '../../validationErrorExternal';
+import {
+ StatusCode,
+ SECURITY_SPEC_JWT,
+ SECURITY_SPEC_JWT_KC_PASSWORD,
+ SUBSCRIPTION_STATUS,
+ Roles,
+ AUTH_STRATEGY,
+ MaasSubscriptionList,
+ IUser,
+} from '../../utils';
+import {generatePdfInvoices} from '../../utils/invoice';
+import {INCENTIVE_TYPE} from '../../utils/enum';
+import {encryptFileHybrid, generateAESKey, encryptAESKey} from '../../utils/encryption';
+
+const multerInterceptor = toInterceptor(multer({storage: multer.memoryStorage()}).any());
+@authenticate(AUTH_STRATEGY.KEYCLOAK)
+export class SubscriptionV1Controller {
+ constructor(
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ @repository(CitizenRepository)
+ public citizenRepository: CitizenRepository,
+ @repository(MetadataRepository)
+ public metadataRepository: MetadataRepository,
+ @repository(EnterpriseRepository)
+ public enterpriseRepository: EnterpriseRepository,
+ @repository(CommunityRepository)
+ public communityRepository: CommunityRepository,
+ @service(RabbitmqService)
+ public rabbitmqService: RabbitmqService,
+ @service(S3Service)
+ private s3Service: S3Service,
+ @service(MailService)
+ public mailService: MailService,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ @service(SubscriptionService)
+ public subscriptionService: SubscriptionService,
+ @repository(CollectivityRepository)
+ public collectivityRepository: CollectivityRepository,
+ ) {}
+
+ @authorize({allowedRoles: [Roles.MAAS, Roles.PLATFORM], voters: [checkMaas]})
+ @intercept(AffiliationInterceptor.BINDING_KEY)
+ @intercept(SubscriptionV1Interceptor.BINDING_KEY)
+ @post('v1/maas/subscriptions', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Crée une souscription',
+ security: SECURITY_SPEC_JWT_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description:
+ "Création initiale d'une demande d'aide où additionalProp correspond aux champs spécifiques",
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ example: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ },
+ [StatusCode.Forbidden]: {
+ description: "L'utilisateur n'a pas les droits pour souscrire à cette aide",
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: 'La demande ne peut pas être traitée',
+ },
+ },
+ })
+ async createSubscription(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(CreateSubscription),
+ },
+ },
+ })
+ subscription: CreateSubscription,
+ ): Promise<{id: string} | HttpErrors.HttpError> {
+ try {
+ const incentive = await this.incentiveRepository.findById(subscription.incentiveId);
+ const citizen = await this.citizenRepository.findById(this.currentUser.id);
+ const newSubscription = new Subscription({
+ ...subscription,
+ incentiveTitle: incentive.title,
+ incentiveTransportList: incentive.transportList,
+ citizenId: citizen.id,
+ lastName: citizen.identity.lastName.value,
+ firstName: citizen.identity.firstName.value,
+ email: citizen.email,
+ city: citizen.city,
+ postcode: citizen.postcode,
+ birthdate: citizen.identity.birthDate.value,
+ status: SUBSCRIPTION_STATUS.DRAFT,
+ funderName: incentive.funderName,
+ incentiveType: incentive.incentiveType,
+ funderId: incentive.funderId,
+ isCitizenDeleted: false,
+ });
+
+ // Add company email to subscription object if exists
+ if (citizen.affiliation?.enterpriseEmail) {
+ newSubscription.enterpriseEmail = citizen.affiliation.enterpriseEmail;
+ }
+
+ const result = await this.subscriptionRepository.create(newSubscription);
+ return {id: result.id};
+ } catch (error) {
+ return validationErrorExternalHandler(error);
+ }
+ }
+
+ @authorize({allowedRoles: [Roles.MAAS, Roles.PLATFORM], voters: [checkMaas]})
+ @intercept(multerInterceptor)
+ @intercept(AffiliationInterceptor.BINDING_KEY)
+ @intercept(SubscriptionV1AttachmentsInterceptor.BINDING_KEY)
+ @post('v1/maas/subscriptions/{subscriptionId}/attachments', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Ajoute des justificatifs à une souscription',
+ security: SECURITY_SPEC_JWT_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description:
+ "Ajouter les justificatifs pour une demande d'aide avec un status brouillon",
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ example: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ },
+ [StatusCode.Forbidden]: {
+ description:
+ "L'utilisateur n'a pas les droits pour ajouter des justificatifs à la demande",
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette demande n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Subscription not found',
+ path: '/subscriptionNotFound',
+ resourceName: 'Subscription',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.PreconditionFailed]: {
+ description:
+ 'La demande ou les justificatifs ne rencontrent pas les bonnes conditions',
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: 'Les justificatifs ne peuvent pas être traités',
+ },
+ },
+ })
+ async addAttachments(
+ @param.path.string('subscriptionId', {description: `L'identifiant de la demande`})
+ subscriptionId: string,
+ @requestBody({
+ description: `Multipart/form-data pour la creation d'une demande`,
+ content: {
+ 'multipart/form-data': {
+ // Skip body parsing
+ 'x-parser': 'stream',
+ schema: {
+ type: 'object',
+ properties: {
+ attachmentExample: {
+ description: `Exemple d'ajout d'un document justificatif`,
+ type: 'array',
+ items: {
+ format: 'binary',
+ type: 'string',
+ },
+ },
+ data: {
+ type: 'object',
+ description: `Ajout des metadataId`,
+ properties: {
+ metadataId: {
+ type: 'string',
+ },
+ },
+ example: {
+ metadataId: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ attachmentData?: any,
+ ): Promise<{id: string} | HttpErrors.HttpError> {
+ try {
+ const subscription: Subscription = await this.subscriptionRepository.findById(
+ subscriptionId,
+ );
+ let encryptionKey: EncryptionKey | undefined = undefined;
+ const collectivity: Collectivity | null = await this.collectivityRepository.findOne(
+ {
+ where: {id: subscription.funderId},
+ },
+ );
+ const enterprise: Enterprise | null = await this.enterpriseRepository.findOne({
+ where: {id: subscription.funderId},
+ });
+
+ encryptionKey = collectivity
+ ? collectivity.encryptionKey
+ : enterprise
+ ? enterprise.encryptionKey
+ : undefined;
+
+ const metadataId: string | undefined = attachmentData.body.data
+ ? JSON.parse(attachmentData.body.data)?.metadataId
+ : undefined;
+ const citizen: Citizen = await this.citizenRepository.findById(this.currentUser.id);
+ let formattedSubscriptionAttachments: AttachmentType[] = [];
+ let invoicesPdf: Express.Multer.File[] = [];
+ let createSubscriptionAttachments: Express.Multer.File[] = [];
+ let attachments: Express.Multer.File[] = [];
+
+ const {key, iv}: {key: Buffer; iv: Buffer} = generateAESKey();
+ const {encryptKey, encryptIV}: {encryptKey: Buffer; encryptIV: Buffer} =
+ encryptAESKey(encryptionKey!.publicKey, key, iv);
+
+ if (attachmentData) {
+ createSubscriptionAttachments = attachmentData.files as Express.Multer.File[];
+ createSubscriptionAttachments.forEach((attachment: Express.Multer.File) => {
+ attachment.buffer = encryptFileHybrid(attachment.buffer, key, iv);
+ });
+ }
+
+ if (metadataId) {
+ const metadata: Metadata = await this.metadataRepository.findById(metadataId);
+ invoicesPdf = await generatePdfInvoices(metadata.attachmentMetadata.invoices);
+ invoicesPdf.forEach((invoicePdf: Express.Multer.File) => {
+ invoicePdf.buffer = encryptFileHybrid(invoicePdf.buffer, key, iv);
+ });
+ }
+
+ attachments = [...createSubscriptionAttachments, ...invoicesPdf];
+ const formattedAttachments: Express.Multer.File[] =
+ this.subscriptionService.formatAttachments(attachments);
+ formattedSubscriptionAttachments = formattedAttachments?.map(
+ (file: Express.Multer.File) => {
+ return {
+ originalName: file.originalname,
+ uploadDate: new Date(),
+ proofType: file.fieldname,
+ mimeType: file.mimetype,
+ };
+ },
+ );
+
+ if (attachments.length > 0) {
+ await this.s3Service.uploadFileListIntoBucket(
+ citizen.id,
+ subscriptionId,
+ formattedAttachments,
+ );
+ await this.subscriptionRepository.updateById(subscriptionId, {
+ attachments: formattedSubscriptionAttachments,
+ encryptedAESKey: encryptKey.toString('base64'),
+ encryptedIV: encryptIV.toString('base64'),
+ encryptionKeyId: encryptionKey!.id,
+ encryptionKeyVersion: encryptionKey!.version,
+ privateKeyAccess: encryptionKey!.privateKeyAccess
+ ? encryptionKey!.privateKeyAccess
+ : undefined,
+ });
+ }
+
+ // Delete metadata once subscription is updated with files
+ if (metadataId) {
+ await this.metadataRepository.deleteById(metadataId);
+ }
+ return {id: subscriptionId};
+ } catch (error) {
+ return validationErrorExternalHandler(error);
+ }
+ }
+
+ @authorize({allowedRoles: [Roles.MAAS, Roles.PLATFORM], voters: [checkMaas]})
+ @intercept(AffiliationInterceptor.BINDING_KEY)
+ @intercept(SubscriptionV1FinalizeInterceptor.BINDING_KEY)
+ @post('v1/maas/subscriptions/{subscriptionId}/verify', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Finalise une souscription',
+ security: SECURITY_SPEC_JWT_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Finalisation de la demande',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ example: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ },
+ [StatusCode.Forbidden]: {
+ description: "L'utilisateur n'a pas les droits pour finaliser la demande",
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette demande n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Subscription not found',
+ path: '/subscriptionNotFound',
+ resourceName: 'Subscription',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.PreconditionFailed]: {
+ description: "La demande n'est pas au bon statut",
+ },
+ },
+ })
+ async finalizeSubscription(
+ @param.path.string('subscriptionId', {description: `L'identifiant de la demande`})
+ subscriptionId: string,
+ ): Promise<{id: string} | HttpErrors.HttpError> {
+ try {
+ const subscription = await this.subscriptionRepository.findById(subscriptionId);
+ // Change subscription status
+ await this.subscriptionRepository.updateById(subscriptionId, {
+ status: SUBSCRIPTION_STATUS.TO_PROCESS,
+ });
+ // check if the funder is entreprise and if its HRIS to publish msg to rabbitmq
+ if (subscription?.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) {
+ const enterprise = await this.enterpriseRepository.findById(
+ subscription?.funderId,
+ );
+ if (enterprise?.isHris) {
+ const payload = await this.subscriptionService.preparePayLoad(subscription);
+ // Publish to rabbitmq
+ await this.rabbitmqService.publishMessage(payload, enterprise?.name);
+ }
+ }
+ // Send a notification as an email
+ const dashboardLink = `${WEBSITE_FQDN}/mon-dashboard`;
+ await this.mailService.sendMailAsHtml(
+ subscription.email,
+ 'Confirmation d’envoi de la demande',
+ 'requests-to-process',
+ {
+ username: capitalize(subscription.firstName),
+ funderName: subscription.funderName,
+ dashboardLink: dashboardLink,
+ },
+ );
+ return {id: subscriptionId};
+ } catch (error) {
+ return validationErrorExternalHandler(error);
+ }
+ }
+
+ @authorize({allowedRoles: [Roles.MAAS], voters: [checkMaas]})
+ @get('v1/maas/subscriptions', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Retourne les souscriptions',
+ security: SECURITY_SPEC_JWT,
+ responses: {
+ [StatusCode.Success]: {
+ description:
+ "Ce service permet au citoyen de consulter l'ensemble de ses demandes réalisées",
+ content: {
+ 'application/json': {
+ schema: {
+ title: 'MaasSubscriptionList',
+ type: 'array',
+ items: {
+ allOf: [
+ getModelSchemaRef(Subscription, {
+ title: 'MaasSubscriptionItem',
+ exclude: [
+ 'attachments',
+ 'incentiveType',
+ 'citizenId',
+ 'lastName',
+ 'firstName',
+ 'email',
+ 'communityId',
+ 'consent',
+ 'specificFields',
+ 'status',
+ 'subscriptionRejection',
+ 'subscriptionValidation',
+ ],
+ }),
+ {
+ anyOf: [
+ {
+ properties: {
+ status: {
+ type: 'string',
+ example: SUBSCRIPTION_STATUS.TO_PROCESS,
+ },
+ },
+ },
+ {
+ type: 'object',
+ properties: {
+ subscriptionValidation: {
+ oneOf: [
+ {'x-ts-type': ValidationMultiplePayment},
+ {'x-ts-type': ValidationSinglePayment},
+ {'x-ts-type': ValidationNoPayment},
+ ],
+ },
+ status: {
+ type: 'string',
+ example: SUBSCRIPTION_STATUS.VALIDATED,
+ },
+ },
+ },
+ {
+ type: 'object',
+ properties: {
+ subscriptionRejection: {
+ oneOf: [{'x-ts-type': NoReason}, {'x-ts-type': OtherReason}],
+ },
+ status: {
+ type: 'string',
+ example: SUBSCRIPTION_STATUS.REJECTED,
+ },
+ },
+ },
+ ],
+ },
+ {
+ type: 'object',
+ properties: {
+ contact: {
+ type: 'string',
+ example: 'contact@mcm.com',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findMaasSubscription(): Promise {
+ // get current user id
+ const userId: string = this.currentUser.id;
+
+ // get incentives
+ const incentiveList: Incentive[] = await this.incentiveRepository.find({
+ fields: {id: true, contact: true},
+ });
+
+ // get current users subscriptions
+ const userSubcriptionList: Subscription[] = await this.subscriptionRepository.find({
+ where: {citizenId: userId, status: {neq: SUBSCRIPTION_STATUS.DRAFT}},
+ fields: {
+ id: true,
+ incentiveId: true,
+ incentiveTitle: true,
+ funderName: true,
+ status: true,
+ createdAt: true,
+ updatedAt: true,
+ subscriptionValidation: true,
+ subscriptionRejection: true,
+ },
+ });
+
+ // add help contact info to subscriptions
+ const response: MaasSubscriptionList[] =
+ userSubcriptionList &&
+ userSubcriptionList.map((subscription: Subscription) => {
+ const newSubscription: Subscription = subscription;
+ const incentive =
+ incentiveList &&
+ incentiveList.find(
+ (incentive: Incentive) =>
+ newSubscription.incentiveId.toString() === incentive.id,
+ );
+ newSubscription.status === SUBSCRIPTION_STATUS.VALIDATED &&
+ delete newSubscription.subscriptionRejection;
+ newSubscription.status === SUBSCRIPTION_STATUS.REJECTED &&
+ delete newSubscription.subscriptionValidation;
+ return {
+ ...newSubscription,
+ contact: incentive?.contact,
+ };
+ });
+
+ return response;
+ }
+}
diff --git a/api/src/controllers/funder.controller.ts b/api/src/controllers/funder.controller.ts
new file mode 100644
index 0000000..a603bf5
--- /dev/null
+++ b/api/src/controllers/funder.controller.ts
@@ -0,0 +1,480 @@
+import {post, param, get, getModelSchemaRef, requestBody, put} from '@loopback/rest';
+import {repository, Count, CountSchema, Where} from '@loopback/repository';
+import {inject, intercept, service} from '@loopback/core';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+
+import {orderBy, capitalize} from 'lodash';
+
+import {
+ Collectivity,
+ Community,
+ Enterprise,
+ FunderCommunity,
+ EncryptionKey,
+ Error,
+ Client,
+} from '../models';
+import {
+ CommunityRepository,
+ EnterpriseRepository,
+ CollectivityRepository,
+ ClientScopeRepository,
+} from '../repositories';
+import {FunderService} from '../services';
+import {
+ ResourceName,
+ StatusCode,
+ SECURITY_SPEC_KC_PASSWORD,
+ Roles,
+ AUTH_STRATEGY,
+ IFunder,
+ IFindCommunities,
+ SECURITY_SPEC_KC_CREDENTIALS,
+ SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD,
+} from '../utils';
+import {ValidationError} from '../validationError';
+import {FunderInterceptor} from '../interceptors';
+import {canAccessHisOwnData} from '../services';
+import _ from 'lodash';
+
+@authenticate(AUTH_STRATEGY.KEYCLOAK)
+export class FunderController {
+ constructor(
+ @repository(CommunityRepository)
+ public communityRepository: CommunityRepository,
+ @repository(EnterpriseRepository)
+ public enterpriseRepository: EnterpriseRepository,
+ @repository(CollectivityRepository)
+ public collectivityRepository: CollectivityRepository,
+ @inject('services.FunderService')
+ public funderService: FunderService,
+ @repository(ClientScopeRepository)
+ private clientScopeRepository: ClientScopeRepository,
+ ) {}
+
+ /**
+ * Get all funders
+ * @returns all funders
+ */
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/funders', {
+ 'x-controller-name': 'Funders',
+ summary: 'Retourne les financeurs',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of funders',
+ content: {
+ 'application/json': {
+ schema: {
+ title: 'Funders',
+ type: 'array',
+ items: {
+ allOf: [
+ {
+ anyOf: [{'x-ts-type': Enterprise}, {'x-ts-type': Collectivity}],
+ },
+ {
+ type: 'object',
+ properties: {
+ funderType: {
+ type: 'string',
+ },
+ },
+ required: ['funderType'],
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ find(): Promise {
+ return this.funderService.getFunders();
+ }
+
+ /**
+ * Get the number of communities
+ */
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/funders/communities/count', {
+ 'x-controller-name': 'Funders',
+ summary: 'Retourne le nombre de communautés',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Community model count',
+ content: {
+ 'application/json': {
+ schema: {...CountSchema, ...{title: 'Count'}},
+ },
+ },
+ },
+ },
+ })
+ async count(@param.where(Community) where?: Where): Promise {
+ return this.communityRepository.count(where);
+ }
+
+ /**
+ * Get all communities with associated funders
+ * @returns all communities with associated funders
+ */
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/funders/communities', {
+ 'x-controller-name': 'Funders',
+ summary: 'Retourne les communautés et leurs financeurs associés',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of Community model instances',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: getModelSchemaRef(FunderCommunity),
+ },
+ },
+ },
+ },
+ },
+ })
+ async findCommunities(): Promise {
+ const funders = await this.funderService.getFunders();
+
+ const community: Community[] = await this.communityRepository.find({
+ fields: {name: true, id: true, funderId: true},
+ });
+ const allCommunities =
+ community &&
+ community.map((elt: Community) => {
+ const funder =
+ funders && funders.find((elt1: IFunder) => elt.funderId === elt1.id);
+
+ return {
+ ...elt,
+ funderType: capitalize(funder?.funderType),
+ funderName: funder?.name,
+ };
+ });
+
+ return orderBy(allCommunities, ['funderName', 'funderType', 'name'], ['asc']);
+ }
+
+ /**
+ * Post funder's public encryption key
+ * @returns funder's public encryption key
+ */
+ @authorize({allowedRoles: [Roles.SIRH_BACKEND, Roles.VAULT_BACKEND]})
+ @intercept(FunderInterceptor.BINDING_KEY)
+ @put('/v1/funders/{funderId}/encryption_key', {
+ 'x-controller-name': 'Funders',
+ summary: 'Enregistre les paramètres de clé de chiffrement',
+ security: SECURITY_SPEC_KC_CREDENTIALS,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: `Les paramètres de clé de chiffrement ont bien été enregistrés`,
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 401,
+ name: 'Error',
+ message: 'Authorization header not found',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description:
+ "L'utilisateur n'a pas les droits pour enregistrer les paramètres de clé de chiffrement",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: "Ce financeur n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Funder not found',
+ path: '/Funder',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: 'Les informations sur la clé de chiffrement ne sont pas valides',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 422,
+ name: 'Error',
+ message: `encryptionKey.error.privateKeyAccess.missing`,
+ path: '/EncryptionKey',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async storeEncryptionKey(
+ @param.path.string('funderId', {description: `L'identifiant du financeur`})
+ funderId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(EncryptionKey),
+ },
+ },
+ })
+ encryptionKey: EncryptionKey,
+ ): Promise {
+ const enterprise: Enterprise | null = await this.enterpriseRepository.findOne({
+ where: {id: funderId},
+ });
+ const collectivity: Collectivity | null = await this.collectivityRepository.findOne({
+ where: {id: funderId},
+ });
+
+ if (enterprise) {
+ await this.enterpriseRepository.updateById(enterprise.id, {encryptionKey});
+ }
+
+ if (collectivity) {
+ await this.collectivityRepository.updateById(collectivity.id, {
+ encryptionKey,
+ });
+ }
+ }
+
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @post('/v1/funders/communities', {
+ 'x-controller-name': 'Funders',
+ summary: 'Crée une communauté pour un financeur',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Community model instance',
+ content: {'application/json': {schema: getModelSchemaRef(Community)}},
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Community),
+ },
+ },
+ })
+ community: Omit,
+ ): Promise {
+ const {name, funderId} = community;
+
+ const communitiesByFunderId = await this.communityRepository.find({
+ where: {funderId, name},
+ fields: {id: true},
+ });
+ if (communitiesByFunderId.length === 0) {
+ const enterprises = await this.enterpriseRepository.find({
+ where: {id: funderId},
+ fields: {id: true},
+ });
+ let collectivities = [];
+
+ if (enterprises.length === 0) {
+ collectivities = await this.collectivityRepository.find({
+ where: {id: funderId},
+ fields: {id: true},
+ });
+ }
+
+ if (collectivities.length > 0 || enterprises.length > 0)
+ return this.communityRepository.create(community);
+
+ throw new ValidationError(
+ `communities.error.funders.missed`,
+ `/communities`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Community,
+ );
+ }
+ throw new ValidationError(
+ `communities.error.name.unique`,
+ `/communities`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Community,
+ );
+ }
+
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR, Roles.PLATFORM]})
+ @get('/v1/funders/{funderId}/communities', {
+ 'x-controller-name': 'Funders',
+ summary: "Retourne les communautés d'un financeur",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: `Réponse si les communautés d'au moins\
+ un financeur sont trouvées.`,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: getModelSchemaRef(Community),
+ },
+ },
+ },
+ },
+ },
+ })
+ async findByFunderId(
+ @param.path.string('funderId', {description: `L'identifiant du financeur`})
+ funderId: string,
+ ): Promise {
+ return this.communityRepository.findByFunderId(funderId);
+ }
+
+ @authorize({voters: [canAccessHisOwnData]})
+ @get('/v1/funders/{funderId}', {
+ 'x-controller-name': 'Funders',
+ summary: 'Retourne la clé privée du financeur',
+ security: SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: `Funder`,
+ content: {
+ 'application/json': {
+ schema: {
+ oneOf: [getModelSchemaRef(Community), getModelSchemaRef(Enterprise)],
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: 'The user is not logged in',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 401,
+ name: 'Error',
+ message: 'Authorization header not found',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description: 'The user does not have access rights',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findFunderById(
+ @param.path.string('funderId', {description: `L'identifiant du financeur`})
+ funderId: string,
+ ): Promise {
+ let funder: Collectivity | Enterprise | undefined = undefined;
+ const collectivity = await this.collectivityRepository.findOne({
+ where: {id: funderId},
+ });
+ const enterprise = await this.enterpriseRepository.findOne({
+ where: {id: funderId},
+ });
+ funder = collectivity ? collectivity : enterprise ? enterprise : undefined;
+ if (!funder) {
+ throw new ValidationError(
+ `Funder not found`,
+ `/Funder`,
+ StatusCode.NotFound,
+ ResourceName.Funder,
+ );
+ }
+ return funder;
+ }
+
+ /**
+ * Get all clients with a specific scope
+ * @returns all clients
+ */
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/funders/clients', {
+ 'x-controller-name': 'Funders',
+ summary: 'Retourne les financeurs',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of clients',
+ content: {
+ 'application/json': {
+ schema: {
+ title: 'clients',
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ clientId: {
+ description: `Identifiant du client`,
+ type: 'string',
+ example: '',
+ },
+ id: {
+ description: `Identifiant`,
+ type: 'string',
+ example: '',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findClients(): Promise {
+ const clients = await this.clientScopeRepository.getClients();
+ return clients!;
+ }
+}
diff --git a/api/src/controllers/idfm_invoice.ts b/api/src/controllers/idfm_invoice.ts
new file mode 100644
index 0000000..7ecbb2c
--- /dev/null
+++ b/api/src/controllers/idfm_invoice.ts
@@ -0,0 +1,53 @@
+export const idfm_invoice = {
+ invoices: [
+ {
+ enterprise: {
+ enterpriseName: 'IDF Mobilités', // Obligatoire
+ sirenNumber: '362521879', // Facultatif
+ siretNumber: '36252187900034', // Obligatoire
+ apeCode: '4711D', // Facultatif
+ enterpriseAddress: {
+ // Facultatif
+ zipCode: 75018, // Facultatif
+ city: 'Paris', // Facultatif
+ street: '6 rue Lepic', // Facultatif
+ },
+ },
+ customer: {
+ customerId: '123789', // Obligatoire - Vérifier par IDFM si cette information peut être diffusable
+ customerName: 'NABLI', // Obligatoire
+ customerSurname: 'Samy', // Obligatoire
+ customerAddress: {
+ // Facultatif
+ zipCode: 75018, // Facultatif
+ city: 'Paris', // Facultatif
+ street: '15 rue Veron', // Facultatif
+ },
+ },
+ transaction: {
+ orderId: '30723', // Obligatoire
+ purchaseDate: '2021-03-03T14:54:18+01:00', // Obligatoire
+ amountInclTaxes: 7520, // Obligatoire
+ amountExclTaxes: 7520, // Facultatif
+ },
+ products: [
+ {
+ productName: 'Forfait Navigo Mois', // Obligatoire
+ quantity: 1, // Obligatoire
+ amountInclTaxes: 7520, // Obligatoire
+ amountExclTaxes: 7520, // Facultatif
+ percentTaxes: 10, // Facultatif
+ productDetails: {
+ // Facultatif
+ periodicity: 'Mensuel', // Facultatif
+ zoneMin: 1, // Facultatif
+ zoneMax: 5, // Facultatif
+ validityStart: '2021-03-01T00:00:00+01:00', // Facultatif
+ validityEnd: '2021-03-31T00:00:00+01:00', // Facultatif
+ },
+ },
+ ],
+ },
+ ],
+ totalElements: 1,
+};
diff --git a/api/src/controllers/incentive.controller.ts b/api/src/controllers/incentive.controller.ts
new file mode 100644
index 0000000..f29e0a6
--- /dev/null
+++ b/api/src/controllers/incentive.controller.ts
@@ -0,0 +1,770 @@
+import {inject, intercept, service} from '@loopback/core';
+import {
+ AnyObject,
+ Count,
+ CountSchema,
+ Filter,
+ PredicateComparison,
+ repository,
+ Where,
+} from '@loopback/repository';
+import {
+ post,
+ param,
+ get,
+ getModelSchemaRef,
+ patch,
+ del,
+ requestBody,
+ Response,
+ RestBindings,
+} from '@loopback/rest';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+import {SecurityBindings} from '@loopback/security';
+
+import {Incentive, Link, SpecificField, Error, Citizen, Territory} from '../models';
+import {
+ IncentiveRepository,
+ CollectivityRepository,
+ EnterpriseRepository,
+ CitizenRepository,
+ TerritoryRepository,
+} from '../repositories';
+import {
+ IncentiveInterceptor,
+ AffiliationInterceptor,
+ AffiliationPublicInterceptor,
+} from '../interceptors';
+import {FunderService, IncentiveService} from '../services';
+import {ValidationError} from '../validationError';
+import {
+ INCENTIVE_TYPE,
+ ResourceName,
+ Roles,
+ StatusCode,
+ SECURITY_SPEC_KC_PASSWORD,
+ AFFILIATION_STATUS,
+ SECURITY_SPEC_ALL,
+ HTTP_METHOD,
+ SECURITY_SPEC_JWT_KC_PASSWORD_KC_CREDENTIALS,
+ SECURITY_SPEC_API_KEY,
+ GET_INCENTIVES_INFORMATION_MESSAGES,
+ AUTH_STRATEGY,
+ IScore,
+ IUpdateAt,
+ IUser,
+} from '../utils';
+import {TAG_MAAS, WEBSITE_FQDN} from '../constants';
+import {
+ incentiveExample,
+ incentiveContentDifferentExample,
+} from './utils/incentiveExample';
+import {TerritoryService} from '../services/territory.service';
+
+@intercept(IncentiveInterceptor.BINDING_KEY)
+export class IncentiveController {
+ constructor(
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ @repository(CollectivityRepository)
+ public collectivityRepository: CollectivityRepository,
+ @repository(EnterpriseRepository)
+ public enterpriseRepository: EnterpriseRepository,
+ @repository(CitizenRepository)
+ public citizenRepository: CitizenRepository,
+ @repository(TerritoryRepository)
+ public territoryRepository: TerritoryRepository,
+ @inject('services.IncentiveService')
+ public incentiveService: IncentiveService,
+ @service(FunderService)
+ public funderService: FunderService,
+ @service(TerritoryService)
+ public territoryService: TerritoryService,
+ @inject(SecurityBindings.USER, {optional: true})
+ private currentUser?: IUser,
+ ) {}
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @post('/v1/incentives', {
+ 'x-controller-name': 'Incentives',
+ summary: 'Crée une aide',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Incentives model instance',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Incentive),
+ },
+ },
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Incentive),
+ },
+ },
+ })
+ incentive: Incentive,
+ ): Promise {
+ let createdTerritory: Territory;
+ try {
+ /**
+ * Check if the territory ID is provided.
+ */
+ if (incentive.territory.id) {
+ /**
+ * Check if the provided ID exists in the territoy collection.
+ */
+ const territoryResult: Territory = await this.territoryRepository.findById(
+ incentive.territory.id,
+ );
+
+ /**
+ * Check if the name provided matches the territory name.
+ */
+ if (
+ territoryResult.name !== incentive.territory.name ||
+ (incentive.territoryName && incentive?.territoryName !== territoryResult.name) // TODO: REMOVING DEPRECATED territoryName.
+ ) {
+ throw new ValidationError(
+ 'territory.name.mismatch',
+ '/territory',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Territory,
+ );
+ }
+ } else {
+ /**
+ * Create Territory
+ */
+ createdTerritory = await this.territoryService.createTerritory({
+ name: incentive.territory.name,
+ } as Territory);
+
+ incentive.territory = createdTerritory;
+ }
+
+ if (incentive.incentiveType === INCENTIVE_TYPE.TERRITORY_INCENTIVE) {
+ const collectivity = await this.collectivityRepository.find({
+ where: {name: incentive.funderName},
+ });
+ if (collectivity.length > 0) {
+ incentive.funderId = collectivity[0].id;
+ }
+ } else if (incentive.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) {
+ const enterprise = await this.enterpriseRepository.find({
+ where: {name: incentive.funderName},
+ });
+ if (enterprise.length > 0) {
+ incentive.funderId = enterprise[0].id;
+ } else {
+ throw new ValidationError(
+ `incentives.error.fundername.enterprise.notExist`,
+ '/enterpriseNotExist',
+ StatusCode.NotFound,
+ ResourceName.Enterprise,
+ );
+ }
+ }
+ if (incentive.specificFields) {
+ incentive.jsonSchema = this.incentiveService.convertSpecificFields(
+ incentive.title,
+ incentive.specificFields,
+ );
+ }
+ return await this.incentiveRepository.create(incentive);
+ } catch (error) {
+ if (createdTerritory!) {
+ await this.territoryRepository.deleteById(createdTerritory.id);
+ }
+ throw error;
+ }
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({
+ allowedRoles: [Roles.MANAGERS, Roles.CONTENT_EDITOR, Roles.MAAS, Roles.MAAS_BACKEND],
+ })
+ @get('/v1/incentives', {
+ 'x-controller-name': 'Incentives',
+ summary: 'Retourne les aides',
+ security: SECURITY_SPEC_JWT_KC_PASSWORD_KC_CREDENTIALS,
+ tags: ['Incentives', TAG_MAAS],
+ responses: {
+ [StatusCode.Success]: {
+ description: 'La liste des aides',
+ content: {
+ 'application/json': {
+ schema: {
+ title: 'Incentive',
+ type: 'array',
+ items: {
+ title: 'Incentive',
+ allOf: [
+ getModelSchemaRef(Incentive, {
+ exclude: ['specificFields', 'links'],
+ }),
+ {
+ type: 'object',
+ properties: {
+ specificFields: {
+ type: 'array',
+ items: getModelSchemaRef(SpecificField),
+ },
+ },
+ },
+ ],
+ },
+ example: incentiveExample,
+ },
+ },
+ },
+ },
+ [StatusCode.ContentDifferent]: {
+ description: 'La liste des aides nationales et du territoire concerné',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ response: {
+ type: 'array',
+ items: {
+ title: 'Incentive',
+ allOf: [
+ getModelSchemaRef(Incentive, {
+ exclude: [
+ 'attachments',
+ 'additionalInfos',
+ 'contact',
+ 'jsonSchema',
+ 'subscriptionLink',
+ 'specificFields',
+ 'links',
+ ],
+ }),
+ {
+ type: 'object',
+ properties: {
+ specificFields: {
+ type: 'array',
+ items: getModelSchemaRef(SpecificField),
+ },
+ },
+ },
+ ],
+ },
+ },
+ message: {
+ type: 'string',
+ enum: Object.values(GET_INCENTIVES_INFORMATION_MESSAGES),
+ example:
+ GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_AFFILIATED_WITHOUT_INCENTIVES,
+ },
+ },
+ example: incentiveContentDifferentExample,
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 401,
+ name: 'Error',
+ message: 'Authorization header not found',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description:
+ "L'utilisateur n'a pas les droits pour accéder au catalogue des aides",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async find(
+ @inject(RestBindings.Http.RESPONSE) resp: Response,
+ ): Promise> {
+ const {id, roles} = this.currentUser!;
+ if (roles && roles.includes(Roles.CONTENT_EDITOR)) {
+ return this.incentiveRepository.find({
+ order: ['updatedAt DESC'],
+ });
+ }
+
+ if (roles && roles.includes(Roles.MANAGERS)) {
+ const withParams: AnyObject[] = [
+ {funderName: this.currentUser!.funderName},
+ {incentiveType: this.currentUser!.incentiveType},
+ ];
+ return this.incentiveRepository.find({
+ where: {
+ and: withParams,
+ },
+ fields: {
+ id: true,
+ title: true,
+ },
+ });
+ }
+
+ const commonFilter: Filter = {
+ order: ['updatedAt DESC'],
+ fields: {
+ id: true,
+ funderId: true,
+ title: true,
+ incentiveType: true,
+ conditions: true,
+ paymentMethod: true,
+ allocatedAmount: true,
+ funderName: true,
+ minAmount: true,
+ transportList: true,
+ validityDate: true,
+ createdAt: true,
+ updatedAt: true,
+ description: true,
+ isMCMStaff: true,
+ territory: true,
+ },
+ };
+ const incentiveList: Incentive[] = await this.incentiveRepository.find({
+ where: {
+ or: [
+ {
+ incentiveType:
+ INCENTIVE_TYPE.TERRITORY_INCENTIVE as PredicateComparison,
+ },
+ {
+ incentiveType:
+ INCENTIVE_TYPE.NATIONAL_INCENTIVE as PredicateComparison,
+ },
+ ],
+ },
+ ...commonFilter,
+ });
+
+ if (roles && roles.includes(Roles.MAAS_BACKEND)) {
+ return incentiveList;
+ }
+
+ if (roles && roles.includes(Roles.MAAS)) {
+ const citizen: Citizen | null = await this.citizenRepository.findOne({where: {id}});
+ const citizenFunderId: string | null | undefined =
+ citizen?.affiliation?.enterpriseId;
+ const citizenStatus: string | null | undefined =
+ citizen?.affiliation?.affiliationStatus;
+
+ if (citizenFunderId && citizenStatus === AFFILIATION_STATUS.AFFILIATED) {
+ const incentiveEnterpriseList: Incentive[] = await this.incentiveRepository.find({
+ where: {funderId: citizenFunderId},
+ ...commonFilter,
+ });
+
+ if (incentiveEnterpriseList.length === 0)
+ return resp.status(210).send({
+ response: incentiveList,
+ message:
+ GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_AFFILIATED_WITHOUT_INCENTIVES,
+ });
+ return [...incentiveList, ...incentiveEnterpriseList];
+ }
+
+ if (
+ citizenStatus ===
+ (AFFILIATION_STATUS.TO_AFFILIATE || AFFILIATION_STATUS.DISAFFILIATED)
+ ) {
+ return resp.status(210).send({
+ response: incentiveList,
+ message: GET_INCENTIVES_INFORMATION_MESSAGES.CITIZEN_NOT_AFFILIATED,
+ });
+ }
+ return incentiveList;
+ }
+ return [];
+ }
+ @authenticate(AUTH_STRATEGY.API_KEY)
+ @authorize({allowedRoles: [Roles.API_KEY]})
+ @get('/v1/incentives/search', {
+ 'x-controller-name': 'Incentives',
+ summary: 'Recherche les aides correspondantes',
+ security: SECURITY_SPEC_API_KEY,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of Incentive model instances',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: getModelSchemaRef(Incentive),
+ },
+ },
+ },
+ },
+ },
+ })
+ async search(
+ @param.query.string('_q') textSearch?: string,
+ @param.query.string('incentiveType') incentiveType?: string,
+ @param.query.string('enterpriseId') enterpriseId?: string,
+ ): Promise {
+ const sort: IScore | IUpdateAt = textSearch
+ ? {score: {$meta: 'textScore'}}
+ : {updatedAt: -1};
+ const match: any = textSearch ? {$text: {$search: textSearch, $language: 'fr'}} : {};
+
+ if (incentiveType) {
+ match['$or'] = [];
+ const incentiveTypeList = incentiveType.split(',');
+ for (const row of incentiveTypeList) {
+ match['$or'].push({incentiveType: row});
+ }
+ }
+
+ if (enterpriseId) {
+ match['$or'].push({
+ incentiveType: INCENTIVE_TYPE.EMPLOYER_INCENTIVE,
+ funderId: enterpriseId,
+ });
+ }
+
+ return this.incentiveRepository
+ .execute('Incentive', 'aggregate', [
+ {$match: match},
+ {$sort: sort},
+ {$addFields: {id: '$_id'}},
+ {$project: {_id: 0}},
+ ])
+ .then(res => res.get())
+ .catch(err => err);
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/incentives/count', {
+ 'x-controller-name': 'Incentives',
+ summary: "Retourne le nombre d'aides",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Incentives model count',
+ content: {
+ 'application/json': {
+ schema: {...CountSchema, ...{title: 'Count'}},
+ },
+ },
+ },
+ },
+ })
+ async count(@param.where(Incentive) where?: Where): Promise {
+ return this.incentiveRepository.count(where);
+ }
+
+ /**
+ * get incentive by id
+ * @param incentiveId the incentive id
+ * @returns incentive object details
+ */
+ @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY)
+ @authorize({
+ allowedRoles: [
+ Roles.API_KEY,
+ Roles.CONTENT_EDITOR,
+ Roles.MAAS,
+ Roles.MAAS_BACKEND,
+ Roles.PLATFORM,
+ ],
+ })
+ @intercept(AffiliationPublicInterceptor.BINDING_KEY)
+ @intercept(AffiliationInterceptor.BINDING_KEY)
+ @get('/v1/incentives/{incentiveId}', {
+ 'x-controller-name': 'Incentives',
+ summary: "Retourne le détail d'une aide",
+ security: SECURITY_SPEC_ALL,
+ tags: ['Incentives', TAG_MAAS],
+ responses: {
+ [StatusCode.Success]: {
+ description: "Le détail de l'aide",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Incentive),
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 401,
+ name: 'Error',
+ message: 'Authorization header not found',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description:
+ "L'utilisateur n'a pas les droits pour accéder au détail de cette aide",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette aide n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Incentive not found',
+ path: '/incentiveNotFound',
+ resourceName: 'Incentive',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findIncentiveById(
+ @param.path.string('incentiveId', {description: `L'identifiant de l'aide`})
+ incentiveId: string,
+ ): Promise {
+ const incentive: Incentive = await this.incentiveRepository.findById(incentiveId);
+
+ if (incentive?.isMCMStaff) {
+ const links: Link[] = [
+ new Link({
+ href: `${WEBSITE_FQDN}/subscriptions/new?incentiveId=${incentive.id}`,
+ rel: 'subscribe',
+ method: HTTP_METHOD.GET,
+ }),
+ ];
+ incentive.links = links;
+ }
+
+ return incentive;
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @patch('/v1/incentives/{incentiveId}', {
+ 'x-controller-name': 'Incentives',
+ summary: 'Modifie une aide',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'Incentives put success',
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette aide n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Incentive not found',
+ path: '/incentiveNotFound',
+ resourceName: 'Incentive',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async updateById(
+ @param.path.string('incentiveId', {description: `L'identifiant de l'aide`})
+ incentiveId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Incentive, {
+ title: 'IncentiveUpdate',
+ partial: true,
+ }),
+ },
+ },
+ })
+ incentive: Incentive,
+ ): Promise {
+ // Remove contact from incentive object
+ if (!incentive.contact) {
+ delete incentive.contact;
+ await this.incentiveRepository.updateById(incentiveId, {
+ $unset: {contact: ''},
+ } as any);
+ }
+ // Remove validityDuration from incentive object
+ if (!incentive.validityDuration) {
+ delete incentive.validityDuration;
+ await this.incentiveRepository.updateById(incentiveId, {
+ $unset: {validityDuration: ''},
+ } as any);
+ }
+ // Remove additionalInfos from incentive object
+ if (!incentive.additionalInfos) {
+ delete incentive.additionalInfos;
+ await this.incentiveRepository.updateById(incentiveId, {
+ $unset: {additionalInfos: ''},
+ } as any);
+ }
+ // Remove validityDate from incentive object
+ if (!incentive.validityDate) {
+ delete incentive.validityDate;
+ await this.incentiveRepository.updateById(incentiveId, {
+ $unset: {validityDate: ''},
+ } as any);
+ }
+
+ if (incentive.territory) {
+ if (!incentive.territory.id) {
+ throw new ValidationError(
+ 'territory.id.undefined',
+ '/territory',
+ StatusCode.PreconditionFailed,
+ ResourceName.Territory,
+ );
+ }
+ /**
+ * Check if the provided ID exists in the territoy collection.
+ */
+ const territoryResult: Territory = await this.territoryRepository.findById(
+ incentive.territory.id,
+ );
+
+ /**
+ * Check if the name provided matches the territory name.
+ */
+ if (
+ territoryResult.name !== incentive.territory.name ||
+ (incentive.territoryName && incentive?.territoryName !== territoryResult.name) // TODO: REMOVING DEPRECATED territoryName.
+ ) {
+ throw new ValidationError(
+ 'territory.name.mismatch',
+ '/territory',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Territory,
+ );
+ }
+ }
+
+ // Add new specificFields and unset subscription link
+ if (
+ incentive.isMCMStaff &&
+ incentive.specificFields &&
+ incentive.specificFields.length
+ ) {
+ // Add json schema from specific fields
+ incentive.jsonSchema = this.incentiveService.convertSpecificFields(
+ incentive.title,
+ incentive.specificFields,
+ );
+ // Remove subscription from incentive object
+ delete incentive.subscriptionLink;
+ // Unset subscription
+ await this.incentiveRepository.updateById(incentiveId, {
+ $unset: {subscriptionLink: ''},
+ } as any);
+ }
+
+ // Add subscription link and unset specificFields and jsonSchema
+ if (
+ (!incentive.isMCMStaff && incentive.subscriptionLink) ||
+ (incentive?.specificFields && !incentive.specificFields.length)
+ ) {
+ // Remove specific field && jsonSchema from incentive object
+ delete incentive.specificFields;
+ delete incentive.jsonSchema;
+ // Unset specificFields && jsonSchema
+ await this.incentiveRepository.updateById(incentiveId, {
+ $unset: {specificFields: '', jsonSchema: ''},
+ } as any);
+ }
+ await this.incentiveRepository.updateById(incentiveId, incentive);
+ incentive.id = incentiveId;
+ return incentive;
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @del('/v1/incentives/{incentiveId}', {
+ 'x-controller-name': 'Incentives',
+ summary: 'Supprime une aide',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'Incentives DELETE success',
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette aide n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Incentive not found',
+ path: '/incentiveNotFound',
+ resourceName: 'Incentive',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async deleteById(
+ @param.path.string('incentiveId', {description: `L'identifiant de l'aide`})
+ incentiveId: string,
+ ): Promise {
+ await this.incentiveRepository.deleteById(incentiveId);
+ }
+}
diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts
new file mode 100644
index 0000000..4a6cfe2
--- /dev/null
+++ b/api/src/controllers/index.ts
@@ -0,0 +1,10 @@
+export * from './incentive.controller';
+export * from './citizen.controller';
+export * from './contact.controller';
+export * from './collectivity.controller';
+export * from './enterprise.controller';
+export * from './subscription.controller';
+export * from './funder.controller';
+export * from './dashboard.controller';
+export * from './user.controller';
+export * from './territory.controller';
diff --git a/api/src/controllers/subscription.controller.ts b/api/src/controllers/subscription.controller.ts
new file mode 100644
index 0000000..2ff29da
--- /dev/null
+++ b/api/src/controllers/subscription.controller.ts
@@ -0,0 +1,917 @@
+import {AnyObject, repository, Count} from '@loopback/repository';
+import {inject, service, intercept} from '@loopback/core';
+import {
+ param,
+ get,
+ getModelSchemaRef,
+ put,
+ requestBody,
+ Response,
+ RestBindings,
+ post,
+ HttpErrors,
+} from '@loopback/rest';
+import {authorize} from '@loopback/authorization';
+import {authenticate} from '@loopback/authentication';
+import {SecurityBindings} from '@loopback/security';
+
+import {isEqual, intersection} from 'lodash';
+
+import {AttachmentMetadata, Error, Invoice, Metadata, Subscription} from '../models';
+import {
+ CommunityRepository,
+ SubscriptionRepository,
+ UserRepository,
+ MetadataRepository,
+ CitizenRepository,
+} from '../repositories';
+import {
+ SubscriptionService,
+ FunderService,
+ S3Service,
+ checkMaas,
+ MailService,
+} from '../services';
+import {validationErrorExternalHandler} from '../validationErrorExternal';
+import {ValidationError} from '../validationError';
+import {
+ ResourceName,
+ StatusCode,
+ SECURITY_SPEC_KC_PASSWORD,
+ SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD,
+ FUNDER_TYPE,
+ INCENTIVE_TYPE,
+ Roles,
+ SUBSCRIPTION_STATUS,
+ AUTH_STRATEGY,
+ SECURITY_SPEC_JWT,
+ IUser,
+} from '../utils';
+import {
+ AffiliationInterceptor,
+ SubscriptionInterceptor,
+ SubscriptionMetadataInterceptor,
+} from '../interceptors';
+import {getInvoiceFilename} from '../utils/invoice';
+import {TAG_MAAS, WEBSITE_FQDN} from '../constants';
+import {endOfYear, startOfYear} from 'date-fns';
+import {
+ ValidationMultiplePayment,
+ SubscriptionValidation,
+ ValidationSinglePayment,
+ ValidationNoPayment,
+ NoReason,
+ OtherReason,
+ SubscriptionRejection,
+} from '../models';
+
+/**
+ * set the list pagination value
+ */
+const PAGINATION_LIMIT = 200;
+
+interface SubscriptionsWithCount {
+ subscriptions: Subscription[];
+ count: number;
+}
+
+@authenticate(AUTH_STRATEGY.KEYCLOAK)
+export class SubscriptionController {
+ constructor(
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @service(S3Service)
+ private s3Service: S3Service,
+ @service(SubscriptionService)
+ private subscriptionService: SubscriptionService,
+ @service(FunderService)
+ private funderService: FunderService,
+ @service(CommunityRepository)
+ private communityRepository: CommunityRepository,
+ @service(MetadataRepository)
+ private metadataRepository: MetadataRepository,
+ @service(UserRepository)
+ private userRepository: UserRepository,
+ @service(CitizenRepository)
+ private citizenRepository: CitizenRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ @inject(RestBindings.Http.RESPONSE) private response: Response,
+ @inject('services.MailService')
+ public mailService: MailService,
+ ) {}
+
+ /**
+ * get the subscriptions list
+ * @param status that subscription status (VALIDATED or REJECTED)
+ * @param incentiveId the incentive id
+ * @param lastName the last name related to the subscription
+ * @param citizenId the citizen id
+ * @returns subscription list
+ */
+ @authorize({allowedRoles: [Roles.MANAGERS, Roles.CITIZENS]})
+ @get('/v1/subscriptions', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Retourne les souscriptions',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of Subscriptions model instances',
+ content: {
+ 'application/json': {
+ schema: {
+ oneOf: [
+ {
+ type: 'array',
+ items: getModelSchemaRef(Subscription),
+ },
+ {
+ type: 'object',
+ properties: {
+ subscriptions: getModelSchemaRef(Subscription),
+ count: {
+ type: 'number',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ })
+ async find(
+ @param.query.string('status') status: string,
+ @param.query.string('incentiveId') incentiveId?: string,
+ @param.query.string('incentiveType') incentiveType?: string,
+ @param.query.string('idCommunities') idCommunities?: string,
+ @param.query.string('lastName') lastName?: string,
+ @param.query.string('citizenId') citizenId?: string,
+ @param.query.string('year') year?: string,
+ @param.query.string('skip') skip?: number | undefined,
+ ): Promise {
+ const withParams: AnyObject[] = [
+ {
+ funderName: this.currentUser.funderName!,
+ incentiveType: this.currentUser.incentiveType!,
+ },
+ ];
+
+ const userId = this.currentUser.id;
+
+ let communityIds: '' | string[] | null | undefined = null;
+
+ communityIds =
+ userId && (await this.userRepository.findOne({where: {id: userId}}))?.communityIds;
+
+ if (communityIds && communityIds?.length > 0) {
+ withParams.push({communityId: {inq: communityIds}});
+ }
+
+ if (idCommunities) {
+ const match: AnyObject[] = [];
+ const idCommunitiesList = idCommunities.split(',');
+ const mapping: AnyObject = {};
+ mapping[INCENTIVE_TYPE['TERRITORY_INCENTIVE']] = FUNDER_TYPE.collectivity;
+ mapping[INCENTIVE_TYPE['EMPLOYER_INCENTIVE']] = FUNDER_TYPE.enterprise;
+
+ const funders = await this.funderService.getFunderByName(
+ this.currentUser.funderName!,
+ mapping[this.currentUser.incentiveType!],
+ );
+
+ if (funders?.id && idCommunitiesList && idCommunitiesList.length > 0) {
+ const communities = await this.communityRepository.findByFunderId(funders.id);
+
+ if (
+ communities &&
+ communities.length > 0 &&
+ isEqual(
+ intersection(
+ communities.map(({id}) => id),
+ idCommunitiesList,
+ ).sort(),
+ idCommunitiesList.sort(),
+ )
+ ) {
+ for (const row of idCommunitiesList) {
+ match.push({communityId: row});
+ }
+ withParams.push({or: match});
+ }
+ }
+ if (match.length === 0)
+ throw new ValidationError(
+ `subscriptions.error.communities.mismatch`,
+ `/subscriptions`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+ );
+ }
+
+ if (incentiveId) {
+ const match: AnyObject[] = [];
+ const incentiveIdList = incentiveId.split(',');
+ for (const row of incentiveIdList) {
+ match.push({incentiveId: row});
+ }
+ withParams.push({or: match});
+ }
+
+ if (incentiveType) {
+ const match: AnyObject[] = [];
+ const incentiveTypeList = incentiveType.split(',');
+ for (const row of incentiveTypeList) {
+ match.push({incentiveType: row});
+ }
+ withParams.push({or: match});
+ }
+
+ if (lastName) {
+ withParams.push({lastName: lastName});
+ }
+
+ if (citizenId) {
+ withParams.push({citizenId: citizenId});
+ }
+
+ if (year) {
+ const yearMatch: AnyObject[] = [];
+ const yearList = year.split(',');
+ for (const yearItem of yearList) {
+ const match: AnyObject[] = [];
+ match.push({
+ updatedAt: {
+ gte: startOfYear(new Date(parseInt(yearItem), 0)),
+ },
+ });
+ match.push({
+ updatedAt: {
+ lte: endOfYear(new Date(parseInt(yearItem), 0)),
+ },
+ });
+ yearMatch.push({and: match});
+ }
+ withParams.push({or: yearMatch});
+ }
+
+ if (status) {
+ const match: AnyObject[] = [];
+ const statusList = status.split(',');
+ if (status.includes(SUBSCRIPTION_STATUS.DRAFT)) {
+ // ticket 1827 should not return subscription with statut BROUILLON
+ return {
+ subscriptions: [],
+ count: 0,
+ };
+ }
+ for (const row of statusList) {
+ match.push({status: row});
+ }
+ withParams.push({or: match});
+ } else {
+ withParams.push({status: {neq: SUBSCRIPTION_STATUS.DRAFT}});
+ }
+
+ if (citizenId) {
+ // get citizen's subscription
+ const subscriptionsResponse = await this.subscriptionRepository.find({
+ limit: 10,
+ skip,
+ order: ['createdAt DESC'],
+ where: {
+ and: withParams,
+ },
+ });
+
+ // params to get count of citizen's subscription
+ const queryParams: object = {
+ funderName: this.currentUser.funderName!,
+ incentiveType: this.currentUser.incentiveType!,
+ citizenId: citizenId,
+ communityId: communityIds ? {inq: communityIds} : undefined,
+ status: {neq: SUBSCRIPTION_STATUS.DRAFT},
+ updatedAt: year
+ ? {
+ gte: startOfYear(new Date(parseInt(year), 0)),
+ lte: endOfYear(new Date(parseInt(year), 0)),
+ }
+ : undefined,
+ };
+ const subscriptionCount: Count = await this.subscriptionRepository.count(
+ queryParams,
+ );
+
+ return {
+ subscriptions: subscriptionsResponse,
+ ...subscriptionCount,
+ };
+ } else {
+ return this.subscriptionRepository.find({
+ limit: PAGINATION_LIMIT,
+ where: {
+ and: withParams,
+ },
+ });
+ }
+ }
+
+ /**
+ * download the validated demands
+ * @param funderName is the funder name as collectivity or enterprise
+ * @param funderType is the funder type or nature
+ * @param Response the validated subscriptions from founder name and funder type
+ */
+ @authorize({allowedRoles: [Roles.MANAGERS]})
+ @get('/v1/subscriptions/export', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Exporte les souscriptions validées du financeur connecté',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Downloadable .xlsx file with validated incentive list',
+ content: {
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
+ schema: {type: 'string', format: 'base64'},
+ },
+ },
+ },
+ },
+ })
+ async generateExcel(
+ @inject(RestBindings.Http.RESPONSE) Response: Response,
+ ): Promise {
+ const withParams: AnyObject[] = [
+ {funderName: this.currentUser.funderName},
+ {incentiveType: this.currentUser.incentiveType},
+ {status: SUBSCRIPTION_STATUS.VALIDATED},
+ ];
+
+ const userId = this.currentUser.id;
+
+ let communityIds: '' | string[] | null | undefined = null;
+
+ communityIds =
+ userId && (await this.userRepository.findOne({where: {id: userId}}))?.communityIds;
+ if (communityIds && communityIds?.length > 0) {
+ withParams.push({communityId: {inq: communityIds}});
+ }
+
+ const subscriptionList = await this.subscriptionRepository.find({
+ order: ['updatedAT ASC'],
+ where: {
+ and: withParams,
+ },
+ });
+ if (subscriptionList && subscriptionList.length > 0) {
+ const buffer = await this.subscriptionService.generateExcelValidatedIncentives(
+ subscriptionList,
+ );
+ Response.status(200)
+ .contentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+ .send(buffer);
+ } else if (subscriptionList && subscriptionList.length === 0) {
+ throw new ValidationError(
+ 'Aucune demande validée à télécharger',
+ '/downloadXlsx',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+ );
+ } else {
+ throw new ValidationError(
+ 'Le téléchargement a échoué, veuillez réessayer',
+ '/downloadXlsx',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+ );
+ }
+ }
+
+ /**
+ * download file by id
+ * @param subscriptionId the requested subscription id
+ * @param filename the given name of the downloaded file
+ * @returns the response object for file download
+ */
+ @authorize({allowedRoles: [Roles.MANAGERS, Roles.SIRH_BACKEND]})
+ @intercept(SubscriptionInterceptor.BINDING_KEY)
+ @get('/v1/subscriptions/{subscriptionId}/attachments/{filename}', {
+ 'x-controller-name': 'Subscriptions',
+ summary: "Récupère le justificatif d'une souscription",
+ security: SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Le fichier du justificatif associé à la demande',
+ content: {
+ 'application/octet-stream': {
+ schema: {
+ type: 'string',
+ format: 'binary',
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 401,
+ name: 'Error',
+ message: 'Authorization header not found',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description: "L'utilisateur n'a pas les droits pour accéder au fichier",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: "La demande ou le fichier n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Not Found',
+ path: '/subscription',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async getSubscriptionFileByName(
+ @param.path.string('subscriptionId', {description: `L'identifiant de la demande`})
+ subscriptionId: string,
+ @param.path.string('filename', {
+ description: `Le nom de fichier du justificatif à récupérer`,
+ })
+ filename: string,
+ ): Promise {
+ try {
+ const subscription = await this.subscriptionRepository.findById(subscriptionId);
+ const downloadBucket = await this.s3Service.downloadFileBuffer(
+ subscription.citizenId,
+ subscriptionId,
+ filename,
+ );
+ this.response
+ .status(200)
+ .contentType('application/octet-stream')
+ .send(downloadBucket);
+ return downloadBucket;
+ } catch (error) {
+ return validationErrorExternalHandler(error);
+ }
+ }
+
+ /**
+ * get the subscription by id
+ * @param subscriptionId the subscription id
+ * @param filter the subscriptions search filter
+ * @returns the subscriptions objects
+ */
+ @authorize({allowedRoles: [Roles.MANAGERS]})
+ @intercept(SubscriptionInterceptor.BINDING_KEY)
+ @get('/v1/subscriptions/{subscriptionId}', {
+ 'x-controller-name': 'Subscriptions',
+ summary: "Retourne le détail d'une souscription",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Subscriptions model instance',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Subscription),
+ },
+ },
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette demande n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Subscription not found',
+ path: '/subscriptionNotFound',
+ resourceName: 'Subscription',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findById(
+ @param.path.string('subscriptionId', {description: `L'identifiant de la demande`})
+ subscriptionId: string,
+ ): Promise {
+ return this.subscriptionRepository.findById(subscriptionId);
+ }
+
+ /**
+ * subscriptions validation by subscription id
+ * @param subscriptionId the subscription id
+ */
+ @authorize({allowedRoles: [Roles.MANAGERS]})
+ @intercept(SubscriptionInterceptor.BINDING_KEY)
+ @put('/v1/subscriptions/{subscriptionId}/validate', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Valide une souscription',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'La demande est validée',
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette demande n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Subscription not found',
+ path: '/subscriptionNotFound',
+ resourceName: 'Subscription',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.PreconditionFailed]: {
+ description: "La demande n'est pas au bon status",
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "La demande n'est pas au bon format",
+ },
+ },
+ })
+ async validate(
+ @param.path.string('subscriptionId', {description: `L'identifiant de la demande`})
+ subscriptionId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: {
+ anyOf: [
+ getModelSchemaRef(ValidationMultiplePayment),
+ getModelSchemaRef(ValidationSinglePayment),
+ getModelSchemaRef(ValidationNoPayment),
+ ],
+ },
+ },
+ },
+ })
+ payment: SubscriptionValidation,
+ ): Promise {
+ // Vérification de l'existence de la subscription
+ const subscription = await this.subscriptionRepository.findById(subscriptionId);
+ if (subscription.status === SUBSCRIPTION_STATUS.TO_PROCESS) {
+ const result = this.subscriptionService.checkPayment(payment);
+ await this.subscriptionService.validateSubscription(result, subscription);
+ } else {
+ throw new ValidationError(
+ 'subscriptions.error.bad.status',
+ '/subscriptionBadStatus',
+ StatusCode.PreconditionFailed,
+ ResourceName.Subscription,
+ );
+ }
+ }
+
+ /**
+ * Subscriptions rejection by subscription id
+ * @param subscriptionId the subscription id
+ * @param reason the property to update
+ */
+ @authorize({allowedRoles: [Roles.MANAGERS]})
+ @intercept(SubscriptionInterceptor.BINDING_KEY)
+ @put('/v1/subscriptions/{subscriptionId}/reject', {
+ 'x-controller-name': 'Subscriptions',
+ summary: 'Rejette une souscription',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'La demande est rejetée',
+ },
+ [StatusCode.NotFound]: {
+ description: "Cette demande n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Subscription not found',
+ path: '/subscriptionNotFound',
+ resourceName: 'Subscription',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.PreconditionFailed]: {
+ description: "La demande n'est pas au bon status",
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "La demande n'est pas au bon format",
+ },
+ },
+ })
+ async reject(
+ @param.path.string('subscriptionId', {description: `L'identifiant de la demande`})
+ subscriptionId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: {
+ oneOf: [getModelSchemaRef(NoReason), getModelSchemaRef(OtherReason)],
+ },
+ },
+ },
+ })
+ reason: SubscriptionRejection,
+ ): Promise {
+ // Vérification de l'existence de la subscription
+ const subscription = await this.subscriptionRepository.findById(subscriptionId);
+ if (subscription.status === SUBSCRIPTION_STATUS.TO_PROCESS) {
+ const result = this.subscriptionService.checkRefusMotif(reason);
+ await this.subscriptionService.rejectSubscription(result, subscription);
+ } else {
+ throw new ValidationError(
+ 'subscriptions.error.bad.status',
+ '/subscriptionBadStatus',
+ StatusCode.PreconditionFailed,
+ ResourceName.Subscription,
+ );
+ }
+ }
+
+ @authorize({allowedRoles: [Roles.PLATFORM]})
+ @intercept(SubscriptionMetadataInterceptor.BINDING_KEY)
+ @get('v1/subscriptions/metadata/{metadataId}', {
+ 'x-controller-name': 'Subscriptions',
+ summary: "Récupère les métadonnées d'une souscription",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: "Métadonnées d'un utilisateur",
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ incentiveId: {
+ description: `Identifiant de l'aide`,
+ type: 'string',
+ example: '',
+ },
+ citizenId: {
+ description: `Identifiant du citoyen`,
+ type: 'string',
+ example: '',
+ },
+ attachmentMetadata: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ fileName: {
+ description: `Nom du fichier de la preuve d'achat`,
+ type: 'string',
+ example: '03-03-2021_Forfait_Navigo_Mois_Bob_RASOVSKY.pdf',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ },
+ [StatusCode.Forbidden]: {
+ description: "L'utilisateur n'a pas les droits pour récupérer les métadonnées",
+ },
+ [StatusCode.NotFound]: {
+ description: "Ces métadonnées n'existent pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Metadata not found',
+ path: '/metadataNotFound',
+ resourceName: 'Metadata',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async getMetadata(
+ @param.path.string('metadataId', {
+ description: `L'identifiant des métadonnées à envoyer \
+ lors de la souscription d'une aide pour un citoyen`,
+ })
+ metadataId: string,
+ ): Promise<
+ | {
+ incentiveId: string;
+ citizenId: string;
+ attachmentMetadata: {fileName: string}[];
+ }
+ | HttpErrors.HttpError
+ > {
+ try {
+ const metadata: Metadata = await this.metadataRepository.findById(metadataId);
+
+ // Get fileName for each invoices
+ const fileNameList = metadata.attachmentMetadata.invoices.map(
+ (invoice: Invoice) => {
+ return {fileName: getInvoiceFilename(invoice)};
+ },
+ );
+
+ return {
+ incentiveId: metadata.incentiveId,
+ citizenId: metadata.citizenId,
+ attachmentMetadata: fileNameList,
+ };
+ } catch (error) {
+ return validationErrorExternalHandler(error);
+ }
+ }
+
+ @authorize({allowedRoles: [Roles.MAAS], voters: [checkMaas]})
+ @intercept(AffiliationInterceptor.BINDING_KEY)
+ @intercept(SubscriptionMetadataInterceptor.BINDING_KEY)
+ @post('v1/subscriptions/metadata', {
+ 'x-controller-name': 'Subscriptions',
+ summary: "Crée des métadonnées de justificatifs d'achat liées à une souscription",
+ security: SECURITY_SPEC_JWT,
+ tags: ['Subscriptions', TAG_MAAS],
+ responses: {
+ [StatusCode.Created]: {
+ description: 'Les métadonnées sont enregistrées',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ subscriptionURL: {
+ description: `URL de redirection pour initier la souscription à l'aide`,
+ type: 'string',
+ example:
+ 'https://website.${env}.moncomptemobilite.fr/subscriptions/new?incentiveId=6d0efef1-4dc9-422e-a17d-40c1bf1c37c4&metadataId=6d0efef1-4dc9-422e-a17d-40c1bf1c37c4',
+ },
+ metadataId: {
+ description: `Identifiant des metadonnées`,
+ type: 'string',
+ example: '6d0efef1-4dc9-422e-a17d-40c1bf1c37c4',
+ },
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Unauthorized]: {
+ description: "L'utilisateur est non connecté",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 401,
+ name: 'Error',
+ message: 'Authorization header not found',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description:
+ "L'utilisateur n'a pas les droits pour envoyer les métadonnées pour cette aide",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ path: '/authorization',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: "Les métadonnées ne sont pas conformes au contrat d'interface",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 422,
+ name: 'UnprocessableEntityError',
+ message:
+ 'The request body is invalid. See error object `details` property for more info.',
+ code: 'VALIDATION_FAILED',
+ details: [
+ {
+ path: '/attachmentMetadata/invoices/0/enterprise',
+ code: 'required',
+ message: "should have required property 'enterpriseName'",
+ info: {
+ missingProperty: 'enterpriseName',
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async createMetadata(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: {
+ title: 'MetadataPost',
+ allOf: [
+ {
+ type: 'object',
+ properties: {
+ incentiveId: {
+ type: 'string',
+ example: '',
+ },
+ },
+ required: ['incentiveId'],
+ },
+ {
+ type: 'object',
+ properties: {
+ attachmentMetadata: {'x-ts-type': AttachmentMetadata},
+ },
+ required: ['attachmentMetadata'],
+ },
+ ],
+ },
+ },
+ },
+ })
+ metadata: Omit,
+ ): Promise<
+ Response<{subscriptionURL: string; metadataId: string}> | HttpErrors.HttpError
+ > {
+ try {
+ const result = await this.metadataRepository.create(metadata);
+ return this.response.status(201).send({
+ // eslint-disable-next-line
+ subscriptionURL: `${WEBSITE_FQDN}/subscriptions/new?incentiveId=${result.incentiveId}&metadataId=${result.id}`,
+ metadataId: result.id,
+ });
+ } catch (error) {
+ return validationErrorExternalHandler(error);
+ }
+ }
+}
diff --git a/api/src/controllers/territory.controller.ts b/api/src/controllers/territory.controller.ts
new file mode 100644
index 0000000..1950da5
--- /dev/null
+++ b/api/src/controllers/territory.controller.ts
@@ -0,0 +1,301 @@
+import {
+ AnyObject,
+ Count,
+ CountSchema,
+ Filter,
+ repository,
+ Where,
+} from '@loopback/repository';
+import {service} from '@loopback/core';
+import {post, param, get, getModelSchemaRef, patch, requestBody} from '@loopback/rest';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+import {Incentive, Territory} from '../models';
+import {IncentiveRepository, TerritoryRepository} from '../repositories';
+import {
+ AUTH_STRATEGY,
+ SECURITY_SPEC_KC_PASSWORD,
+ StatusCode,
+ Roles,
+ ResourceName,
+ SECURITY_SPEC_API_KEY,
+} from '../utils';
+import {ValidationError} from '../validationError';
+import {TerritoryService} from '../services/territory.service';
+import {removeWhiteSpace} from './utils/helpers';
+
+const ObjectId = require('mongodb').ObjectId;
+
+export class TerritoryController {
+ constructor(
+ @repository(TerritoryRepository)
+ public territoryRepository: TerritoryRepository,
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ @service(TerritoryService)
+ public territoryService: TerritoryService,
+ ) {}
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @post('/v1/territories', {
+ 'x-controller-name': 'Territories',
+ summary: 'Crée un territoire',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Le territoire est créé',
+ content: {},
+ },
+ [StatusCode.Forbidden]: {
+ description: "L'utilisateur n'a pas les droits pour gérer les territoires",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: 'Le nom de territoire que vous avez fourni existe déjà',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 422,
+ name: 'Error',
+ message: 'territory.name.error.unique',
+ path: '/territoryName',
+ resourceName: 'Territoire',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Territory, {
+ title: 'TerritoryCreation',
+ exclude: ['id'],
+ }),
+ },
+ },
+ })
+ territory: Omit,
+ ): Promise {
+ return this.territoryService.createTerritory(territory);
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK, AUTH_STRATEGY.API_KEY)
+ @authorize({
+ allowedRoles: [
+ Roles.CONTENT_EDITOR,
+ Roles.API_KEY,
+ Roles.CITIZENS,
+ Roles.CITIZENS_FC,
+ ],
+ })
+ @get('/v1/territories', {
+ 'x-controller-name': 'Territories',
+ summary: 'Retourne la liste des territoires',
+ security: SECURITY_SPEC_API_KEY,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'La liste des territoires est retournée',
+ },
+ },
+ })
+ async find(@param.filter(Territory) filter?: Filter): Promise {
+ return this.territoryRepository.find(filter);
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/territories/count', {
+ 'x-controller-name': 'Territories',
+ summary: 'Récupère le nombre de territoires',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Le nombre de territoires est retourné',
+ content: {
+ 'application/json': {
+ schema: {...CountSchema, ...{title: 'Count'}},
+ },
+ },
+ },
+ },
+ })
+ async count(@param.where(Territory) where?: Where): Promise {
+ return this.territoryRepository.count(where);
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @patch('/v1/territories/{id}', {
+ 'x-controller-name': 'Territories',
+ summary: 'Modifie un territoire',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.NoContent]: {
+ description: 'La modification est faite',
+ },
+ [StatusCode.NotFound]: {
+ description: "Le territoire que vous voulez modifier n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Entity not found: Territory',
+ code: 'ENTITY_NOT_FOUND',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.Forbidden]: {
+ description: "L'utilisateur n'a pas les droits pour gérer les territoires",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 403,
+ name: 'Error',
+ message: 'Access denied',
+ },
+ },
+ },
+ },
+ },
+ [StatusCode.UnprocessableEntity]: {
+ description: 'Le nom de territoire que vous avez fourni existe déjà',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 422,
+ name: 'Error',
+ message: 'territory.name.error.unique',
+ path: '/territoryName',
+ resourceName: 'Territoire',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async updateById(
+ @param.path.string('id') id: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Territory, {
+ title: 'TerritoryUpdate',
+ exclude: ['id'],
+ partial: true,
+ }),
+ },
+ },
+ })
+ newTerritory: Omit,
+ ): Promise {
+ /**
+ * Removing white spaces.
+ * Exemple : " Mulhouse aglo " returns "Mulhouse aglo".
+ */
+ newTerritory.name = removeWhiteSpace(newTerritory.name);
+
+ /**
+ * Perform a case-insensitive search excluding the territory that will be updated.
+ */
+ const result: Territory | null = await this.territoryRepository.findOne({
+ where: {id: {neq: new ObjectId(id)}, name: {regexp: `/^${newTerritory.name}$/i`}},
+ });
+
+ /**
+ * Throw an error if the territory name is duplicated.
+ */
+ if (result) {
+ throw new ValidationError(
+ 'territory.name.error.unique',
+ '/territoryName',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Territory,
+ );
+ }
+
+ /**
+ * Get all incentives related to the territory.
+ */
+ const incentiveWithTerritory: Incentive[] = await this.incentiveRepository.find({
+ where: {
+ 'territory.id': id,
+ } as AnyObject,
+ });
+
+ /**
+ * Loop throught the incentives and change the territory name.
+ */
+ await Promise.all(
+ incentiveWithTerritory.map((incentive: Incentive) => {
+ const queryParams = {
+ 'territory.name': newTerritory.name,
+ } as AnyObject;
+ if (incentive.territoryName) {
+ queryParams.territoryName = newTerritory.name; // TODO: REMOVING DEPRECATED territoryName.
+ }
+ return this.incentiveRepository.updateById(incentive.id, queryParams);
+ }),
+ );
+ await this.territoryRepository.updateById(id, newTerritory);
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/territories/{id}', {
+ 'x-controller-name': 'Territories',
+ summary: `Retoune les informations d'un territoire`,
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Le territoire est retourné',
+ },
+ [StatusCode.NotFound]: {
+ description: "Le territoire n'existe pas",
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 404,
+ name: 'Error',
+ message: 'Entity not found: Territory',
+ code: 'ENTITY_NOT_FOUND',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async findById(@param.path.string('id') id: string): Promise {
+ return this.territoryRepository.findById(id);
+ }
+}
diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts
new file mode 100644
index 0000000..bc35c2b
--- /dev/null
+++ b/api/src/controllers/user.controller.ts
@@ -0,0 +1,514 @@
+import {inject} from '@loopback/core';
+import {
+ Count,
+ CountSchema,
+ repository,
+ Where,
+ AnyObject,
+ Filter,
+} from '@loopback/repository';
+import {
+ post,
+ patch,
+ param,
+ get,
+ del,
+ getModelSchemaRef,
+ requestBody,
+} from '@loopback/rest';
+import {SecurityBindings} from '@loopback/security';
+import {authenticate} from '@loopback/authentication';
+import {authorize} from '@loopback/authorization';
+
+import {head, omit, orderBy, capitalize, isEqual, intersection} from 'lodash';
+
+import {
+ UserRepository,
+ CommunityRepository,
+ UserEntityRepository,
+ KeycloakGroupRepository,
+} from '../repositories';
+import {FunderService, KeycloakService} from '../services';
+import {Community, CommunityRelations, KeycloakRole, User} from '../models';
+import {
+ ResourceName,
+ StatusCode,
+ SECURITY_SPEC_KC_PASSWORD,
+ Roles,
+ FUNDER_TYPE,
+ GROUPS,
+ AUTH_STRATEGY,
+ IUsersResult,
+ IFunder,
+ ICreate,
+ IUser,
+} from '../utils';
+import {ValidationError} from '../validationError';
+import {RequiredActionAlias} from 'keycloak-admin/lib/defs/requiredActionProviderRepresentation';
+export class UserController {
+ constructor(
+ @repository(UserRepository)
+ public userRepository: UserRepository,
+ @repository(KeycloakGroupRepository)
+ public keycloakGroupRepository: KeycloakGroupRepository,
+ @repository(UserEntityRepository)
+ public userEntityRepository: UserEntityRepository,
+ @repository(CommunityRepository)
+ public communityRepository: CommunityRepository,
+ @inject('services.KeycloakService')
+ public kcService: KeycloakService,
+ @inject('services.FunderService')
+ public funderService: FunderService,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ ) {}
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/users/funders/count', {
+ 'x-controller-name': 'Users',
+ summary: "Récupère le nombre d'utilisateurs financeurs",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'User model count',
+ content: {
+ 'application/json': {
+ schema: {...CountSchema, ...{title: 'Count'}},
+ },
+ },
+ },
+ },
+ })
+ async count(@param.where(User) where?: Where): Promise {
+ return this.userRepository.count(where);
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/users/funders', {
+ 'x-controller-name': 'Users',
+ summary: 'Retourne les utilisateurs financeurs',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of User model instances',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: getModelSchemaRef(User),
+ },
+ },
+ },
+ },
+ },
+ })
+ async find(@param.filter(User) filter?: Filter): Promise {
+ const funders = await this.funderService.getFunders();
+ const users: User[] = await this.userRepository.find(filter);
+
+ const usersResult: Promise[] =
+ users &&
+ users.map(async (user: User) => {
+ const funder: IFunder | undefined =
+ funders && funders.find((fnd: IFunder) => fnd.id === user.funderId);
+
+ let communities = undefined;
+ if (user.communityIds && user.communityIds.length >= 0) {
+ communities = await Promise.all(
+ user.communityIds?.map((id: string) =>
+ this.communityRepository.find({
+ where: {id},
+ fields: {name: true},
+ }),
+ ),
+ );
+ }
+
+ const community: string[] | undefined = communities?.map(
+ (res: (Community & CommunityRelations)[]) => res[0].name,
+ );
+
+ const roles: KeycloakRole[] = await this.userEntityRepository.getUserRoles(
+ user.id,
+ );
+
+ const rolesFormatted: string[] = roles.map(({name}) => name);
+
+ const funderRoles: string[] =
+ await this.keycloakGroupRepository.getSubGroupFunderRoles();
+
+ const funderRolesUser: string[] = intersection(
+ rolesFormatted,
+ funderRoles,
+ ).filter(x => x);
+
+ const rolesMatchedAndMapped =
+ funderRolesUser &&
+ funderRolesUser
+ .map((elt: string) => capitalize(elt.replace(/s$/, '')))
+ .join(' ; ');
+
+ return {
+ ...user,
+ funderType: capitalize(funder?.funderType),
+ communityName: community
+ ? community.join(' ; ')
+ : rolesMatchedAndMapped === 'Superviseur'
+ ? ''
+ : funderRolesUser
+ ? 'Ensemble du périmètre financeur'
+ : null,
+ funderName: funder?.name,
+ roles: rolesMatchedAndMapped,
+ };
+ });
+
+ const resolved = await Promise.all(
+ orderBy(usersResult, ['funderName', 'lastName'], ['asc']),
+ );
+
+ return resolved;
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @post('/v1/users', {
+ 'x-controller-name': 'Users',
+ summary: 'Crée un utilisateur financeur',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'User financeur model instance',
+ content: {'application/json': {schema: getModelSchemaRef(User)}},
+ },
+ [StatusCode.PreconditionFailed]: {
+ description: `Votre entreprise n'accepte pas l'affiliation manuelle`,
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 412,
+ name: 'Error',
+ message: 'users.funder.manualAffiliation.refuse',
+ path: '/users',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async create(
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(User),
+ },
+ },
+ })
+ user: User,
+ ): Promise {
+ let keycloakResult: ICreate = {id: ''};
+ try {
+ const funders = await this.funderService.getFunders();
+ const fundersFiltered = head(funders.filter(({id}) => id === user.funderId));
+
+ /**
+ * Check if this user's company accepts manual affiliation
+ */
+ if (!fundersFiltered?.hasManualAffiliation && user.canReceiveAffiliationMail) {
+ throw new ValidationError(
+ `users.funder.manualAffiliation.refuse`,
+ `/users`,
+ StatusCode.PreconditionFailed,
+ ResourceName.User,
+ );
+ }
+
+ if (fundersFiltered) {
+ const {name, funderType, emailFormat} = fundersFiltered;
+ if (
+ funderType === FUNDER_TYPE.collectivity ||
+ emailFormat?.some((format: string) => user.email.endsWith(format))
+ ) {
+ const availableRoles =
+ await this.keycloakGroupRepository.getSubGroupFunderRoles();
+ const {roles, funderId, communityIds} = user;
+
+ if (isEqual(intersection(availableRoles, roles).sort(), roles.sort())) {
+ const availableCommunities: Community[] =
+ await this.communityRepository.findByFunderId(funderId);
+ const conditionNoCommunities =
+ (!communityIds || communityIds.length === 0) &&
+ availableCommunities &&
+ availableCommunities.length === 0;
+
+ const conditionMismatch =
+ !conditionNoCommunities &&
+ communityIds &&
+ isEqual(
+ intersection(
+ availableCommunities.map(({id}) => id),
+ communityIds,
+ ).sort(),
+ communityIds.sort(),
+ );
+
+ if (
+ !roles.includes(Roles.MANAGERS) ||
+ (roles.includes(Roles.MANAGERS) &&
+ (conditionNoCommunities || conditionMismatch))
+ ) {
+ const actions: RequiredActionAlias[] = [
+ RequiredActionAlias.VERIFY_EMAIL,
+ RequiredActionAlias.UPDATE_PASSWORD,
+ ];
+ keycloakResult = await this.kcService.createUserKc(
+ {
+ ...user,
+ funderName: name,
+ group: [
+ `/${
+ funderType === FUNDER_TYPE.collectivity
+ ? GROUPS.collectivities
+ : GROUPS.enterprises
+ }/${name}`,
+ ...user.roles.map(role => `${GROUPS.funders}/${role}`),
+ ],
+ },
+ actions,
+ );
+ if (keycloakResult && keycloakResult.id) {
+ user.id = keycloakResult.id;
+ const propertiesToOmit = roles.includes(Roles.MANAGERS)
+ ? ['password', 'roles']
+ : ['password', 'roles', 'communityIds'];
+ const userRepo = omit(user, propertiesToOmit);
+
+ await this.userRepository.create(userRepo);
+
+ // Send mail to set password and activate account.
+ await this.kcService.sendExecuteActionsEmailUserKc(
+ keycloakResult.id,
+ actions,
+ );
+
+ // returning id because of react-admin specifications
+ return {
+ id: userRepo.id,
+ email: userRepo.email,
+ lastName: userRepo.lastName,
+ firstName: userRepo.firstName,
+ };
+ }
+ return keycloakResult;
+ }
+ throw new ValidationError(
+ `users.error.communities.mismatch`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ }
+ throw new ValidationError(
+ `users.error.roles.mismatch`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ }
+
+ throw new ValidationError(
+ `email.error.emailFormat`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ }
+ throw new ValidationError(
+ `users.error.funders.missed`,
+ `/users`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.User,
+ );
+ } catch (error) {
+ if (keycloakResult && keycloakResult.id) {
+ await this.kcService.deleteUserKc(keycloakResult.id);
+ }
+ throw error;
+ }
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @get('/v1/users/roles', {
+ 'x-controller-name': 'Users',
+ summary: 'Retourne les rôles des utilisateurs financeurs',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Array of roles',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async getRolesForUsers(): Promise {
+ return this.keycloakGroupRepository.getSubGroupFunderRoles();
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({
+ allowedRoles: [Roles.CONTENT_EDITOR, Roles.FUNDERS],
+ })
+ @get('/v1/users/{userId}', {
+ 'x-controller-name': 'Users',
+ summary: "Retourne les informations de l'utilisateur financeur",
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'User model instance',
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(User),
+ },
+ },
+ },
+ },
+ })
+ async findUserById(
+ @param.path.string('userId', {
+ description: `L'identifiant de l'utilisateur financeur`,
+ })
+ userId: string,
+ ): Promise {
+ const isContentEditor = this.currentUser?.roles?.includes(Roles.CONTENT_EDITOR);
+
+ if (this.currentUser?.id !== userId && !isContentEditor) {
+ throw new ValidationError(`Access Denied`, `/authorization`, StatusCode.Forbidden);
+ }
+
+ const rolesQuery: KeycloakRole[] = await this.userEntityRepository.getUserRoles(
+ userId,
+ );
+ const rolesFormatted: string[] = rolesQuery.map(({name}) => name);
+ const user: User = await this.userRepository.findById(userId);
+
+ const res: any = {
+ ...user,
+ roles: rolesFormatted,
+ };
+
+ return res;
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @patch('/v1/users/{userId}', {
+ 'x-controller-name': 'Users',
+ summary: 'Modifie un utilisateur financeur',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'Utilisateur patch success',
+ },
+ [StatusCode.PreconditionFailed]: {
+ description: `Votre entreprise n'accepte pas l'affiliation manuelle`,
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(Error),
+ example: {
+ error: {
+ statusCode: 412,
+ name: 'Error',
+ message: 'users.funder.manualAffiliation.refuse',
+ path: '/users',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ async updateById(
+ @param.path.string('userId', {
+ description: `L'identifiant de l'utilisateur financeur`,
+ })
+ userId: string,
+ @requestBody({
+ content: {
+ 'application/json': {
+ schema: getModelSchemaRef(User, {title: 'UserUpdate', partial: true}),
+ },
+ },
+ })
+ user: User,
+ ): Promise<{id: string}> {
+ /**
+ * Check if this user's company accepts manual affiliation
+ */
+ if (user?.canReceiveAffiliationMail) {
+ const funders = await this.funderService.getFunders();
+ const filteredFunder = head(funders.filter(({id}) => id === user.funderId));
+
+ if (!filteredFunder?.hasManualAffiliation) {
+ throw new ValidationError(
+ `users.funder.manualAffiliation.refuse`,
+ `/users`,
+ StatusCode.PreconditionFailed,
+ ResourceName.User,
+ );
+ }
+ }
+
+ const userRepo: User = await this.userRepository.findById(userId);
+ if (userRepo) {
+ const {roles} = user;
+ const propertiesToOmit = ['roles'];
+
+ if (user.communityIds) {
+ user.communityIds = roles.includes(Roles.MANAGERS) ? user.communityIds : [];
+ }
+
+ const userOmit = omit(user, propertiesToOmit);
+ await this.kcService.updateUserGroupsKc(userId, roles);
+ await this.kcService.updateUser(userId, userOmit);
+ await this.userRepository.updateById(userId, userOmit);
+ }
+ return {id: userId};
+ }
+
+ @authenticate(AUTH_STRATEGY.KEYCLOAK)
+ @authorize({allowedRoles: [Roles.CONTENT_EDITOR]})
+ @del('/v1/users/{userId}', {
+ 'x-controller-name': 'Users',
+ summary: 'Supprime un utilisateur financeur',
+ security: SECURITY_SPEC_KC_PASSWORD,
+ responses: {
+ [StatusCode.Success]: {
+ description: 'utilisateur DELETE success',
+ },
+ },
+ })
+ async deleteById(
+ @param.path.string('userId', {
+ description: `L'identifiant de l'utilisateur financeur`,
+ })
+ userId: string,
+ ): Promise<{id: string}> {
+ await this.kcService.deleteUserKc(userId);
+ await this.userRepository.deleteById(userId);
+ return {id: userId};
+ }
+}
diff --git a/api/src/controllers/utils/helpers.ts b/api/src/controllers/utils/helpers.ts
new file mode 100644
index 0000000..4a1addc
--- /dev/null
+++ b/api/src/controllers/utils/helpers.ts
@@ -0,0 +1,127 @@
+import {Subscription} from '../../models';
+import {FUNDER_TYPE, IDashboardCitizenIncentiveList, INCENTIVE_TYPE} from '../../utils';
+
+/**
+ * INTERFACES
+ *
+ *
+ *
+ *
+ */
+interface SearchQueryDate {
+ startDate: Date;
+ endDate: Date;
+}
+
+/**
+ * get the percentage of the validated subscription
+ */
+export const calculatePercentage = (
+ incentiveList: Array,
+ totalCitizensCount: number,
+): Array => {
+ incentiveList.forEach((incentive: IDashboardCitizenIncentiveList) => {
+ incentive.validatedSubscriptionPercentage = Math.round(
+ (100 * incentive.totalSubscriptionsCount) / totalCitizensCount,
+ );
+ });
+
+ return incentiveList;
+};
+
+/**
+ * get valid date depending on the year and semester values
+ */
+export const setValidDate = (year: string, semester: string) => {
+ const newValidDate: SearchQueryDate = {
+ startDate: new Date(),
+ endDate: new Date(),
+ };
+
+ /**
+ * determine start and end Date for year
+ */
+ newValidDate.startDate = new Date(year);
+ newValidDate.endDate = new Date(
+ newValidDate.startDate.getFullYear() + 1,
+ newValidDate.startDate.getMonth(),
+ newValidDate.startDate.getDate(),
+ );
+
+ /**
+ * determine start and end Date for semester additional filter
+ * set the first semester
+ */
+ if (Number(semester) === 1) {
+ newValidDate.endDate = new Date(
+ newValidDate.startDate.getFullYear(),
+ newValidDate.startDate.getMonth() + 6,
+ newValidDate.startDate.getDate(),
+ );
+ }
+
+ /**
+ * set the second semester
+ */
+ if (Number(semester) === 2) {
+ newValidDate.endDate = new Date(
+ newValidDate.startDate.getFullYear() + 1,
+ newValidDate.startDate.getMonth(),
+ newValidDate.startDate.getDate(),
+ );
+
+ newValidDate.startDate = new Date(
+ newValidDate.startDate.getFullYear(),
+ newValidDate.startDate.getMonth() + 6,
+ newValidDate.startDate.getDate(),
+ );
+ }
+
+ return newValidDate;
+};
+
+/**
+ * sort the demands list by the total subscriptions count
+ * @returns sorted list
+ */
+export const isFirstElementGreater = (
+ a: {totalSubscriptionsCount: number},
+ b: {totalSubscriptionsCount: number},
+) => {
+ if (a.totalSubscriptionsCount > b.totalSubscriptionsCount) {
+ return -1;
+ }
+ return 0;
+};
+
+/**
+ * Returns list of emails and funderType based on incentive type
+ */
+export const getFunderTypeAndListEmails = (subscription: Subscription) => {
+ const listEmails: string[] = [subscription.email];
+
+ let funderType: FUNDER_TYPE;
+
+ if (subscription.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) {
+ funderType = FUNDER_TYPE.enterprise;
+ if (subscription.enterpriseEmail) {
+ listEmails.push(subscription.enterpriseEmail);
+ }
+ } else if (subscription.incentiveType === INCENTIVE_TYPE.TERRITORY_INCENTIVE) {
+ funderType = FUNDER_TYPE.collectivity;
+ }
+ return {listEmails: listEmails, funderType: funderType!};
+};
+
+/**
+ * Returns the string with white spaces removed
+ */
+export const removeWhiteSpace = (word: string): string => {
+ /**
+ * Regex for removing white spaces.
+ * Exemple : " Removing white spaces " returns "Removing white spaces".
+ */
+ const removeSpacesRegex: RegExp = new RegExp('^\\s+|\\s+$|\\s+(?=\\s)', 'g');
+ const newWord: string = word.replace(removeSpacesRegex, '');
+ return newWord;
+};
diff --git a/api/src/controllers/utils/incentiveExample.ts b/api/src/controllers/utils/incentiveExample.ts
new file mode 100644
index 0000000..431d760
--- /dev/null
+++ b/api/src/controllers/utils/incentiveExample.ts
@@ -0,0 +1,210 @@
+export const incentiveExample = [
+ {
+ id: '',
+ title: 'Aide pour acheter vélo électrique',
+ description: `Sous conditions d'éligibilité,\
+ Mulhouse met à disposition une aide au financement d'un vélo électrique`,
+ territory: {name: 'Mulhouse', id: 'randomTerritoryId'},
+ funderName: 'Mulhouse',
+ incentiveType: 'AideTerritoire',
+ conditions: "Fournir une preuve d'achat d'un vélo électrique",
+ paymentMethod: 'Remboursement par virement',
+ allocatedAmount: '500 €',
+ minAmount: '50 €',
+ transportList: ['electrique'],
+ attachments: ['Justificatif de domicile'],
+ additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Mulhouse',
+ contact: 'Contactez le numéro vert au 05 206 308',
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: false,
+ subscriptionLink: 'https://www.mulhouse.com',
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ funderId: '25eb2670-7984-4ff8-b43c-515a694edfe0',
+ },
+ {
+ id: '',
+ title: 'Aide pour acheter scooter électrique',
+ description: `Sous conditions d'éligibilité,\
+ Capgemini Toulouse met à disposition une aide au financement d'un scooter électrique`,
+ territory: {name: 'Toulouse', id: 'randomTerritoryId'},
+ funderName: 'Capgemini',
+ incentiveType: 'AideEmployeur',
+ conditions: "Fournir une preuve d'achat d'un scooter électrique",
+ paymentMethod: 'Remboursement par virement',
+ allocatedAmount: '500 €',
+ minAmount: '50 €',
+ transportList: ['electrique'],
+ attachments: ['identite', 'factureAchat'],
+ additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Toulouse',
+ contact: 'Contactez le numéro vert au 05 206 308',
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: false,
+ subscriptionLink: 'https://www.capgemini.com',
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ funderId: '93a294b9-4ac3-483d-9831-ebe6e3609c29',
+ },
+ {
+ id: '',
+ title: 'Bonus écologique',
+ description: `Profiter d'un bonus écologique pour les personnes,\
+ qui souhaitent acquérir une voiture électrique ou hybride`,
+ territory: {name: 'France entière', id: 'randomTerritoryId'},
+ funderName: 'État français',
+ incentiveType: 'AideNationale',
+ conditions: 'Acheter une voiture hybride ou électrique à compter du 1er janvier 2022',
+ paymentMethod: `Aide de l'état`,
+ allocatedAmount: '500 €',
+ minAmount: '1000 €',
+ transportList: ['voiture'],
+ attachments: ['identite', 'factureAchat'],
+ additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Toulouse',
+ contact: 'Contactez le numéro vert au 05 206 308',
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: false,
+ subscriptionLink: 'www.primealaconversion.gouv.fr',
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ },
+ {
+ id: '',
+ title: 'Le vélo électrique arrive à Mulhouse !',
+ description: `Sous conditions d'éligibilité,\
+ Mulhouse met à disposition une aide au financement d'un vélo électrique`,
+ territory: {name: 'Mulhouse', id: 'randomTerritoryId'},
+ funderName: 'Mulhouse',
+ incentiveType: 'AideTerritoire',
+ conditions: "Fournir une preuve d'achat d'un vélo électrique",
+ paymentMethod: 'Remboursement par virement',
+ allocatedAmount: '500 €',
+ minAmount: '50 €',
+ transportList: ['electrique'],
+ attachments: ['Justificatif de domicile'],
+ additionalInfos: 'Aide mise à disposition uniquement pour les habitants de Mulhouse',
+ contact: 'Contactez le numéro vert au 05 206 308',
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: true,
+ specificFields: [
+ {
+ title: 'Statut marital',
+ inputFormat: 'listeChoix',
+ choiceList: {
+ possibleChoicesNumber: 1,
+ inputChoiceList: [
+ {
+ inputChoice: 'Marié',
+ },
+ ],
+ },
+ },
+ ],
+ jsonSchema: {
+ properties: {
+ 'Statut marital': {
+ type: 'array',
+ maxItems: 1,
+ items: {
+ enum: ['Marié', 'Célibataire'],
+ },
+ },
+ },
+ type: 'object',
+ required: ['Statut marital'],
+ additionalProperties: false,
+ },
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ funderId: 'e13c5de9-d695-4c03-b7b7-9efdd13d937c',
+ },
+];
+
+export const incentiveContentDifferentExample = {
+ response: [
+ {
+ id: '',
+ title: 'Aide pour acheter vélo électrique',
+ description: `Sous conditions d'éligibilité,\
+ Mulhouse met à disposition une aide au financement d'un vélo électrique`,
+ territory: {name: 'Mulhouse', id: 'randomTerritoryId'},
+ funderName: 'Mulhouse',
+ incentiveType: 'AideTerritoire',
+ conditions: "Fournir une preuve d'achat d'un vélo électrique",
+ paymentMethod: 'Remboursement par virement',
+ allocatedAmount: '500 €',
+ minAmount: '50 €',
+ transportList: ['electrique'],
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: false,
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ funderId: '25eb2670-7984-4ff8-b43c-515a694edfe0',
+ },
+ {
+ id: '',
+ title: 'Aide pour acheter scooter électrique',
+ description: `Sous conditions d'éligibilité,\
+ Capgemini Toulouse met à disposition une aide au financement d'un scooter électrique`,
+ territory: {name: 'Toulouse', id: 'randomTerritoryId'},
+ funderName: 'Capgemini',
+ incentiveType: 'AideEmployeur',
+ conditions: "Fournir une preuve d'achat d'un scooter électrique",
+ paymentMethod: 'Remboursement par virement',
+ allocatedAmount: '500 €',
+ minAmount: '50 €',
+ transportList: ['electrique'],
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: false,
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ funderId: '93a294b9-4ac3-483d-9831-ebe6e3609c29',
+ },
+ {
+ id: '',
+ title: 'Bonus écologique',
+ description: `Profiter d'un bonus écologique pour les personnes,\
+ qui souhaitent acquérir une voiture électrique ou hybride`,
+ territory: {name: 'France entière', id: 'randomTerritoryId'},
+ funderName: 'État français',
+ incentiveType: 'AideNationale',
+ conditions:
+ 'Acheter une voiture hybride ou électrique à compter du 1er janvier 2022',
+ paymentMethod: `Aide de l'état`,
+ allocatedAmount: '500 €',
+ minAmount: '1000 €',
+ transportList: ['voiture'],
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: false,
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ },
+ {
+ id: '',
+ title: 'Le vélo électrique arrive à Mulhouse !',
+ description: `Sous conditions d'éligibilité,\
+ Mulhouse met à disposition une aide au financement d'un vélo électrique`,
+ territory: {name: 'Mulhouse', id: 'randomTerritoryId'},
+ funderName: 'Mulhouse',
+ incentiveType: 'AideTerritoire',
+ conditions: "Fournir une preuve d'achat d'un vélo électrique",
+ paymentMethod: 'Remboursement par virement',
+ allocatedAmount: '500 €',
+ minAmount: '50 €',
+ transportList: ['electrique'],
+ validityDuration: '12 mois',
+ validityDate: '2024-07-31',
+ isMCMStaff: true,
+ createdAt: '2022-01-01 00:00:00.000Z',
+ updatedAt: '2022-01-02 00:00:00.000Z',
+ funderId: 'e13c5de9-d695-4c03-b7b7-9efdd13d937c',
+ },
+ ],
+ message: "Le Citoyen est bien affilié à son employeur, mais il ne dispose pas d'aides.",
+};
diff --git a/api/src/cronjob/index.ts b/api/src/cronjob/index.ts
new file mode 100644
index 0000000..4526765
--- /dev/null
+++ b/api/src/cronjob/index.ts
@@ -0,0 +1,3 @@
+export * from './rabbitmqCronJob';
+export * from './subscriptionCronJob';
+export * from './nonActivatedAccountDeletionCronJob';
diff --git a/api/src/cronjob/nonActivatedAccountDeletionCronJob.ts b/api/src/cronjob/nonActivatedAccountDeletionCronJob.ts
new file mode 100644
index 0000000..ccdec26
--- /dev/null
+++ b/api/src/cronjob/nonActivatedAccountDeletionCronJob.ts
@@ -0,0 +1,74 @@
+import {service} from '@loopback/core';
+import {CronJob, cronJob} from '@loopback/cron';
+
+import {CronJobService, CitizenService} from '../services';
+import {CronJob as CronJobModel} from '../models';
+
+import {CRON_TYPES, logger} from '../utils';
+import {isAfterDate} from '../utils/date';
+
+@cronJob()
+export class NonActivatedAccountDeletionCronJob extends CronJob {
+ // Cron's type
+ private cronType: string = CRON_TYPES.DELETE_SUBSCRIPTION;
+
+ constructor(
+ @service(CronJobService)
+ public cronJobService: CronJobService,
+ @service(CitizenService)
+ public citizenService: CitizenService,
+ ) {
+ super({
+ name: 'nonActivatedAccountDeletion-job',
+ onTick: async () => {
+ logger.info(`${NonActivatedAccountDeletionCronJob.name} - ticked`);
+ await this.performJob();
+ },
+ cronTime: '0 3 * * *',
+ start: false,
+ });
+ }
+
+ // cron process
+ private async createCron(): Promise {
+ let createdCronId: CronJobModel | null = null;
+ try {
+ createdCronId = await this.cronJobService.createCronLog(this.cronType);
+ logger.info(`${NonActivatedAccountDeletionCronJob.name} created`);
+ await this.citizenService.accountDeletionService();
+ await this.cronJobService.delCronLog(this.cronType);
+ logger.info(`${NonActivatedAccountDeletionCronJob.name} finished`);
+ } catch (error) {
+ if (createdCronId && createdCronId.id) {
+ await this.cronJobService.delCronLogById(createdCronId.id);
+ }
+ throw new Error(`${error}`);
+ }
+ }
+
+ /**
+ * Perform cron job
+ */
+ private async performJob(): Promise {
+ try {
+ // Get active crons jobs
+ const activeCronList: CronJobModel[] = await this.cronJobService.getCronsLog();
+
+ // Check if this cron is already in use
+ const cronAlreadyInUse: CronJobModel[] | [] = activeCronList.filter(
+ (cron: CronJobModel) => cron.type === this.cronType,
+ );
+ if (
+ cronAlreadyInUse?.[0]?.createdAt &&
+ isAfterDate(cronAlreadyInUse?.[0]?.createdAt, 2)
+ ) {
+ // Delete old log
+ await this.cronJobService.delCronLog(this.cronType);
+ }
+
+ await this.createCron();
+ } catch (err) {
+ logger.error(`${NonActivatedAccountDeletionCronJob.name}: ${err}`);
+ }
+ }
+}
diff --git a/api/src/cronjob/rabbitmqCronJob.ts b/api/src/cronjob/rabbitmqCronJob.ts
new file mode 100644
index 0000000..e5da0b2
--- /dev/null
+++ b/api/src/cronjob/rabbitmqCronJob.ts
@@ -0,0 +1,62 @@
+import {inject, service} from '@loopback/core';
+import {CronJob, cronJob} from '@loopback/cron';
+
+import {isEqual, difference} from 'lodash';
+
+import {ParentProcessService, RabbitmqService} from '../services';
+import {EVENT_MESSAGE, UPDATE_MODE, logger} from '../utils';
+
+@cronJob()
+export class RabbitmqCronJob extends CronJob {
+ private enterpriseHRISNameList: string[] = [];
+
+ constructor(
+ @service(RabbitmqService)
+ public rabbitmqService: RabbitmqService,
+ @inject('services.ParentProcessService')
+ public parentProcessService: ParentProcessService,
+ ) {
+ super({
+ name: 'rabbitmq-job',
+ onTick: async () => {
+ logger.info(`${RabbitmqCronJob.name} - ticked`);
+ await this.performJob();
+ },
+ cronTime: '0 2 * * *',
+ start: false,
+ });
+
+ // Start cronjob once process is ready
+ this.parentProcessService.on(
+ EVENT_MESSAGE.READY,
+ () => this.fireOnTick() && this.start(),
+ );
+ }
+
+ /**
+ * Perform cron job
+ */
+ private async performJob(): Promise {
+ try {
+ const repositoryList: string[] =
+ await this.rabbitmqService.getHRISEnterpriseNameList();
+
+ // If arrays are the same, no need to send updated list
+ if (!isEqual(repositoryList, this.enterpriseHRISNameList)) {
+ // Find added HRIS enterprise
+ const added = difference(repositoryList, this.enterpriseHRISNameList);
+
+ // Find deleted HRIS enterprise
+ const deleted = difference(this.enterpriseHRISNameList, repositoryList);
+
+ this.enterpriseHRISNameList = repositoryList;
+ this.parentProcessService.emit(EVENT_MESSAGE.UPDATE, {
+ type: EVENT_MESSAGE.UPDATE,
+ data: {[UPDATE_MODE.ADD]: added, [UPDATE_MODE.DELETE]: deleted},
+ });
+ }
+ } catch (err) {
+ throw new Error('An error occurred');
+ }
+ }
+}
diff --git a/api/src/cronjob/subscriptionCronJob.ts b/api/src/cronjob/subscriptionCronJob.ts
new file mode 100644
index 0000000..3bda382
--- /dev/null
+++ b/api/src/cronjob/subscriptionCronJob.ts
@@ -0,0 +1,74 @@
+import {service} from '@loopback/core';
+import {CronJob, cronJob} from '@loopback/cron';
+
+import {CronJobService, SubscriptionService} from '../services';
+import {CronJob as CronJobModel} from '../models';
+
+import {logger, CRON_TYPES} from '../utils';
+import {isAfterDate} from '../utils/date';
+
+@cronJob()
+export class SubscriptionCronJob extends CronJob {
+ // Cron's type
+ private cronType: string = CRON_TYPES.DELETE_SUBSCRIPTION;
+
+ constructor(
+ @service(CronJobService)
+ public cronJobService: CronJobService,
+ @service(SubscriptionService)
+ public subscriptionService: SubscriptionService,
+ ) {
+ super({
+ name: 'subscription-job',
+ onTick: async () => {
+ logger.info(`${SubscriptionCronJob.name} - ticked`);
+ await this.performJob();
+ },
+ cronTime: '0 2 * * *',
+ start: false,
+ });
+ }
+
+ // cron process
+ private async createCron(): Promise {
+ let createdCronId: CronJobModel | null = null;
+ try {
+ createdCronId = await this.cronJobService.createCronLog(this.cronType);
+ logger.info(`${SubscriptionCronJob.name} created`);
+ await this.subscriptionService.deleteSubscription();
+ await this.cronJobService.delCronLog(this.cronType);
+ logger.info(`${SubscriptionCronJob.name} finished`);
+ } catch (error) {
+ if (createdCronId && createdCronId.id) {
+ await this.cronJobService.delCronLogById(createdCronId.id);
+ }
+ throw new Error(`${error}`);
+ }
+ }
+
+ /**
+ * Perform cron job
+ */
+ private async performJob(): Promise {
+ try {
+ // Get active crons jobs
+ const activeCronList: CronJobModel[] = await this.cronJobService.getCronsLog();
+
+ // Check if this cron is already in use
+ const cronAlreadyInUse: CronJobModel[] | [] = activeCronList.filter(
+ (cron: CronJobModel) => cron.type === this.cronType,
+ );
+ if (
+ cronAlreadyInUse?.[0]?.createdAt &&
+ isAfterDate(cronAlreadyInUse?.[0]?.createdAt, 2)
+ ) {
+ // Delete old log
+ await this.cronJobService.delCronLog(this.cronType);
+ }
+
+ await this.createCron();
+ } catch (err) {
+ logger.error(`${SubscriptionCronJob.name}: ${err}`);
+ }
+ }
+}
diff --git a/api/src/datasources/README.md b/api/src/datasources/README.md
new file mode 100644
index 0000000..57ae382
--- /dev/null
+++ b/api/src/datasources/README.md
@@ -0,0 +1,3 @@
+# Datasources
+
+This directory contains config for datasources used by this app.
diff --git a/api/src/datasources/idpdb-ds.datasource.ts b/api/src/datasources/idpdb-ds.datasource.ts
new file mode 100644
index 0000000..e240fa5
--- /dev/null
+++ b/api/src/datasources/idpdb-ds.datasource.ts
@@ -0,0 +1,32 @@
+import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core';
+import {juggler} from '@loopback/repository';
+
+const config = {
+ name: 'idpdbDS',
+ connector: 'postgresql',
+ host: process.env.IDP_DB_HOST ?? 'localhost',
+ port: process.env.IDP_DB_PORT ?? 5432,
+ user: process.env.IDP_DB_SERVICE_USER ?? 'admin',
+ password: process.env.IDP_DB_SERVICE_PASSWORD ?? 'pass',
+ database: process.env.IDP_DB_DATABASE ?? 'idp_db',
+ ssl:
+ process.env.LANDSCAPE && process.env.LANDSCAPE !== 'preview'
+ ? {
+ rejectUnauthorized: true,
+ ca: Buffer.from(process.env.PGSQL_FLEX_SSL_CERT!, 'base64').toString('utf-8'),
+ }
+ : null,
+};
+
+@lifeCycleObserver('datasource')
+export class IdpDbDataSource extends juggler.DataSource implements LifeCycleObserver {
+ static dataSourceName = 'idpdbDS';
+ static readonly defaultConfig = config;
+
+ constructor(
+ @inject('datasources.config.idpdbDS', {optional: true})
+ dsConfig: object = config,
+ ) {
+ super(dsConfig);
+ }
+}
diff --git a/api/src/datasources/index.ts b/api/src/datasources/index.ts
new file mode 100644
index 0000000..4dd6aeb
--- /dev/null
+++ b/api/src/datasources/index.ts
@@ -0,0 +1,2 @@
+export * from './mongo-ds.datasource';
+export * from './idpdb-ds.datasource';
diff --git a/api/src/datasources/mongo-ds.datasource.ts b/api/src/datasources/mongo-ds.datasource.ts
new file mode 100644
index 0000000..95a3892
--- /dev/null
+++ b/api/src/datasources/mongo-ds.datasource.ts
@@ -0,0 +1,43 @@
+import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core';
+import {juggler} from '@loopback/repository';
+
+const configProd = {
+ name: 'mongoDS',
+ connector: 'mongodb',
+ url: `mongodb+srv://${process.env.MONGO_SERVICE_USER}:${process.env.MONGO_SERVICE_PASSWORD}@${process.env.MONGO_HOST}/${process.env.MONGO_DATABASE}?retryWrites=true&w=majority`,
+};
+
+const configPreview = {
+ name: 'mongoDS',
+ connector: 'mongodb',
+ host: process.env.MONGO_HOST ?? 'localhost',
+ port: process.env.MONGO_PORT ?? 27017,
+ user: process.env.MONGO_SERVICE_USER ?? 'admin',
+ password: process.env.MONGO_SERVICE_PASSWORD ?? 'pass',
+ database: process.env.MONGO_DATABASE ?? 'mcm',
+ authSource: process.env.MONGO_AUTH_SOURCE ?? 'admin',
+ useNewUrlParser: true,
+ allowExtendedOperators: true,
+};
+
+const config =
+ process.env.LANDSCAPE && process.env.LANDSCAPE !== 'preview'
+ ? configProd
+ : configPreview;
+
+// Observe application's life cycle to disconnect the datasource when
+// application is stopped. This allows the application to be shut down
+// gracefully. The `stop()` method is inherited from `juggler.DataSource`.
+// Learn more at https://loopback.io/doc/en/lb4/Life-cycle.html
+@lifeCycleObserver('datasource')
+export class MongoDsDataSource extends juggler.DataSource implements LifeCycleObserver {
+ static dataSourceName = 'mongoDS';
+ static readonly defaultConfig = config;
+
+ constructor(
+ @inject('datasources.config.mongoDS', {optional: true})
+ dsConfig: object = config,
+ ) {
+ super(dsConfig);
+ }
+}
diff --git a/api/src/index.ts b/api/src/index.ts
new file mode 100644
index 0000000..b737578
--- /dev/null
+++ b/api/src/index.ts
@@ -0,0 +1,39 @@
+import {ApplicationConfig, App} from './application';
+import {logger} from './utils';
+
+export async function main(options: ApplicationConfig = {}) {
+ const app = new App(options);
+ await app.boot();
+ await app.start();
+
+ const url = app.restServer.url;
+ logger.info(`Server is running at ${url}`);
+ return app;
+}
+
+if (require.main === module) {
+ // Run the application
+ const config = {
+ rest: {
+ expressSettings: {
+ 'x-powered-by': false,
+ },
+ port: +(process.env.PORT ?? 3000),
+ host: process.env.HOST,
+ // The `gracePeriodForClose` provides a graceful close for http/https
+ // servers with keep-alive clients. The default value is `Infinity`
+ // (don't force-close). If you want to immediately destroy all sockets
+ // upon stop, set its value to `0`.
+ // See https://www.npmjs.com/package/stoppable
+ gracePeriodForClose: 5000, // 5 seconds
+ openApiSpec: {
+ // useful when used with OpenAPI-to-GraphQL to locate your application
+ setServersFromRequest: true,
+ },
+ },
+ };
+ main(config).catch(err => {
+ logger.error(`Cannot start the application: ${err}`);
+ process.exit(1);
+ });
+}
diff --git a/api/src/interceptors/citizen.interceptor.ts b/api/src/interceptors/citizen.interceptor.ts
new file mode 100644
index 0000000..7975285
--- /dev/null
+++ b/api/src/interceptors/citizen.interceptor.ts
@@ -0,0 +1,229 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {CitizenService} from '../services';
+import {Citizen} from '../models/citizen/citizen.model';
+import {CitizenRepository, UserRepository} from '../repositories';
+import {isAgeValid} from './utils';
+import {ValidationError} from '../validationError';
+import {
+ ResourceName,
+ StatusCode,
+ logger,
+ AFFILIATION_STATUS,
+ IUser,
+ Roles,
+} from '../utils';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+
+@injectable({tags: {key: CitizenInterceptor.BINDING_KEY}})
+export class CitizenInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${CitizenInterceptor.name}`;
+
+ /*
+ * constructor
+ */
+ constructor(
+ @inject('services.CitizenService')
+ public citizenService: CitizenService,
+ @repository(CitizenRepository)
+ public citizenRepository: CitizenRepository,
+ @repository(UserRepository)
+ private userRepository: UserRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ let citizen: Citizen | undefined;
+ if (invocationCtx.methodName === 'create') {
+ citizen = invocationCtx.args[0];
+ if (!citizen?.tos1 || !citizen?.tos2) {
+ throw new ValidationError(
+ `Citizen must agree to terms of services`,
+ '/tos',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Account,
+ );
+ }
+
+ if (!citizen?.password) {
+ throw new ValidationError(
+ `Password cannot be empty`,
+ '/password',
+ StatusCode.PreconditionFailed,
+ ResourceName.Account,
+ );
+ }
+ }
+
+ if (invocationCtx.methodName === 'replaceById') citizen = invocationCtx.args[1];
+
+ if (invocationCtx.methodName === 'findCitizenId') {
+ const citizenId = invocationCtx.args[0];
+ const citizen = await this.citizenRepository.findOne({
+ where: {id: citizenId},
+ });
+ if (!citizen) {
+ throw new ValidationError(
+ `Citizen not found`,
+ '/citizenNotFound',
+ StatusCode.NotFound,
+ ResourceName.Citizen,
+ );
+ }
+ }
+
+ if (citizen && !isAgeValid(citizen.identity.birthDate.value)) {
+ throw new ValidationError(
+ `citizens.error.birthdate.age`,
+ '/birthdate',
+ StatusCode.PreconditionFailed,
+ ResourceName.Account,
+ );
+ }
+ if (invocationCtx.methodName === 'validateAffiliation') {
+ const user = this.currentUser;
+ if (user.id && !user.roles?.includes(Roles.CITIZENS)) {
+ const userData = await this.userRepository.findById(this.currentUser?.id);
+ const citizenId = invocationCtx.args[0];
+ const citizen = await this.citizenRepository.findOne({
+ where: {id: citizenId},
+ });
+ if (
+ userData?.funderId !== citizen?.affiliation.enterpriseId ||
+ citizen!.affiliation!.affiliationStatus !== AFFILIATION_STATUS.TO_AFFILIATE
+ ) {
+ throw new ValidationError(
+ 'citizen.affiliation.impossible',
+ '/citizenAffiliationImpossible',
+ StatusCode.PreconditionFailed,
+ ResourceName.Affiliation,
+ );
+ }
+ } else if (user.id && user.roles?.includes(Roles.CITIZENS)) {
+ if (user?.id !== invocationCtx.args[0]) {
+ throw new ValidationError(
+ 'citizen.affiliation.impossible',
+ '/citizenAffiliationImpossible',
+ StatusCode.PreconditionFailed,
+ ResourceName.Affiliation,
+ );
+ }
+ } else if (!invocationCtx.args[1].token) {
+ throw new ValidationError(
+ 'citizens.affiliation.not.found',
+ '/citizensAffiliationNotFound',
+ StatusCode.NotFound,
+ ResourceName.Affiliation,
+ );
+ }
+ }
+
+ if (invocationCtx.methodName === 'disaffiliation') {
+ const citizenId = invocationCtx.args[0];
+ let newAffiliatedEmployees: Citizen | undefined,
+ newManuelAffiliatedEmployees: Citizen | undefined;
+
+ const citizenToDisaffiliate = await this.citizenRepository.findOne({
+ where: {id: citizenId},
+ });
+ if (!citizenToDisaffiliate) {
+ throw new ValidationError(
+ `Citizen not found`,
+ '/citizenNotFound',
+ StatusCode.NotFound,
+ ResourceName.Citizen,
+ );
+ }
+ if (
+ citizenToDisaffiliate.affiliation.affiliationStatus ===
+ AFFILIATION_STATUS.AFFILIATED
+ ) {
+ const affiliatedEmployees = await this.citizenService.findEmployees({
+ status: AFFILIATION_STATUS.AFFILIATED,
+ lastName: undefined,
+ skip: 0,
+ });
+ newAffiliatedEmployees = affiliatedEmployees?.employees?.find(
+ (elt: Citizen) => elt.id === citizenId,
+ );
+ }
+
+ if (
+ citizenToDisaffiliate.affiliation.affiliationStatus ===
+ AFFILIATION_STATUS.TO_AFFILIATE
+ ) {
+ const manuelAffiliatedEmployees = await this.citizenService.findEmployees({
+ status: AFFILIATION_STATUS.TO_AFFILIATE,
+ lastName: undefined,
+ skip: 0,
+ limit: 0,
+ });
+
+ newManuelAffiliatedEmployees = manuelAffiliatedEmployees?.employees?.find(
+ (elt: Citizen) => elt.id === citizenId,
+ );
+ }
+
+ if (!newAffiliatedEmployees && !newManuelAffiliatedEmployees) {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ } else {
+ const checkDisaffiliation = await this.citizenService.checkDisaffiliation(
+ citizenId,
+ );
+
+ if (!checkDisaffiliation) {
+ throw new ValidationError(
+ 'citizen.disaffiliation.impossible',
+ '/citizenDisaffiliationImpossible',
+ StatusCode.PreconditionFailed,
+ ResourceName.Disaffiliation,
+ );
+ }
+ }
+ }
+
+ const result = await next();
+ return result;
+ }
+ catch(err: string) {
+ logger.error(err);
+ throw err;
+ }
+}
diff --git a/api/src/interceptors/enterprise.interceptor.ts b/api/src/interceptors/enterprise.interceptor.ts
new file mode 100644
index 0000000..82357d6
--- /dev/null
+++ b/api/src/interceptors/enterprise.interceptor.ts
@@ -0,0 +1,60 @@
+import {
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {StatusCode} from '../utils';
+import {ValidationError} from '../validationError';
+import {isEmailFormatValid} from './utils';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+@injectable({tags: {key: EnterpriseInterceptor.BINDING_KEY}})
+export class EnterpriseInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${EnterpriseInterceptor.name}`;
+
+ constructor() {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const {methodName, args} = invocationCtx;
+ const {emailFormat} = args[0];
+ if (methodName === 'create') {
+ const allEmailFormatsValid: boolean = emailFormat.every(
+ (emailFormatString: string) => isEmailFormatValid(emailFormatString),
+ );
+ if (!allEmailFormatsValid) {
+ throw new ValidationError(
+ 'Enterprise email formats are not valid',
+ '/enterpriseEmailBadFormat',
+ StatusCode.UnprocessableEntity,
+ );
+ }
+ }
+
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/external/affiliation.interceptor.ts b/api/src/interceptors/external/affiliation.interceptor.ts
new file mode 100644
index 0000000..72b82ee
--- /dev/null
+++ b/api/src/interceptors/external/affiliation.interceptor.ts
@@ -0,0 +1,152 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ service,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {
+ IncentiveRepository,
+ CitizenRepository,
+ SubscriptionRepository,
+} from '../../repositories';
+import {FunderService} from '../../services';
+import {
+ FUNDER_TYPE,
+ INCENTIVE_TYPE,
+ isEnterpriseAffilitation,
+ IUser,
+ ResourceName,
+ Roles,
+ StatusCode,
+} from '../../utils';
+import {ValidationError} from '../../validationError';
+
+@injectable({tags: {key: AffiliationInterceptor.BINDING_KEY}})
+export class AffiliationInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${AffiliationInterceptor.name}`;
+
+ constructor(
+ @repository(CitizenRepository)
+ public citizenRepository: CitizenRepository,
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @service(FunderService)
+ public funderService: FunderService,
+ @inject(SecurityBindings.USER)
+ private currentUserProfile: IUser,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const {methodName, args} = invocationCtx;
+ const {id, clientName, roles} = this.currentUserProfile;
+ if (!roles?.includes(Roles.MAAS_BACKEND)) {
+ let inputFunderId: string | undefined = undefined;
+ if (methodName === 'findCommunitiesByFunderId') {
+ inputFunderId = args[0];
+ }
+
+ if (
+ methodName === 'createSubscription' ||
+ methodName === 'createMetadata' ||
+ methodName === 'getMetadata'
+ ) {
+ const {incentiveId} = args[0];
+ inputFunderId =
+ incentiveId &&
+ (await this.incentiveRepository.findOne({where: {id: incentiveId}}))?.funderId;
+ }
+
+ if (methodName === 'addAttachments' || methodName === 'finalizeSubscription') {
+ const subscriptionId = args[0];
+ const subscription = await this.subscriptionRepository.findOne({
+ where: {id: subscriptionId},
+ });
+ if (!subscription) {
+ throw new ValidationError(
+ `Subscription not found`,
+ '/subscriptionNotFound',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+ );
+ }
+
+ const incentiveId = subscription.incentiveId;
+ inputFunderId =
+ incentiveId &&
+ (await this.incentiveRepository.findOne({where: {id: incentiveId}}))?.funderId;
+ }
+
+ // Find IncentiveMaasById
+ if (methodName === 'findIncentiveById') {
+ const incentiveId = args[0];
+
+ const incentive = await this.incentiveRepository.findOne({
+ where: {id: incentiveId},
+ });
+ if (!incentive) {
+ throw new ValidationError(
+ `Incentive not found`,
+ '/incentiveNotFound',
+ StatusCode.NotFound,
+ ResourceName.Incentive,
+ );
+ }
+ if (
+ incentive?.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE &&
+ !roles?.includes(Roles.CONTENT_EDITOR)
+ ) {
+ inputFunderId = incentiveId && incentive?.funderId;
+ } else {
+ return next();
+ }
+ }
+
+ const funders: any = inputFunderId && (await this.funderService.getFunders());
+ const funderMatch = funders && funders.find(({id}: any) => inputFunderId === id);
+ const citizen = await this.citizenRepository.findOne({where: {id}});
+
+ // Users from platform can subscribe to all collectivity aid
+ // Users from MaaS can subscribe to all collectivity aid
+ // Users from platform needs to be affiliated to a company to subscribe
+ if (
+ (roles?.includes(Roles.PLATFORM) || roles?.includes(Roles.MAAS)) &&
+ (funderMatch?.funderType === FUNDER_TYPE.collectivity ||
+ isEnterpriseAffilitation({citizen, funderMatch, inputFunderId}))
+ ) {
+ const result = await next();
+ return result;
+ }
+
+ throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden);
+ }
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/external/affiliationPublic.interceptor.ts b/api/src/interceptors/external/affiliationPublic.interceptor.ts
new file mode 100644
index 0000000..f54a7e9
--- /dev/null
+++ b/api/src/interceptors/external/affiliationPublic.interceptor.ts
@@ -0,0 +1,76 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {IncentiveRepository} from '../../repositories';
+import {INCENTIVE_TYPE, StatusCode, ResourceName, IUser} from '../../utils';
+import {ValidationError} from '../../validationError';
+
+@injectable({tags: {key: AffiliationPublicInterceptor.BINDING_KEY}})
+export class AffiliationPublicInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${AffiliationPublicInterceptor.name}`;
+
+ constructor(
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ @inject(SecurityBindings.USER)
+ private currentUserProfile: IUser,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const {methodName, args} = invocationCtx;
+ const {roles} = this.currentUserProfile;
+
+ if (roles && roles.includes('service_maas')) {
+ // Find IncentiveById
+ if (methodName === 'findIncentiveById') {
+ const incentiveId = args[0];
+ const incentive = await this.incentiveRepository.findOne({
+ where: {id: incentiveId},
+ });
+ if (!incentive) {
+ throw new ValidationError(
+ `Incentive not found`,
+ '/incentiveNotFound',
+ StatusCode.NotFound,
+ ResourceName.Incentive,
+ );
+ }
+ if (incentive?.incentiveType === INCENTIVE_TYPE.EMPLOYER_INCENTIVE) {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ }
+ }
+ }
+ return next();
+ }
+}
diff --git a/api/src/interceptors/external/index.ts b/api/src/interceptors/external/index.ts
new file mode 100644
index 0000000..a2aca30
--- /dev/null
+++ b/api/src/interceptors/external/index.ts
@@ -0,0 +1,5 @@
+export * from './affiliation.interceptor';
+export * from './subscriptionV1.interceptor';
+export * from './affiliationPublic.interceptor';
+export * from './subscriptionV1Attachments.interceptor';
+export * from './subscriptionV1Finalize.interceptor';
diff --git a/api/src/interceptors/external/subscriptionV1.interceptor.ts b/api/src/interceptors/external/subscriptionV1.interceptor.ts
new file mode 100644
index 0000000..68402c2
--- /dev/null
+++ b/api/src/interceptors/external/subscriptionV1.interceptor.ts
@@ -0,0 +1,116 @@
+import {
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {getJsonSchema} from '@loopback/rest';
+import {repository} from '@loopback/repository';
+import {Validator} from 'jsonschema';
+import {format} from 'date-fns';
+import {SecurityBindings} from '@loopback/security';
+
+import {IncentiveRepository, CommunityRepository} from '../../repositories';
+import {ResourceName, StatusCode} from '../../utils';
+import {ValidationError} from '../../validationError';
+import {Community, CreateSubscription} from '../../models';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+@injectable({tags: {key: SubscriptionV1Interceptor.BINDING_KEY}})
+export class SubscriptionV1Interceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${SubscriptionV1Interceptor.name}`;
+
+ constructor(
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ @repository(CommunityRepository)
+ public communityRepository: CommunityRepository,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ let subscriptionIncentiveData: CreateSubscription | undefined = invocationCtx.args[0];
+ subscriptionIncentiveData = subscriptionIncentiveData as CreateSubscription;
+ const {jsonSchema, funderId, isMCMStaff} = await this.incentiveRepository.findById(
+ subscriptionIncentiveData?.incentiveId,
+ );
+ if (!isMCMStaff)
+ throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden);
+
+ if (funderId) {
+ const communities: Community[] = await this.communityRepository.findByFunderId(
+ funderId,
+ );
+ const communityId = subscriptionIncentiveData?.communityId;
+
+ const conditionWithCommunity =
+ communityId && !communities.map(({id}) => id).some(elt => elt === communityId);
+ const conditionWOCommunity = !communityId && communities.length > 0;
+
+ if (conditionWithCommunity || conditionWOCommunity) {
+ throw new ValidationError(
+ `subscriptions.error.communities.mismatch`,
+ `/subscriptions`,
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+ );
+ }
+ }
+ const validator = new Validator();
+ const {incentiveId, consent, communityId, ...specificFieldsToCompare} =
+ subscriptionIncentiveData;
+
+ // If no incentive.json schema, compare with createSubscription model and no additional properties
+ const resultCompare = validator.validate(
+ jsonSchema ? specificFieldsToCompare : subscriptionIncentiveData,
+ jsonSchema ?? {...getJsonSchema(CreateSubscription), additionalProperties: false},
+ );
+ if (resultCompare.errors.length > 0) {
+ throw new ValidationError(
+ resultCompare.errors[0].message,
+ resultCompare.errors[0].path.toString(),
+ StatusCode.UnprocessableEntity,
+ ResourceName.Subscription,
+ );
+ }
+ // Extract specific fields if incentive.jsonSchema (meaning specificFields)
+ if (jsonSchema) {
+ const specificFields: Object = {};
+ Object.keys(resultCompare.schema.properties as Object).forEach(element => {
+ let value = subscriptionIncentiveData?.[element];
+ // Format date
+ if (resultCompare.schema.properties?.[element]?.format === 'date') {
+ value = format(new Date(value), 'dd/MM/yyyy');
+ }
+ Object.assign(specificFields, {[element]: value});
+ delete subscriptionIncentiveData?.[element];
+ });
+ subscriptionIncentiveData!.specificFields = specificFields;
+ }
+ invocationCtx.args[0] = {...subscriptionIncentiveData};
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/external/subscriptionV1Attachments.interceptor.ts b/api/src/interceptors/external/subscriptionV1Attachments.interceptor.ts
new file mode 100644
index 0000000..b91c824
--- /dev/null
+++ b/api/src/interceptors/external/subscriptionV1Attachments.interceptor.ts
@@ -0,0 +1,239 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ service,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {Express} from 'express';
+import {EncryptionKey} from '../../models';
+
+import {
+ SubscriptionRepository,
+ MetadataRepository,
+ EnterpriseRepository,
+ CollectivityRepository,
+} from '../../repositories';
+import {ClamavService, S3Service} from '../../services';
+import {
+ ResourceName,
+ StatusCode,
+ canAccessHisSubscriptionData,
+ SUBSCRIPTION_STATUS,
+ IUser,
+} from '../../utils';
+import {isExpired} from '../../utils/date';
+import {ValidationError} from '../../validationError';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+@injectable({tags: {key: SubscriptionV1AttachmentsInterceptor.BINDING_KEY}})
+export class SubscriptionV1AttachmentsInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${SubscriptionV1AttachmentsInterceptor.name}`;
+
+ constructor(
+ @inject('services.S3Service')
+ private s3Service: S3Service,
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @repository(MetadataRepository)
+ public metadataRepository: MetadataRepository,
+ @repository(EnterpriseRepository)
+ public enterpriseRepository: EnterpriseRepository,
+ @repository(CollectivityRepository)
+ public collectivityRepository: CollectivityRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ @service(ClamavService)
+ public clamavService: ClamavService,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const subscriptionDetails = await this.subscriptionRepository.findOne({
+ where: {id: invocationCtx.args[0]},
+ });
+
+ // Check if subscription exists
+ if (!subscriptionDetails) {
+ throw new ValidationError(
+ 'Subscription does not exist',
+ '/subscription',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+ );
+ }
+
+ // Check if user has access to his own data
+ if (
+ !canAccessHisSubscriptionData(this.currentUser.id, subscriptionDetails?.citizenId)
+ ) {
+ throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden);
+ }
+
+ // Check subscription status
+ if (subscriptionDetails?.status !== SUBSCRIPTION_STATUS.DRAFT) {
+ throw new ValidationError(
+ `Only subscriptions with Draft status are allowed`,
+ '/status',
+ StatusCode.PreconditionFailed,
+ ResourceName.Subscription,
+ );
+ }
+
+ // Check encryptionKey Exists
+ let encryptionKey: EncryptionKey | undefined = undefined;
+ const collectivity = await this.collectivityRepository.findOne({
+ where: {id: subscriptionDetails.funderId},
+ });
+ const enterprise = await this.enterpriseRepository.findOne({
+ where: {id: subscriptionDetails.funderId},
+ });
+ if (!collectivity && !enterprise) {
+ throw new ValidationError(
+ `Funder not found`,
+ '/Funder',
+ StatusCode.NotFound,
+ ResourceName.EncryptionKey,
+ );
+ }
+
+ if (collectivity) encryptionKey = collectivity.encryptionKey;
+ if (enterprise) encryptionKey = enterprise.encryptionKey;
+ if (!encryptionKey) {
+ throw new ValidationError(
+ `Encryption Key not found`,
+ '/EncryptionKey',
+ StatusCode.NotFound,
+ ResourceName.EncryptionKey,
+ );
+ }
+
+ // check if public key is expired
+ if (isExpired(encryptionKey.expirationDate, new Date())) {
+ throw new ValidationError(
+ `Encryption Key Expired`,
+ '/EncryptionKey',
+ StatusCode.UnprocessableEntity,
+ ResourceName.EncryptionKey,
+ );
+ }
+
+ // Check if there is already files in db
+ if (
+ subscriptionDetails!.attachments &&
+ subscriptionDetails!.attachments?.length > 0
+ ) {
+ throw new ValidationError(
+ `You already provided files to this subscription`,
+ '/attachments',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Attachments,
+ );
+ }
+
+ const attachments = invocationCtx.args[1].files;
+ const idMetadata = invocationCtx.args[1].body.data
+ ? JSON.parse(invocationCtx.args[1].body.data)?.metadataId
+ : undefined;
+
+ const subscriptionMetadata = idMetadata
+ ? await this.metadataRepository.findById(idMetadata)
+ : undefined;
+
+ // Check at least one of attachments and idMetadata
+ if (!subscriptionMetadata && (!attachments || !(attachments.length > 0))) {
+ throw new ValidationError(
+ `You need the provide at least one file or valid metadata`,
+ '/attachments',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Attachments,
+ );
+ }
+
+ // Check if incentiveId in metadata is the same than the one from the subscription
+ if (
+ subscriptionMetadata &&
+ subscriptionDetails!.incentiveId?.toString() !==
+ subscriptionMetadata?.incentiveId?.toString()
+ ) {
+ throw new ValidationError(
+ `Metadata does not match this subscription`,
+ '/attachments',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Attachments,
+ );
+ }
+
+ // Check number of file to upload
+ if (
+ !this.s3Service.hasCorrectNumberOfFiles([
+ ...attachments,
+ ...(subscriptionMetadata?.attachmentMetadata?.invoices ?? []),
+ ])
+ ) {
+ throw new ValidationError(
+ `Too many files to upload`,
+ '/attachments',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Attachments,
+ );
+ }
+ // Check attachments valid mime type
+ if (!this.s3Service.hasValidMimeType(attachments)) {
+ throw new ValidationError(
+ `Uploaded files do not have valid content type`,
+ '/attachments',
+ StatusCode.PreconditionFailed,
+ ResourceName.AttachmentsType,
+ );
+ }
+ // Check attachments valid mime size
+ if (!this.s3Service.hasValidFileSize(attachments)) {
+ throw new ValidationError(
+ `Uploaded files do not have a valid file size`,
+ '/attachments',
+ StatusCode.PreconditionFailed,
+ ResourceName.Attachments,
+ );
+ }
+
+ // Check if a file is corrupted using ClamAV
+ if (!(await this.clamavService.checkCorruptedFiles(attachments))) {
+ throw new ValidationError(
+ 'A corrupted file has been found',
+ '/antivirus',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Antivirus,
+ );
+ }
+
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/external/subscriptionV1Finalize.interceptor.ts b/api/src/interceptors/external/subscriptionV1Finalize.interceptor.ts
new file mode 100644
index 0000000..82a0908
--- /dev/null
+++ b/api/src/interceptors/external/subscriptionV1Finalize.interceptor.ts
@@ -0,0 +1,88 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {SubscriptionRepository} from '../../repositories';
+import {
+ ResourceName,
+ StatusCode,
+ canAccessHisSubscriptionData,
+ SUBSCRIPTION_STATUS,
+ IUser,
+} from '../../utils';
+import {ValidationError} from '../../validationError';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+@injectable({tags: {key: SubscriptionV1FinalizeInterceptor.BINDING_KEY}})
+export class SubscriptionV1FinalizeInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${SubscriptionV1FinalizeInterceptor.name}`;
+
+ constructor(
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const subscription = await this.subscriptionRepository.findById(
+ invocationCtx.args[0],
+ );
+
+ // Check if subscription exists
+ if (!subscription) {
+ throw new ValidationError(
+ 'Subscription does not exist',
+ '/subscription',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+ );
+ }
+
+ // Check if user has access to his own data
+ if (!canAccessHisSubscriptionData(this.currentUser.id, subscription?.citizenId)) {
+ throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden);
+ }
+
+ // Check subscription status
+ if (subscription?.status !== SUBSCRIPTION_STATUS.DRAFT) {
+ throw new ValidationError(
+ `Only subscriptions with Draft status are allowed`,
+ '/status',
+ StatusCode.PreconditionFailed,
+ ResourceName.Subscription,
+ );
+ }
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/funder.interceptor.ts b/api/src/interceptors/funder.interceptor.ts
new file mode 100644
index 0000000..fc4b161
--- /dev/null
+++ b/api/src/interceptors/funder.interceptor.ts
@@ -0,0 +1,99 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {CollectivityRepository, EnterpriseRepository} from '../repositories';
+import {ValidationError} from '../validationError';
+import {ResourceName, StatusCode, IUser} from '../utils';
+import {Collectivity, EncryptionKey, Enterprise} from '../models';
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+
+@injectable({tags: {key: FunderInterceptor.BINDING_KEY}})
+export class FunderInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${FunderInterceptor.name}`;
+
+ /*
+ * constructor
+ */
+ constructor(
+ @repository(CollectivityRepository)
+ public collectivityRepository: CollectivityRepository,
+ @repository(EnterpriseRepository)
+ public enterpriseRepository: EnterpriseRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const encryptionKey: EncryptionKey = invocationCtx.args[1];
+ const funderId = invocationCtx.args[0];
+ let funder: Collectivity | Enterprise | undefined = undefined;
+
+ const collectivity: Collectivity | null = await this.collectivityRepository.findOne({
+ where: {id: funderId},
+ });
+ const enterprise: Enterprise | null = await this.enterpriseRepository.findOne({
+ where: {id: funderId},
+ });
+
+ funder = collectivity ? collectivity : enterprise ? enterprise : undefined;
+ if (!funder) {
+ throw new ValidationError(
+ `Funder not found`,
+ `/Funder`,
+ StatusCode.NotFound,
+ ResourceName.Funder,
+ );
+ }
+
+ // Will not anymore if we stop using ${funderName}-backend convention when creating KC backend clients
+ if (
+ funder &&
+ this.currentUser?.clientName &&
+ !this.currentUser?.groups?.includes(funder.name)
+ ) {
+ throw new ValidationError('Access denied', '/authorization', StatusCode.Forbidden);
+ }
+ if (
+ (collectivity || (enterprise && !enterprise.isHris)) &&
+ !encryptionKey.privateKeyAccess
+ ) {
+ throw new ValidationError(
+ `encryptionKey.error.privateKeyAccess.missing`,
+ '/EncryptionKey',
+ StatusCode.UnprocessableEntity,
+ );
+ }
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/incentive.interceptor.ts b/api/src/interceptors/incentive.interceptor.ts
new file mode 100644
index 0000000..0abfa14
--- /dev/null
+++ b/api/src/interceptors/incentive.interceptor.ts
@@ -0,0 +1,158 @@
+import {
+ /* inject, */
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+
+import {Incentive} from '../models/incentive/incentive.model';
+import {IncentiveRepository} from '../repositories';
+import {ResourceName, StatusCode} from '../utils';
+import {ValidationError} from '../validationError';
+import {isValidityDateValid} from './utils';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+@injectable({tags: {key: IncentiveInterceptor.BINDING_KEY}})
+export class IncentiveInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${IncentiveInterceptor.name}`;
+
+ constructor(
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ let incentives: Incentive | undefined;
+ if (invocationCtx.methodName === 'create') {
+ incentives = invocationCtx.args[0];
+ if (incentives && incentives.title && incentives.funderName) {
+ const incentive = await this.incentiveRepository.findOne({
+ where: {title: incentives.title, funderName: incentives.funderName},
+ });
+ if (incentive) {
+ throw new ValidationError(
+ `incentives.error.title.alreadyUsedForFunder`,
+ '/incentiveTitleAlreadyUsed',
+ StatusCode.Conflict,
+ ResourceName.Incentive,
+ );
+ }
+ }
+ }
+
+ if (invocationCtx.methodName === 'replaceById') incentives = invocationCtx.args[1];
+
+ if (invocationCtx.methodName === 'updateById') {
+ const incentiveId = invocationCtx.args[0];
+ incentives = invocationCtx.args[1];
+
+ const incentiveToUpdate = await this.incentiveRepository.findOne({
+ where: {id: incentiveId},
+ });
+ if (!incentiveToUpdate) {
+ throw new ValidationError(
+ `Incentive not found`,
+ '/incentiveNotFound',
+ StatusCode.NotFound,
+ ResourceName.Incentive,
+ );
+ }
+ if (incentives && incentives.title && incentives.funderName) {
+ const incentive = await this.incentiveRepository.findOne({
+ where: {
+ id: {nin: [incentiveId]},
+ title: incentives.title,
+ funderName: incentives.funderName,
+ },
+ });
+ if (incentive) {
+ throw new ValidationError(
+ `incentives.error.title.alreadyUsedForFunder`,
+ '/incentiveTitleAlreadyUsed',
+ StatusCode.Conflict,
+ ResourceName.Incentive,
+ );
+ }
+ }
+ }
+
+ if (invocationCtx.methodName === 'deleteById') {
+ const incentiveId = invocationCtx.args[0];
+ const incentiveToDelete = await this.incentiveRepository.findOne({
+ where: {id: incentiveId},
+ });
+ if (!incentiveToDelete) {
+ throw new ValidationError(
+ `Incentive not found`,
+ '/incentiveNotFound',
+ StatusCode.NotFound,
+ ResourceName.Incentive,
+ );
+ }
+ }
+
+ if (
+ incentives &&
+ incentives.validityDate &&
+ !isValidityDateValid(incentives.validityDate)
+ ) {
+ throw new ValidationError(
+ `incentives.error.validityDate.minDate`,
+ '/validityDate',
+ StatusCode.PreconditionFailed,
+ ResourceName.Incentive,
+ );
+ }
+
+ // If isMCMStaff === true , should not have a subscription link
+ if (incentives && incentives.isMCMStaff && incentives.subscriptionLink) {
+ throw new ValidationError(
+ `incentives.error.isMCMStaff.subscriptionLink`,
+ '/isMCMStaff',
+ StatusCode.PreconditionFailed,
+ ResourceName.Incentive,
+ );
+ }
+ // If isMCMStaff === false , should not have a specific fields but subscription is required
+ if (
+ incentives &&
+ !incentives.isMCMStaff &&
+ (incentives.specificFields?.length || !incentives.subscriptionLink)
+ ) {
+ throw new ValidationError(
+ `incentives.error.isMCMStaff.specificFieldOrSubscriptionLink`,
+ '/isMCMStaff',
+ StatusCode.PreconditionFailed,
+ ResourceName.Incentive,
+ );
+ }
+
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/index.ts b/api/src/interceptors/index.ts
new file mode 100644
index 0000000..5964951
--- /dev/null
+++ b/api/src/interceptors/index.ts
@@ -0,0 +1,7 @@
+export * from './incentive.interceptor';
+export * from './citizen.interceptor';
+export * from './subscription.interceptor';
+export * from './subscriptionMetadata.interceptor';
+export * from './enterprise.interceptor';
+export * from './funder.interceptor';
+export * from './external';
diff --git a/api/src/interceptors/subscription.interceptor.ts b/api/src/interceptors/subscription.interceptor.ts
new file mode 100644
index 0000000..03d17c1
--- /dev/null
+++ b/api/src/interceptors/subscription.interceptor.ts
@@ -0,0 +1,130 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {UserRepository, SubscriptionRepository} from '../repositories';
+import {IUser, ResourceName, StatusCode, SUBSCRIPTION_STATUS} from '../utils';
+import {ValidationError} from '../validationError';
+import {Subscription} from '../models';
+import {IDP_SUFFIX_BACKEND} from '../constants';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+@injectable({tags: {key: SubscriptionInterceptor.BINDING_KEY}})
+export class SubscriptionInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${SubscriptionInterceptor.name}`;
+
+ constructor(
+ @repository(UserRepository)
+ private userRepository: UserRepository,
+ @repository(SubscriptionRepository)
+ public subscriptionRepository: SubscriptionRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const {methodName, args} = invocationCtx;
+ const {id} = this.currentUser;
+
+ let communityIds: '' | string[] | undefined = [];
+
+ const subscriptionId = args[0];
+ const subscription: Subscription = await this.subscriptionRepository.findById(
+ subscriptionId,
+ );
+
+ if (!subscription) {
+ throw new ValidationError(
+ `Subscription not found`,
+ '/subscriptionNotFound',
+ StatusCode.NotFound,
+ ResourceName.Subscription,
+ );
+ }
+
+ if (methodName === 'findById') {
+ if (subscription?.status === SUBSCRIPTION_STATUS.DRAFT) {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ }
+ }
+
+ if (methodName === 'getSubscriptionFileByName') {
+ if (this.currentUser?.clientName) {
+ const clientName = this.currentUser?.clientName;
+ if (`${subscription.funderName}-${IDP_SUFFIX_BACKEND}` !== clientName) {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ }
+ }
+ if (subscription.status !== SUBSCRIPTION_STATUS.TO_PROCESS) {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ }
+ }
+
+ if (
+ ['findById', 'validate', 'reject', 'getSubscriptionFileByName'].includes(methodName)
+ ) {
+ // get current user communities
+ communityIds =
+ id && (await this.userRepository.findOne({where: {id}}))?.communityIds;
+
+ if (communityIds) {
+ if (
+ subscription &&
+ subscription.communityId &&
+ communityIds?.includes(subscription.communityId.toString())
+ ) {
+ return next();
+ } else {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ }
+ } else {
+ return next();
+ }
+ }
+ }
+}
diff --git a/api/src/interceptors/subscriptionMetadata.interceptor.ts b/api/src/interceptors/subscriptionMetadata.interceptor.ts
new file mode 100644
index 0000000..40502f7
--- /dev/null
+++ b/api/src/interceptors/subscriptionMetadata.interceptor.ts
@@ -0,0 +1,123 @@
+import {
+ inject,
+ injectable,
+ Interceptor,
+ InvocationContext,
+ InvocationResult,
+ Provider,
+ ValueOrPromise,
+} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {SecurityBindings} from '@loopback/security';
+
+import {IncentiveRepository, MetadataRepository} from '../repositories';
+import {canAccessHisSubscriptionData, IUser, ResourceName, StatusCode} from '../utils';
+import {ValidationError} from '../validationError';
+import {Invoice, Metadata} from '../models';
+
+/**
+ * This class will be bound to the application as an `Interceptor` during
+ * `boot`
+ */
+@injectable({tags: {key: SubscriptionMetadataInterceptor.BINDING_KEY}})
+export class SubscriptionMetadataInterceptor implements Provider {
+ static readonly BINDING_KEY = `interceptors.${SubscriptionMetadataInterceptor.name}`;
+
+ constructor(
+ @repository(IncentiveRepository)
+ public incentiveRepository: IncentiveRepository,
+ @repository(MetadataRepository)
+ public metadataRepository: MetadataRepository,
+ @inject(SecurityBindings.USER)
+ private currentUser: IUser,
+ ) {}
+
+ /**
+ * This method is used by LoopBack context to produce an interceptor function
+ * for the binding.
+ *
+ * @returns An interceptor function
+ */
+ value() {
+ return this.intercept.bind(this);
+ }
+
+ /**
+ * The logic to intercept an invocation
+ * @param invocationCtx - Invocation context
+ * @param next - A function to invoke next interceptor or the target method
+ */
+ async intercept(
+ invocationCtx: InvocationContext,
+ next: () => ValueOrPromise,
+ ) {
+ const {methodName, args} = invocationCtx;
+ const {incentiveId, attachmentMetadata} = new Metadata(args[0]);
+ const {id} = this.currentUser;
+ if (methodName === 'createMetadata') {
+ // Get incentive to see if it exists
+ // Check isMCMStaff
+ const {isMCMStaff} = await this.incentiveRepository.findById(incentiveId);
+ if (!isMCMStaff) {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ }
+
+ // Check empty invoices or empty products
+ if (
+ attachmentMetadata.invoices.length === 0 ||
+ attachmentMetadata.invoices.find(
+ (invoice: Invoice) => invoice.products.length === 0,
+ )
+ ) {
+ throw new ValidationError(
+ 'Metadata invoices or products length invalid',
+ '/metadata',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Metadata,
+ );
+ }
+
+ // Check totalElements && nb invoices
+ if (attachmentMetadata.invoices.length !== attachmentMetadata.totalElements) {
+ throw new ValidationError(
+ 'Metadata invoices length must be equal to totalElements',
+ '/metadata',
+ StatusCode.UnprocessableEntity,
+ ResourceName.Metadata,
+ );
+ }
+
+ // Attach citizenId and token to metadata
+ args[0].citizenId = id;
+ }
+
+ if (methodName === 'getMetadata') {
+ const metadataId = args[0];
+ const metadata = await this.metadataRepository.findOne({where: {id: metadataId}});
+ if (!metadata) {
+ throw new ValidationError(
+ `Metadata not found`,
+ '/metadataNotFound',
+ StatusCode.NotFound,
+ ResourceName.Metadata,
+ );
+ }
+
+ // Check if user has access to his own data
+ if (!canAccessHisSubscriptionData(this.currentUser.id, metadata.citizenId)) {
+ throw new ValidationError(
+ 'Access denied',
+ '/authorization',
+ StatusCode.Forbidden,
+ );
+ }
+ }
+
+ const result = await next();
+ return result;
+ }
+}
diff --git a/api/src/interceptors/utils.ts b/api/src/interceptors/utils.ts
new file mode 100644
index 0000000..5fd10b6
--- /dev/null
+++ b/api/src/interceptors/utils.ts
@@ -0,0 +1,44 @@
+const isValidityDateValid = (validityDate: string) => {
+ return new Date(validityDate) >= new Date();
+};
+
+const formatDate = (date: Date) => {
+ return [date.getFullYear(), date.getMonth() + 1, date.getDate()].join('-');
+};
+
+const isAgeValid = (birthdate: string) => {
+ const currentDate = new Date();
+
+ const lowelLimitDate = [
+ currentDate.getFullYear() - 16,
+ currentDate.getMonth() + 1,
+ currentDate.getDate(),
+ ].join('-');
+
+ return formatDate(new Date(birthdate)) <= lowelLimitDate;
+};
+
+/**
+ * Format Date as Day/Month/Year
+ */
+const formatDateInFrenchNotation = (date: Date) => {
+ const dd = String(date.getDate()).padStart(2, '0');
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
+ const yyyy = date.getFullYear();
+ return `${dd}/${mm}/${yyyy}`;
+};
+
+const isEmailFormatValid = (email: string) => {
+ const regex = new RegExp(
+ /^@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
+ );
+ return email.match(regex);
+};
+
+export {
+ isValidityDateValid,
+ formatDate,
+ isAgeValid,
+ formatDateInFrenchNotation,
+ isEmailFormatValid,
+};
diff --git a/api/src/migrate.ts b/api/src/migrate.ts
new file mode 100644
index 0000000..5f97929
--- /dev/null
+++ b/api/src/migrate.ts
@@ -0,0 +1,21 @@
+import {App} from './application';
+import {logger} from './utils';
+
+export async function migrate(args: string[]) {
+ const existingSchema = args.includes('--rebuild') ? 'drop' : 'alter';
+ logger.info(`Migrating schemas (%s existing schema): ${existingSchema}`);
+
+ const app = new App();
+ await app.boot();
+ await app.migrateSchema({existingSchema});
+
+ // Connectors usually keep a pool of opened connections,
+ // this keeps the process running even after all work is done.
+ // We need to exit explicitly.
+ process.exit(0);
+}
+
+migrate(process.argv).catch(err => {
+ logger.error(`Cannot migrate database schema: ${err}`);
+ process.exit(1);
+});
diff --git a/api/src/migrations/1.10.0.migration.ts b/api/src/migrations/1.10.0.migration.ts
new file mode 100644
index 0000000..9a40396
--- /dev/null
+++ b/api/src/migrations/1.10.0.migration.ts
@@ -0,0 +1,127 @@
+import {service} from '@loopback/core';
+import {repository} from '@loopback/repository';
+import {MigrationScript, migrationScript} from 'loopback4-migration';
+import {removeWhiteSpace} from '../controllers/utils/helpers';
+import {Incentive, Territory} from '../models';
+import {IncentiveRepository, TerritoryRepository} from '../repositories';
+import {TerritoryService} from '../services/territory.service';
+import {logger} from '../utils';
+
+@migrationScript()
+export class MigrationScript110 implements MigrationScript {
+ version = '1.10.0';
+ scriptName = MigrationScript110.name;
+ description = 'Add Territory Model';
+
+ constructor(
+ @repository(IncentiveRepository) private incentiveRepository: IncentiveRepository,
+ @repository(TerritoryRepository) private territoryRepository: TerritoryRepository,
+ @service(TerritoryService) public territoryService: TerritoryService,
+ ) {}
+
+ async up(): Promise {
+ logger.info(`${MigrationScript110.name} - Started`);
+
+ const territoriesToCreate: string[] = [];
+ let count: number = 0;
+
+ const incentivesList: Incentive[] = await this.incentiveRepository.find({
+ where: {territory: {exists: false}},
+ fields: {id: true, territoryName: true},
+ });
+
+ const createdTerritories: Territory[] = await this.territoryRepository.find();
+ logger.info(
+ `${MigrationScript110.name} - {${createdTerritories.length}} Territories from database`,
+ );
+ logger.info(
+ `${MigrationScript110.name} - Initial Created Territories from database : \
+ ${
+ createdTerritories.length ? JSON.stringify(createdTerritories, null, 2) : 'None'
+ }`,
+ );
+
+ incentivesList.map((incentive: Incentive) => {
+ const name: string = removeWhiteSpace(incentive.territoryName);
+
+ const foundInIncentive: string | undefined = territoriesToCreate.find(
+ (territoryName: string) =>
+ territoryName.toLowerCase() === incentive.territoryName.toLowerCase(),
+ );
+
+ const foundInTerritory: Territory | undefined = createdTerritories.find(
+ (territory: Territory) => territory.name.toLowerCase() === name.toLowerCase(),
+ );
+
+ if (!foundInTerritory && !foundInIncentive) {
+ territoriesToCreate.push(incentive.territoryName);
+ }
+ });
+
+ logger.info(
+ `${MigrationScript110.name} - {${territoriesToCreate.length}} Territories will be created`,
+ );
+ logger.info(
+ `${MigrationScript110.name} - Territories that will be Created : \
+ ${
+ territoriesToCreate.length
+ ? JSON.stringify(territoriesToCreate, null, 2)
+ : 'None'
+ }`,
+ );
+
+ await Promise.all(
+ territoriesToCreate.map(async (territoy: string) => {
+ const createdTerritory: Territory = await this.territoryService.createTerritory({
+ name: territoy,
+ } as Territory);
+ createdTerritories.push(createdTerritory);
+
+ logger.info(
+ `${MigrationScript110.name} - Territory ${createdTerritory.name} with ID \
+ ${createdTerritory.id} is Created`,
+ );
+ }),
+ );
+
+ logger.info(
+ `${MigrationScript110.name} - {${incentivesList.length}} Incentives to Update`,
+ );
+ await Promise.all(
+ incentivesList.map(async (incentive: Incentive) => {
+ const name: string = removeWhiteSpace(incentive.territoryName);
+
+ const foundTerritory: Territory | undefined = createdTerritories.find(
+ (territory: Territory) => territory.name.toLowerCase() === name.toLowerCase(),
+ );
+
+ if (foundTerritory) {
+ await this.incentiveRepository.updateById(incentive.id, {
+ territory: {
+ id: foundTerritory?.id,
+ name: foundTerritory?.name,
+ },
+ territoryName: foundTerritory?.name,
+ });
+
+ logger.info(
+ `${MigrationScript110.name} - Incentive with ID \ ${
+ incentive.id
+ } is updated with territory : ${JSON.stringify(foundTerritory, null, 2)}`,
+ );
+ count++;
+ } else {
+ logger.error(
+ `${MigrationScript110.name} - Incentive with ID ${incentive.id} is not updating`,
+ );
+ }
+ }),
+ );
+
+ logger.info(`${MigrationScript110.name} - {${count}} Incentives are updated`);
+ if (count !== incentivesList.length) {
+ logger.error(`${MigrationScript110.name} - Error in updating incentives`);
+ }
+ logger.info(`${MigrationScript110.name} - Completed`);
+ }
+}
diff --git a/api/src/migrations/1.11.0.migration.ts b/api/src/migrations/1.11.0.migration.ts
new file mode 100644
index 0000000..6b7490c
--- /dev/null
+++ b/api/src/migrations/1.11.0.migration.ts
@@ -0,0 +1,104 @@
+import {AnyObject, repository} from '@loopback/repository';
+import {inject} from '@loopback/core';
+import {MigrationScript, migrationScript} from 'loopback4-migration';
+
+import {KeycloakService} from '../services';
+import {CitizenMigrationRepository, CitizenRepository} from '../repositories';
+import {logger, GENDER, IUser} from '../utils';
+
+@migrationScript()
+export class MigrationScript111 implements MigrationScript {
+ version = '1.11.0';
+ scriptName = MigrationScript111.name;
+ description = 'add identity object to citizen';
+
+ constructor(
+ @repository(CitizenRepository)
+ private citizenRepository: CitizenRepository,
+ @repository(CitizenMigrationRepository)
+ private citizenMigrationRepository: CitizenMigrationRepository,
+ @inject('services.KeycloakService')
+ public kcService: KeycloakService,
+ ) {}
+
+ async up(): Promise {
+ logger.info(`${MigrationScript111.name} - Started`);
+
+ // Update all citizens to add identity object
+ const citizens: Array = await this.citizenMigrationRepository.find({
+ where: {identity: {exists: false}},
+ });
+
+ const updateCitizens: Promise[] = citizens.map(async citizen => {
+ logger.info(
+ `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \
+ ${citizen.id} will be updated with identity object`,
+ );
+
+ logger.info(
+ `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \
+ ${citizen.id} Get User Information for certification source`,
+ );
+
+ const user: IUser = await this.kcService.getUser(citizen.id);
+
+ const certificationSource: string =
+ user?.attributes?.provider?.[0] === 'FC'
+ ? 'franceconnect.gouv.fr'
+ : 'moncomptemobilite.fr';
+
+ const newCitizen: AnyObject = {
+ ...citizen,
+ identity: {
+ gender: {
+ value: citizen.gender === GENDER.FEMALE ? 2 : 1,
+ source: certificationSource,
+ certificationDate: new Date(),
+ },
+ lastName: {
+ value: citizen.lastName,
+ source: certificationSource,
+ certificationDate: new Date(),
+ },
+ firstName: {
+ value: citizen.firstName,
+ source: certificationSource,
+ certificationDate: new Date(),
+ },
+ birthDate: {
+ value: citizen.birthdate,
+ source: certificationSource,
+ certificationDate: new Date(),
+ },
+ },
+ };
+
+ delete newCitizen.gender;
+ delete newCitizen.lastName;
+ delete newCitizen.firstName;
+ delete newCitizen.birthdate;
+ delete newCitizen?.certifiedData;
+
+ // Update the citizen attributes on KC
+ logger.info(
+ `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \
+ ${citizen.id} will be updated on KC with identity attributes`,
+ );
+
+ await this.kcService.updateCitizenAttributes(citizen.id, {
+ ...newCitizen.identity,
+ });
+
+ // Update the citizen on mongo
+ logger.info(
+ `${MigrationScript111.name} - Citizen ${citizen.lastName} with ID \
+ ${citizen.id} will be updated on Mongo with identity object`,
+ );
+
+ return this.citizenRepository.replaceById(citizen.id, newCitizen);
+ });
+ await Promise.all(updateCitizens);
+
+ logger.info(`${MigrationScript111.name} - Completed`);
+ }
+}
diff --git a/api/src/migrations/1.2.0.migration.ts b/api/src/migrations/1.2.0.migration.ts
new file mode 100644
index 0000000..cef0260
--- /dev/null
+++ b/api/src/migrations/1.2.0.migration.ts
@@ -0,0 +1,46 @@
+import {Count, repository} from '@loopback/repository';
+import {MigrationScript, migrationScript} from 'loopback4-migration';
+import {Enterprise} from '../models';
+import {EnterpriseRepository, SubscriptionRepository} from '../repositories';
+import {logger} from '../utils';
+
+@migrationScript()
+export class MigrationScript120 implements MigrationScript {
+ version = '1.2.0';
+ scriptName = MigrationScript120.name;
+ description = 'add manual affiliation to enterprises and delete all subscriptions';
+
+ constructor(
+ @repository(EnterpriseRepository)
+ private enterpriseRepository: EnterpriseRepository,
+ @repository(SubscriptionRepository)
+ private subscriptionRepository: SubscriptionRepository,
+ ) {}
+
+ async up(): Promise