diff --git a/afapast/.gitignore b/afapast/.gitignore new file mode 100644 index 0000000..33ca215 --- /dev/null +++ b/afapast/.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/afapast/Dockerfile b/afapast/Dockerfile new file mode 100644 index 0000000..b821665 --- /dev/null +++ b/afapast/Dockerfile @@ -0,0 +1,34 @@ +# Check out https://hub.docker.com/_/node to select a new base image +FROM docker.io/library/node:20 + +# Install Python, make, g++, and other build tools required by node-gyp +RUN apt-get update && \ + apt-get install -y python3 make g++ && \ + ln -s /usr/bin/python3 /usr/bin/python + +# 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 + +# A wildcard is used to ensure both package.json AND package-lock.json are copied +# where available (npm@5+) +COPY --chown=node package*.json ./ + +RUN npm install + +# Bundle app source code +COPY --chown=node . . + +RUN npm run build + +# Bind to all network interfaces so that it can be mapped to the host OS +ENV HOST=0.0.0.0 PORT=3002 + +EXPOSE ${PORT} +CMD [ "node", "." ] diff --git a/afapast/README.md b/afapast/README.md new file mode 100644 index 0000000..04de3ec --- /dev/null +++ b/afapast/README.md @@ -0,0 +1,48 @@ +# Afapast + +A voucher distribution tool, reading mob subscription API and sending emails with vouchers. + +Also an API in itself to manage tracked incentives and vouchers. + +## Dependencies + +Tested on node v20.18.0. Install deps with +```sh +npm install +``` + + +## Run the application + +Make sure to have the DB env variables set. + +Migrate the db if it's the first time. +```sh +yarn migrate +``` + +```sh +yarn start +``` + +Open http://127.0.0.1:3002 in your browser. + +## Env variables + +| Variables | Description | Mandatory | Default value if unspecified | +| ------------------------ | ------------------------------------------------------------ | ----------- | ---------------------------- | +| HOST | Service host | No | '127.0.0.1' | +| PORT | Service port | No | 3002 | +| API_KEY | Api Key authorizing queries to this project API | No, but recommended | 'apikey' | +| EMAIL_FROM | email used as a sender, only used with NODE_ENV='production' | Only in production | | +| EMAIL_HOST | smtp host, only used with NODE_ENV='production' | Only in production | | +| EMAIL_PORT | smtp port, only used with NODE_ENV='production' | Only in production | | +| MOB_TOKEN_URL | oidc token url for mob | No | 'http://localhost:9000/auth/realms/mcm/protocol/openid-connect/token' | +| MOB_CLIENT_ID | oidc client id to fetch a token | No | 'simulation-maas-backend' | +| MOB_CLIENT_SECRET | oidc client secret to fetch a token | No | '4x1zfk4p4d7ZdLPAsaWBhd5mu86n5ZWN' | +| MOB_API_SUBSCRIPTION_URL | subscription url | No | 'http://localhost:3000/v1/subscriptions' | +| DB_HOST | database host | No | 'localhost' | +| DB_PORT | database port | No | 5432 | +| DB_SERVICE_USER | database user | No | 'afapast' | +| DB_SERVICE_PASSWORD | database password | No | 'afapast' | +| DB_DATABASE | database name | No | 'afapast_db' | \ No newline at end of file diff --git a/afapast/data/db.sqlite b/afapast/data/db.sqlite new file mode 100644 index 0000000..3f63ff0 Binary files /dev/null and b/afapast/data/db.sqlite differ diff --git a/afapast/helper_scripts/README.md b/afapast/helper_scripts/README.md new file mode 100644 index 0000000..934e88d --- /dev/null +++ b/afapast/helper_scripts/README.md @@ -0,0 +1,3 @@ +Example scripts used to talk to the API. + +This folder is unused during deployement. diff --git a/afapast/helper_scripts/add_vouchers_from_csv.py b/afapast/helper_scripts/add_vouchers_from_csv.py new file mode 100644 index 0000000..728667a --- /dev/null +++ b/afapast/helper_scripts/add_vouchers_from_csv.py @@ -0,0 +1,22 @@ +import csv +import requests + +CSV_FILE = "voucher_csv_exemple.csv" +API = "http://localhost:3002/vouchers" +API_KEY = "apikey" + +headers = { + "X-API-Key": API_KEY +} +with open(CSV_FILE, 'r') as csvfile: + reader = csv.DictReader(csvfile, delimiter=',') + for line in reader: + json_params = { + "value": line['Code'], + "amount": line['Value'] + } + res = requests.post(API, json=json_params, headers=headers) + if res.status_code < 300: + print(res.json()) + else: + print("Error, " + res.text) \ No newline at end of file diff --git a/afapast/helper_scripts/afapast_api_tips.py b/afapast/helper_scripts/afapast_api_tips.py new file mode 100644 index 0000000..1d6af68 --- /dev/null +++ b/afapast/helper_scripts/afapast_api_tips.py @@ -0,0 +1,55 @@ +# coding: utf-8 + +# This file contains the main examples of API usage as a reminder +# An API explorer is also available at API_HOST/explorer for a complete documentation + + +import requests + +API_HOST = "http://localhost:3002" +API_KEY = "" + +headers = { + "X-API-Key": API_KEY +} + +# Add a new incentive to track +url = API_HOST + "/tracked-incentives" +json_params = { + incentiveId: "67378718fce6b98d279a0f27", # Incentive id, required + ccContacts: "hello@test.com,hello2@test.com", # Comma separated list of emails, optional +} +res = requests.post(url, json=json_params, headers=headers) +print(res.json()) + + +# Check status of a tracked incentive +url = API_HOST + "/tracked-incentives/67378718fce6b98d279a0f27" +res = requests.get(url, headers=headers) +print(res.json()) + +# Expected response: +# { +# "id": 1, +# "incentiveId": "67378718fce6b98d279a0f27", +# "lastReadTime": "2021-03-09T15:00:00.000Z", # Last time the subscriptions were checked +# "lastNbSubs": 0, # Total number of VALIDEE subscriptions during the last check +# "nbSubsHandled": 0, # Total number of subscriptions handled +# "ccContacts": "hello@test.com,hello2@test.com" +# } + +# Check vouchers usage +url = API_HOST + "/vouchers" +res = requests.get(url, headers=headers) +print(res.json()) + +# Expected response: +# [{ +# "id": 1, +# "value": "4A2NN3ES", +# "amount": "30.00", +# "status": "USED", +# "subscriptionId": "66e32c5974df3754b04faba0", +# "citizenId": "802ac8b1-cde8-42f2-b310-a1c0380018d8", +# "incentiveId": "67378718fce6b98d279a0f27" # Incentive id +# }] \ No newline at end of file diff --git a/afapast/helper_scripts/create_formated_incentive_in_mob.py b/afapast/helper_scripts/create_formated_incentive_in_mob.py new file mode 100644 index 0000000..a21c379 --- /dev/null +++ b/afapast/helper_scripts/create_formated_incentive_in_mob.py @@ -0,0 +1,69 @@ +# coding: utf-8 +import requests +import json + +url = "http://localhost:3000/v1/incentives" +# There is no real way of getting this token programmatically +# We suggest logging in to the admin interface, and looking in dev tools for the token query to idp auth/realms/mcm/protocol/openid-connect/token +# The response should contain an access_token usable here +token = "" + +# These are found in the admin interface +funderId = "318d18d5-0d02-457e-918e-8b272251d1b2" +territoryIds = ["66e328c074df3754b04fab9e"] + +title = "DOMICILE- TRAVAIL - prise en charge à 92,5%" +minAmount = "92.5%" +allocatedAmount = "92.5%" + +# title = "Forfait liberté - prise en charge à 75%" +# minAmount = "75%" +# allocatedAmount = "75%" + +payload = json.dumps({ + "title": title, + "description": "Aide Tiers Payant pour l'entreprise Communauté d’Agglomération de La Rochelle\n\nDemande de prise en charge directe de la part employeur, sans avance de frais.\nRèglement du reste à charge par l'employé directement sur la boutique en ligne : boutiqueenligne.fr ou sur l'application 'BOUTIQUE'", + "incentiveType": "AideEmployeur", + "funderId": funderId, + "minAmount": minAmount, + "transportList": [ + "transportsCommun", + "velo" + ], + "territoryIds": territoryIds, + "allocatedAmount": allocatedAmount, + "conditions": "- être employé de Communauté d’Agglomération de La Rochelle", + "paymentMethod": "- Validation par le gestionnaire \n- Application du droit en une fois directement sur la boutique en ligne\nou\n- Règlement en une fois sous la forme d'un coupon valable sur la boutique en ligne", + "contact": "", + "additionalInfos": "Pour plus d'information, consultez l [aide EN LIGNE](https://moncomptemobilite.fr/) ![image TEST](https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSqQXCfw2Ulfrfe1xG2NGkSe7FOnT0h9AEjcQ&s)", + "isMCMStaff": True, + "subscriptionCheckMode": "MANUEL", + "isCitizenNotificationsDisabled": False, +# "subscriptionLink": "", + "specificFields": [ + { + "isRequired": True, + "title": "Type d'abonnement", + "inputFormat": "listeChoix", + "choiceList": { + "possibleChoicesNumber": 1, + "inputChoiceList": [ + { + "inputChoice": "Abonnement Yélo seul" + }, + { + "inputChoice": "Abonnement Yélo + Vélo Libre Service (VLS) à +5 €" + } + ] + } + }, + ], +}) +headers = { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' +} + +response = requests.request("POST", url, headers=headers, data=payload) + +print(response.json()) diff --git a/afapast/helper_scripts/voucher_csv_exemple.csv b/afapast/helper_scripts/voucher_csv_exemple.csv new file mode 100644 index 0000000..45bfa65 --- /dev/null +++ b/afapast/helper_scripts/voucher_csv_exemple.csv @@ -0,0 +1,2 @@ +Code,Voucher Type,Value,Discount Type +4A2NN3ES,DISCOUNT_VOUCHER,30.00,AMOUNT diff --git a/afapast/package.json b/afapast/package.json new file mode 100644 index 0000000..eed0d6c --- /dev/null +++ b/afapast/package.json @@ -0,0 +1,83 @@ +{ + "name": "afapast", + "version": "0.0.1", + "description": "afapast", + "keywords": [ + "loopback-application", + "loopback" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": "18 || 20 || 22" + }, + "scripts": { + "build": "lb-tsc", + "postbuild": "npm run copy-png-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": "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 afapast .", + "docker:run": "docker run -p 3000:3000 -d afapast", + "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", + "rebuild": "npm run clean && npm run build", + "copy-png-files": "copyfiles -u 1 src/**/**.png dist/" + }, + "repository": { + "type": "git", + "url": "" + }, + "author": "TTalex", + "license": "", + "files": [ + "README.md", + "dist", + "src", + "!*/__tests__" + ], + "dependencies": { + "@loopback/authentication": "^11.0.6", + "@loopback/authentication-jwt": "^0.15.6", + "@loopback/boot": "^7.0.6", + "@loopback/core": "^6.1.3", + "@loopback/cron": "^0.12.6", + "@loopback/repository": "^7.0.6", + "@loopback/rest": "^14.0.6", + "@loopback/rest-explorer": "^7.0.6", + "@loopback/service-proxy": "^7.0.6", + "copyfiles": "^2.4.1", + "ejs": "^3.1.10", + "loopback-connector-postgresql": "^7.1.8", + "nodemailer": "^6.9.15", + "pdfkit": "^0.15.0", + "sqlite3": "^5.1.7", + "tslib": "^2.0.0" + }, + "devDependencies": { + "@loopback/build": "^11.0.6", + "@loopback/eslint-config": "^15.0.4", + "@loopback/testlab": "^7.0.6", + "@types/ejs": "^3.1.5", + "@types/node": "^16.18.101", + "@types/nodemailer": "^6.4.16", + "@types/pdfkit": "^0.13.5", + "eslint": "^8.57.0", + "source-map-support": "^0.5.21", + "typescript": "~5.2.2" + } +} diff --git a/afapast/public/index.html b/afapast/public/index.html new file mode 100644 index 0000000..2e1a1e0 --- /dev/null +++ b/afapast/public/index.html @@ -0,0 +1,88 @@ + + + + + afapast + + + + + + + + + + +
+

afapast

+

Version 1.0.0

+ +

OpenAPI spec: /openapi.json

+

API Explorer: /explorer

+
+ + + + + diff --git a/afapast/src/__tests__/README.md b/afapast/src/__tests__/README.md new file mode 100644 index 0000000..a88f8a5 --- /dev/null +++ b/afapast/src/__tests__/README.md @@ -0,0 +1,3 @@ +# Tests + +Please place your tests in this folder. diff --git a/afapast/src/__tests__/acceptance/home-page.acceptance.ts b/afapast/src/__tests__/acceptance/home-page.acceptance.ts new file mode 100644 index 0000000..6758585 --- /dev/null +++ b/afapast/src/__tests__/acceptance/home-page.acceptance.ts @@ -0,0 +1,31 @@ +import {Client} from '@loopback/testlab'; +import {AfapastApplication} from '../..'; +import {setupApplication} from './test-helper'; + +describe('HomePage', () => { + let app: AfapastApplication; + let client: Client; + + before('setupApplication', async () => { + ({app, client} = await setupApplication()); + }); + + after(async () => { + await app.stop(); + }); + + it('exposes a default home page', async () => { + await client + .get('/') + .expect(200) + .expect('Content-Type', /text\/html/); + }); + + it('exposes self-hosted explorer', async () => { + await client + .get('/explorer/') + .expect(200) + .expect('Content-Type', /text\/html/) + .expect(/LoopBack API Explorer/); + }); +}); diff --git a/afapast/src/__tests__/acceptance/test-helper.ts b/afapast/src/__tests__/acceptance/test-helper.ts new file mode 100644 index 0000000..c824843 --- /dev/null +++ b/afapast/src/__tests__/acceptance/test-helper.ts @@ -0,0 +1,32 @@ +import {AfapastApplication} from '../..'; +import { + createRestAppClient, + givenHttpServerConfig, + Client, +} from '@loopback/testlab'; + +export async function setupApplication(): Promise<AppWithClient> { + const restConfig = givenHttpServerConfig({ + // Customize the server configuration here. + // Empty values (undefined, '') will be ignored by the helper. + // + // host: process.env.HOST, + // port: +process.env.PORT, + }); + + const app = new AfapastApplication({ + rest: restConfig, + }); + + await app.boot(); + await app.start(); + + const client = createRestAppClient(app); + + return {app, client}; +} + +export interface AppWithClient { + app: AfapastApplication; + client: Client; +} diff --git a/afapast/src/__tests__/pdf-gen-test.ts b/afapast/src/__tests__/pdf-gen-test.ts new file mode 100644 index 0000000..a883ba2 --- /dev/null +++ b/afapast/src/__tests__/pdf-gen-test.ts @@ -0,0 +1,37 @@ +import { expect } from '@loopback/testlab'; +import { Subscription, SubscriptionStatus } from '../services'; +import { generateCertificatePdf } from '../utils/pdf-certificate-gen' +import fs from "fs" + +describe('pdf-certificate-gen', () => { + const subscription : Subscription = { + id: '66e449e9b26ab5652cefa0cc', + incentiveId: '66e3290d74df3754b04fab9f', + funderName: 'La Fabrique des Mobilités', + incentiveType: 'AideEmployeur', + incentiveTitle: 'Aide employeur', + incentiveTransportList: [ 'transportsCommun' ], + citizenId: '802ac8b1-cde8-42f2-b310-a1c0380018d8', + lastName: 'BOUCHON', + firstName: 'Didier Lucien Gabriel', + email: 'test@yopmail.com', + city: 'Paris', + postcode: '75000', + birthdate: '1961-10-18T00:00:00.000Z', + status: SubscriptionStatus.VALIDATED, + createdAt: '2024-09-13T14:19:21.361Z', + updatedAt: '2024-09-13T14:21:57.874Z', + funderId: '2219e721-ac71-4a65-a202-37675c74ba58', + subscriptionValidation: { mode: 'aucun' }, + specificFields: { textelibre: 'libre' }, + isCitizenDeleted: false, + enterpriseEmail: 'willnotworkanyway@yopmail.com', + consent: true + } + + it('should generate a pdf buffer', async () => { + const pdfBuffer = await generateCertificatePdf(subscription) + expect(pdfBuffer).not.to.be.null + // fs.writeFileSync('attestation_employeur.pdf', pdfBuffer) + }); +}); diff --git a/afapast/src/application.ts b/afapast/src/application.ts new file mode 100644 index 0000000..8b5a082 --- /dev/null +++ b/afapast/src/application.ts @@ -0,0 +1,76 @@ +import {BootMixin} from '@loopback/boot'; +import {ApplicationConfig, createBindingFromClass} from '@loopback/core'; +import { + RestExplorerBindings, + RestExplorerComponent, +} from '@loopback/rest-explorer'; +import {RepositoryMixin} from '@loopback/repository'; +import {mergeOpenAPISpec, 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 { ApiKeyAuthenticationStrategy } from './strategies/api-key.strategy'; +import {CronComponent} from '@loopback/cron'; +import { + HandlingCronJob, +} from './cronjob/handling.cronjob'; + + +export {ApplicationConfig}; + +export class AfapastApplication extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), +) { + constructor(options: ApplicationConfig = {}) { + super(options); + + // Set up the custom sequence + this.sequence(MySequence); + + this.api( + mergeOpenAPISpec(this.getSync(RestBindings.API_SPEC), { + // info: infoObject, + components: { + securitySchemes: { + ApiKey: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + } + }, + }, + security: [ + { + ApiKey: [], + }, + ], + }), + ); + + // Set up default home page + this.static('/', path.join(__dirname, '../public')); + + // Customize @loopback/rest-explorer configuration here + this.configure(RestExplorerBindings.COMPONENT).to({ + path: '/explorer', + }); + this.component(RestExplorerComponent); + + this.component(CronComponent); + this.add(createBindingFromClass(HandlingCronJob)); + + this.projectRoot = __dirname; + // Customize @loopback/boot Booter Conventions here + this.bootOptions = { + controllers: { + // Customize ControllerBooter Conventions here + dirs: ['controllers'], + extensions: ['.controller.js'], + nested: true, + }, + }; + this.component(AuthenticationComponent); + registerAuthenticationStrategy(this, ApiKeyAuthenticationStrategy); + } +} diff --git a/afapast/src/config/mailConfig.ts b/afapast/src/config/mailConfig.ts new file mode 100644 index 0000000..6211e09 --- /dev/null +++ b/afapast/src/config/mailConfig.ts @@ -0,0 +1,29 @@ +import nodemailer from 'nodemailer'; + +export class MailConfig { + /** + * Check env before sending the email. + */ + configMailer() { + let mailer, from; + + // TODO, what params are here ? + const production = { + host: process.env.EMAIL_HOST!, + port: parseInt(process.env.EMAIL_PORT!), + }; + + // check landscape + if (process.env.NODE_ENV === 'production') { + mailer = nodemailer.createTransport(production); + from = process.env.EMAIL_FROM; + } else { + mailer = nodemailer.createTransport({ + port: 1025, + }); + from = 'Mon Compte Mobilité <mcm.mailhog@gmail.com>';; + } + + return {mailer, from}; + } +} diff --git a/afapast/src/controllers/README.md b/afapast/src/controllers/README.md new file mode 100644 index 0000000..ad4c4cc --- /dev/null +++ b/afapast/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 [<name>]` 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/afapast/src/controllers/index.ts b/afapast/src/controllers/index.ts new file mode 100644 index 0000000..d8d25de --- /dev/null +++ b/afapast/src/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './voucher.controller'; +export * from './tracked-incentives.controller'; diff --git a/afapast/src/controllers/tracked-incentives.controller.ts b/afapast/src/controllers/tracked-incentives.controller.ts new file mode 100644 index 0000000..41e49f4 --- /dev/null +++ b/afapast/src/controllers/tracked-incentives.controller.ts @@ -0,0 +1,152 @@ +import { + Count, + CountSchema, + Filter, + FilterExcludingWhere, + repository, + Where, +} from '@loopback/repository'; +import { + post, + param, + get, + getModelSchemaRef, + patch, + put, + del, + requestBody, + response, +} from '@loopback/rest'; +import {TrackedIncentives} from '../models'; +import {TrackedIncentivesRepository} from '../repositories'; +import { authenticate } from '@loopback/authentication'; + +@authenticate('api-key') +export class TrackedIncentivesController { + constructor( + @repository(TrackedIncentivesRepository) + public trackedIncentivesRepository : TrackedIncentivesRepository, + ) {} + + @post('/tracked-incentives') + @response(200, { + description: 'TrackedIncentives model instance', + content: {'application/json': {schema: getModelSchemaRef(TrackedIncentives)}}, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TrackedIncentives, { + title: 'NewTrackedIncentives', + exclude: ['id'], + }), + }, + }, + }) + trackedIncentives: Omit<TrackedIncentives, 'id'>, + ): Promise<TrackedIncentives> { + return this.trackedIncentivesRepository.create(trackedIncentives); + } + + @get('/tracked-incentives/count') + @response(200, { + description: 'TrackedIncentives model count', + content: {'application/json': {schema: CountSchema}}, + }) + async count( + @param.where(TrackedIncentives) where?: Where<TrackedIncentives>, + ): Promise<Count> { + return this.trackedIncentivesRepository.count(where); + } + + @get('/tracked-incentives') + @response(200, { + description: 'Array of TrackedIncentives model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(TrackedIncentives, {includeRelations: true}), + }, + }, + }, + }) + async find( + @param.filter(TrackedIncentives) filter?: Filter<TrackedIncentives>, + ): Promise<TrackedIncentives[]> { + return this.trackedIncentivesRepository.find(filter); + } + + @patch('/tracked-incentives') + @response(200, { + description: 'TrackedIncentives PATCH success count', + content: {'application/json': {schema: CountSchema}}, + }) + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TrackedIncentives, {partial: true}), + }, + }, + }) + trackedIncentives: TrackedIncentives, + @param.where(TrackedIncentives) where?: Where<TrackedIncentives>, + ): Promise<Count> { + return this.trackedIncentivesRepository.updateAll(trackedIncentives, where); + } + + @get('/tracked-incentives/{id}') + @response(200, { + description: 'TrackedIncentives model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(TrackedIncentives, {includeRelations: true}), + }, + }, + }) + async findById( + @param.path.number('id') id: number, + @param.filter(TrackedIncentives, {exclude: 'where'}) filter?: FilterExcludingWhere<TrackedIncentives> + ): Promise<TrackedIncentives> { + return this.trackedIncentivesRepository.findById(id, filter); + } + + @patch('/tracked-incentives/{id}') + @response(204, { + description: 'TrackedIncentives PATCH success', + }) + async updateById( + @param.path.number('id') id: number, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TrackedIncentives, {partial: true}), + }, + }, + }) + trackedIncentives: TrackedIncentives, + ): Promise<void> { + await this.trackedIncentivesRepository.updateById(id, trackedIncentives); + } + + @put('/tracked-incentives/{id}') + @response(204, { + description: 'TrackedIncentives PUT success', + }) + async replaceById( + @param.path.number('id') id: number, + @requestBody() trackedIncentives: TrackedIncentives, + ): Promise<void> { + await this.trackedIncentivesRepository.replaceById(id, trackedIncentives); + } + + @del('/tracked-incentives/{id}') + @response(204, { + description: 'TrackedIncentives DELETE success', + }) + async deleteById(@param.path.number('id') id: number): Promise<void> { + await this.trackedIncentivesRepository.deleteById(id); + } +} diff --git a/afapast/src/controllers/voucher.controller.ts b/afapast/src/controllers/voucher.controller.ts new file mode 100644 index 0000000..c42ffb9 --- /dev/null +++ b/afapast/src/controllers/voucher.controller.ts @@ -0,0 +1,151 @@ +import { + Count, + CountSchema, + Filter, + FilterExcludingWhere, + repository, + Where, +} from '@loopback/repository'; +import { + post, + param, + get, + getModelSchemaRef, + patch, + put, + del, + requestBody, + response, +} from '@loopback/rest'; +import {Voucher} from '../models'; +import {VoucherRepository} from '../repositories'; +import { authenticate } from '@loopback/authentication'; + +@authenticate('api-key') +export class VoucherController { + constructor( + @repository(VoucherRepository) + public voucherRepository : VoucherRepository, + ) {} + + @post('/vouchers') + @response(200, { + description: 'Voucher model instance', + content: {'application/json': {schema: getModelSchemaRef(Voucher)}}, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Voucher, { + title: 'NewVoucher', + exclude: ['id'], + }), + }, + }, + }) + voucher: Omit<Voucher, 'id'>, + ): Promise<Voucher> { + return this.voucherRepository.create(voucher); + } + + @get('/vouchers/count') + @response(200, { + description: 'Voucher model count', + content: {'application/json': {schema: CountSchema}}, + }) + async count( + @param.where(Voucher) where?: Where<Voucher>, + ): Promise<Count> { + return this.voucherRepository.count(where); + } + @get('/vouchers') + @response(200, { + description: 'Array of Voucher model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(Voucher, {includeRelations: true}), + }, + }, + }, + }) + async find( + @param.filter(Voucher) filter?: Filter<Voucher>, + ): Promise<Voucher[]> { + return this.voucherRepository.find(filter); + } + + @patch('/vouchers') + @response(200, { + description: 'Voucher PATCH success count', + content: {'application/json': {schema: CountSchema}}, + }) + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Voucher, {partial: true}), + }, + }, + }) + voucher: Voucher, + @param.where(Voucher) where?: Where<Voucher>, + ): Promise<Count> { + return this.voucherRepository.updateAll(voucher, where); + } + + @get('/vouchers/{id}') + @response(200, { + description: 'Voucher model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Voucher, {includeRelations: true}), + }, + }, + }) + async findById( + @param.path.number('id') id: number, + @param.filter(Voucher, {exclude: 'where'}) filter?: FilterExcludingWhere<Voucher> + ): Promise<Voucher> { + return this.voucherRepository.findById(id, filter); + } + + @patch('/vouchers/{id}') + @response(204, { + description: 'Voucher PATCH success', + }) + async updateById( + @param.path.number('id') id: number, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Voucher, {partial: true}), + }, + }, + }) + voucher: Voucher, + ): Promise<void> { + await this.voucherRepository.updateById(id, voucher); + } + + @put('/vouchers/{id}') + @response(204, { + description: 'Voucher PUT success', + }) + async replaceById( + @param.path.number('id') id: number, + @requestBody() voucher: Voucher, + ): Promise<void> { + await this.voucherRepository.replaceById(id, voucher); + } + + @del('/vouchers/{id}') + @response(204, { + description: 'Voucher DELETE success', + }) + async deleteById(@param.path.number('id') id: number): Promise<void> { + await this.voucherRepository.deleteById(id); + } +} diff --git a/afapast/src/cronjob/handling.cronjob.ts b/afapast/src/cronjob/handling.cronjob.ts new file mode 100644 index 0000000..5542289 --- /dev/null +++ b/afapast/src/cronjob/handling.cronjob.ts @@ -0,0 +1,133 @@ +import {service} from '@loopback/core'; +import {CronJob, cronJob} from '@loopback/cron'; + +import {MobService} from '../services'; +import { repository } from '@loopback/repository'; +import { TrackedIncentivesRepository, VoucherRepository } from '../repositories'; +import { VoucherStatus } from '../models/voucher.model'; +import { MailService } from '../services/mail.service'; +import { generateCertificatePdf } from '../utils/pdf-certificate-gen'; + +function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +@cronJob() +export class HandlingCronJob extends CronJob { + // Cron's type + + constructor( + @service(MobService) + public mobService: MobService, + @service(MailService) + public mailService: MailService, + @repository(TrackedIncentivesRepository) + public trackedIncentivesRepository : TrackedIncentivesRepository, + @repository(VoucherRepository) + public voucherRepository : VoucherRepository, + ) { + super({ + name: 'subscription-job', + onTick: async () => { + await this.performJob(); + }, + cronTime: '* * * * *', // every minute + start: false, + }); + } + + /** + * Perform cron job + */ + private async performJob(): Promise<void> { + console.debug("doing the job !") + const trackedIncentives = await this.trackedIncentivesRepository.find() + for (const trackedIncentive of trackedIncentives) { + const subscriptions = await this.mobService.subscriptionsFind(trackedIncentive.incentiveId, "VALIDEE") + console.debug(subscriptions.length + " subs loaded from api") + + let nbSubsHandled = 0 + for (const subscription of subscriptions) { + if (process.env.MINIMAL_SUBSCRIPTION_START_DATE && subscription.updatedAt < process.env.MINIMAL_SUBSCRIPTION_START_DATE) { + // Sub is too old, ignore it + continue + } + // Check if sub was already handled + const voucherGiven = await this.voucherRepository.find({ + where: { + subscriptionId: subscription.id + } + }) + if (voucherGiven.length > 0) { + // skip subscription already handled + continue + } + console.debug("Found one sub to be handled : " + subscription.id) + // Get a voucher + const vouchers = await this.voucherRepository.find({ + where: { + status: VoucherStatus.UNUSED + }, + limit: 1 + }) + if (!vouchers || vouchers.length === 0) { + // Uh oh, not enough vouchers :( + console.error("Not enough vouchers") + return + } + const voucher = vouchers[0] + console.debug("Got an unused voucher : " + voucher.id) + + // Employer certifcate pdf + const pdfCertificateBuffer = await generateCertificatePdf(subscription) + const attachements = [ + { + filename: 'attestation_employeur.pdf', + content: pdfCertificateBuffer, + contentType: 'application/pdf', + } + ] + + // MAIL to employee ! + await this.mailService.sendMailAsHtml(subscription.email, "Votre bon de réduction Airweb", "voucher-airweb", { + username: capitalize(subscription.firstName), + voucher: voucher.value + }, attachements) + console.debug("mail sent to " + subscription.email) + + // MAIL to CC if any + if (trackedIncentive.ccContacts) { + const ccContacts = trackedIncentive.ccContacts.split(',') + for (const ccContact of ccContacts) { + await this.mailService.sendMailAsHtml(ccContact, `Notification : nouvelle validation de droit Tiers payant (pour l'employeur ${subscription.funderName})`, "rtcr-confirmation", { + subscription: subscription, + voucher: voucher.value + }, attachements) + console.debug("mail sent to " + ccContact) + } + } + + // If mail ok, mark the voucher as used + await this.voucherRepository.updateById(voucher.id, { + status: VoucherStatus.USED, + subscriptionId: subscription.id, + citizenId: subscription.citizenId, + incentiveId: trackedIncentive.incentiveId, + }) + + console.log("voucher updated") + + nbSubsHandled++ + } + + await this.trackedIncentivesRepository.updateById(trackedIncentive.id, { + lastNbSubs: subscriptions.length, + nbSubsHandled: (trackedIncentive.nbSubsHandled ?? 0) + nbSubsHandled, + lastReadTime: new Date().toISOString() + }) + console.debug("Incentive updated, added " + nbSubsHandled + " subs handled") + } + + } +} + diff --git a/afapast/src/cronjob/index.ts b/afapast/src/cronjob/index.ts new file mode 100644 index 0000000..72f4971 --- /dev/null +++ b/afapast/src/cronjob/index.ts @@ -0,0 +1 @@ +export * from './handling.cronjob'; \ No newline at end of file diff --git a/afapast/src/datasources/README.md b/afapast/src/datasources/README.md new file mode 100644 index 0000000..57ae382 --- /dev/null +++ b/afapast/src/datasources/README.md @@ -0,0 +1,3 @@ +# Datasources + +This directory contains config for datasources used by this app. diff --git a/afapast/src/datasources/db.datasource.ts b/afapast/src/datasources/db.datasource.ts new file mode 100644 index 0000000..50fc42e --- /dev/null +++ b/afapast/src/datasources/db.datasource.ts @@ -0,0 +1,31 @@ +import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; +import {juggler} from '@loopback/repository'; + +const config = { + name: 'db', + connector: 'postgresql', + host: process.env.DB_HOST ?? 'localhost', + port: process.env.DB_PORT ?? 5432, + user: process.env.DB_SERVICE_USER ?? 'afapast', + password: process.env.DB_SERVICE_PASSWORD ?? 'afapast', + database: process.env.DB_DATABASE ?? 'afapast_db', + ssl: null // TODO ? +}; + +// 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 DbDataSource extends juggler.DataSource + implements LifeCycleObserver { + static dataSourceName = 'db'; + static readonly defaultConfig = config; + + constructor( + @inject('datasources.config.db', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } +} diff --git a/afapast/src/datasources/index.ts b/afapast/src/datasources/index.ts new file mode 100644 index 0000000..5a9914c --- /dev/null +++ b/afapast/src/datasources/index.ts @@ -0,0 +1 @@ +export * from './db.datasource'; diff --git a/afapast/src/index.ts b/afapast/src/index.ts new file mode 100644 index 0000000..574cd5a --- /dev/null +++ b/afapast/src/index.ts @@ -0,0 +1,38 @@ +import {ApplicationConfig, AfapastApplication} from './application'; + +export * from './application'; + +export async function main(options: ApplicationConfig = {}) { + const app = new AfapastApplication(options); + await app.boot(); + await app.start(); + + const url = app.restServer.url; + console.log(`Server is running at ${url}`); + + return app; +} + +if (require.main === module) { + // Run the application + const config = { + rest: { + port: +(process.env.PORT ?? 3002), + host: process.env.HOST ?? '127.0.0.1', + // 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 => { + console.error('Cannot start the application.', err); + process.exit(1); + }); +} diff --git a/afapast/src/migrate.ts b/afapast/src/migrate.ts new file mode 100644 index 0000000..fa361e2 --- /dev/null +++ b/afapast/src/migrate.ts @@ -0,0 +1,20 @@ +import {AfapastApplication} from './application'; + +export async function migrate(args: string[]) { + const existingSchema = args.includes('--rebuild') ? 'drop' : 'alter'; + console.log('Migrating schemas (%s existing schema)', existingSchema); + + const app = new AfapastApplication(); + 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 => { + console.error('Cannot migrate database schema', err); + process.exit(1); +}); diff --git a/afapast/src/models/README.md b/afapast/src/models/README.md new file mode 100644 index 0000000..f5ea972 --- /dev/null +++ b/afapast/src/models/README.md @@ -0,0 +1,3 @@ +# Models + +This directory contains code for models provided by this app. diff --git a/afapast/src/models/index.ts b/afapast/src/models/index.ts new file mode 100644 index 0000000..7b697cd --- /dev/null +++ b/afapast/src/models/index.ts @@ -0,0 +1,2 @@ +export * from './voucher.model'; +export * from './tracked-incentives.model'; diff --git a/afapast/src/models/tracked-incentives.model.ts b/afapast/src/models/tracked-incentives.model.ts new file mode 100644 index 0000000..c55ccdf --- /dev/null +++ b/afapast/src/models/tracked-incentives.model.ts @@ -0,0 +1,56 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class TrackedIncentives extends Entity { + @property({ + type: 'number', + description: 'Auto generated id', + id: true, + generated: true, + }) + id?: number; + + @property({ + type: 'string', + description: 'Id of the incentive to track, should match an id in moB, example: 67378718fce6b98d279a0f27', + required: true, + }) + incentiveId: string; + + @property({ + type: 'date', + description: 'Last time the subscriptions were checked for this incentive', + }) + lastReadTime?: string; + + @property({ + type: 'number', + description: 'Total number of VALIDEE subscriptions during the last check', + default: 0, + }) + lastNbSubs?: number; + + @property({ + type: 'number', + description: 'Total number of subscriptions handled', + default: 0, + }) + nbSubsHandled?: number; + + @property({ + type: 'string', + description: 'Contacts that will also receive the emails, alongside the employee. Comma separated list of emails. Example: "hello@test.com,hello2@test.com"', + default: '', + }) + ccContacts?: string; + + constructor(data?: Partial<TrackedIncentives>) { + super(data); + } +} + +export interface TrackedIncentivesRelations { + // describe navigational properties here +} + +export type TrackedIncentivesWithRelations = TrackedIncentives & TrackedIncentivesRelations; diff --git a/afapast/src/models/voucher.model.ts b/afapast/src/models/voucher.model.ts new file mode 100644 index 0000000..5242063 --- /dev/null +++ b/afapast/src/models/voucher.model.ts @@ -0,0 +1,76 @@ +import {Entity, model, property} from '@loopback/repository'; +export enum VoucherStatus { + UNUSED = "UNUSED", + USED = "USED", + REVOKED = "REVOKED" +} + +@model() +export class Voucher extends Entity { + @property({ + type: 'number', + id: true, + description: 'Auto generated id', + generated: true, + }) + id: number; + + @property({ + type: 'string', + description: 'Code of the voucher, will be sent to the citizen, example: "4A2NN3ES"', + required: true, + index: { + unique: true + } + }) + value: string; + + @property({ + type: 'string', + description: 'Status of the voucher, UNUSED marks availability for distribution', + default: VoucherStatus.UNUSED, + jsonSchema: { + enum: Object.values(VoucherStatus), + }, + }) + status: VoucherStatus; + + @property({ + type: 'string', + description: 'Precision on the amount, unused for now', + default: '' + }) + amount?: string; + + @property({ + type: 'string', + description: 'Automatically filled Id of the subscription the voucher was used for', + default: '' + }) + subscriptionId?: string; + + @property({ + type: 'string', + description: 'Automatically filled Id of the citizen the voucher was distributed to', + default: '' + }) + citizenId?: string; + + @property({ + type: 'string', + description: 'Automatically filled Id of the incentive the voucher was used for', + default: '' + }) + incentiveId?: string; + + + constructor(data?: Partial<Voucher>) { + super(data); + } +} + +export interface VoucherRelations { + // describe navigational properties here +} + +export type VoucherWithRelations = Voucher & VoucherRelations; diff --git a/afapast/src/openapi-spec.ts b/afapast/src/openapi-spec.ts new file mode 100644 index 0000000..2319bf2 --- /dev/null +++ b/afapast/src/openapi-spec.ts @@ -0,0 +1,23 @@ +import {ApplicationConfig} from '@loopback/core'; +import {AfapastApplication} from './application'; + +/** + * Export the OpenAPI spec from the application + */ +async function exportOpenApiSpec(): Promise<void> { + const config: ApplicationConfig = { + rest: { + port: +(process.env.PORT ?? 3000), + host: process.env.HOST ?? 'localhost', + }, + }; + const outFile = process.argv[2] ?? ''; + const app = new AfapastApplication(config); + await app.boot(); + await app.exportOpenApiSpec(outFile); +} + +exportOpenApiSpec().catch(err => { + console.error('Fail to export OpenAPI spec from the application.', err); + process.exit(1); +}); diff --git a/afapast/src/repositories/README.md b/afapast/src/repositories/README.md new file mode 100644 index 0000000..08638a7 --- /dev/null +++ b/afapast/src/repositories/README.md @@ -0,0 +1,3 @@ +# Repositories + +This directory contains code for repositories provided by this app. diff --git a/afapast/src/repositories/index.ts b/afapast/src/repositories/index.ts new file mode 100644 index 0000000..2a1cb4d --- /dev/null +++ b/afapast/src/repositories/index.ts @@ -0,0 +1,2 @@ +export * from './voucher.repository'; +export * from './tracked-incentives.repository'; diff --git a/afapast/src/repositories/tracked-incentives.repository.ts b/afapast/src/repositories/tracked-incentives.repository.ts new file mode 100644 index 0000000..fcc42ec --- /dev/null +++ b/afapast/src/repositories/tracked-incentives.repository.ts @@ -0,0 +1,16 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {DbDataSource} from '../datasources'; +import {TrackedIncentives, TrackedIncentivesRelations} from '../models'; + +export class TrackedIncentivesRepository extends DefaultCrudRepository< + TrackedIncentives, + typeof TrackedIncentives.prototype.id, + TrackedIncentivesRelations +> { + constructor( + @inject('datasources.db') dataSource: DbDataSource, + ) { + super(TrackedIncentives, dataSource); + } +} diff --git a/afapast/src/repositories/voucher.repository.ts b/afapast/src/repositories/voucher.repository.ts new file mode 100644 index 0000000..d84754a --- /dev/null +++ b/afapast/src/repositories/voucher.repository.ts @@ -0,0 +1,16 @@ +import {inject} from '@loopback/core'; +import {DefaultCrudRepository} from '@loopback/repository'; +import {DbDataSource} from '../datasources'; +import {Voucher, VoucherRelations} from '../models'; + +export class VoucherRepository extends DefaultCrudRepository< + Voucher, + typeof Voucher.prototype.id, + VoucherRelations +> { + constructor( + @inject('datasources.db') dataSource: DbDataSource, + ) { + super(Voucher, dataSource); + } +} diff --git a/afapast/src/sequence.ts b/afapast/src/sequence.ts new file mode 100644 index 0000000..2fe7751 --- /dev/null +++ b/afapast/src/sequence.ts @@ -0,0 +1,3 @@ +import {MiddlewareSequence} from '@loopback/rest'; + +export class MySequence extends MiddlewareSequence {} diff --git a/afapast/src/services/authentication.service.ts b/afapast/src/services/authentication.service.ts new file mode 100644 index 0000000..e23d8c7 --- /dev/null +++ b/afapast/src/services/authentication.service.ts @@ -0,0 +1,31 @@ +import {injectable, BindingScope} from '@loopback/core'; +import { HttpErrors } from '@loopback/rest'; +import {Request} from 'express'; + +const API_KEY = process.env.API_KEY ?? 'apikey'; + +@injectable({scope: BindingScope.TRANSIENT}) +export class AuthenticationService { + constructor() {} + + + /** + * Extract apiKey from Bearer Token + * @param request Request + * @returns string + */ + extractApiKey(request: Request): string { + const apiKeyHeaderValue = String(request.headers?.['x-api-key']); + + if (!apiKeyHeaderValue || apiKeyHeaderValue === 'undefined') { + throw new HttpErrors.Unauthorized(`Header is not of type 'X-API-Key'`); + } + + if (apiKeyHeaderValue !== API_KEY) { + throw new HttpErrors.Unauthorized(`Wrong Api Key'`); + } + + return apiKeyHeaderValue; + } + +} diff --git a/afapast/src/services/index.ts b/afapast/src/services/index.ts new file mode 100644 index 0000000..a96098b --- /dev/null +++ b/afapast/src/services/index.ts @@ -0,0 +1,2 @@ +export * from './authentication.service'; +export * from './mob.service'; diff --git a/afapast/src/services/mail.service.ts b/afapast/src/services/mail.service.ts new file mode 100644 index 0000000..46f7a51 --- /dev/null +++ b/afapast/src/services/mail.service.ts @@ -0,0 +1,42 @@ +import {injectable, BindingScope} from '@loopback/core'; +import {MailConfig} from '../config/mailConfig'; +import ejs from 'ejs'; + +export const generateTemplateAsHtml = async ( + templateName: string, + data?: Object | undefined, + ): Promise<string> => { + return ejs.renderFile( + `./templates/${templateName}.ejs`, + data ?? {}, + ); + }; + +@injectable({scope: BindingScope.TRANSIENT}) +export class MailService { + private mailConfig: MailConfig; + + constructor() { + this.mailConfig = new MailConfig(); + } + + /** + * Sending mail with html content + * + * @param to + * @param subject + * @param templateName ejs template to be used + * @param data dict with params used in template + */ + async sendMailAsHtml(to: string, subject: string, templateName: string, data?: Object, attachements?: Array<Object>) { + const html = await generateTemplateAsHtml(templateName, data); + const mailerInfos = this.mailConfig.configMailer(); + await mailerInfos.mailer.sendMail({ + from: mailerInfos.from, + to: to, + subject: subject, + html: html, + attachments: attachements + }); + } +} diff --git a/afapast/src/services/mob.service.ts b/afapast/src/services/mob.service.ts new file mode 100644 index 0000000..3fc5bad --- /dev/null +++ b/afapast/src/services/mob.service.ts @@ -0,0 +1,80 @@ +import axios from 'axios'; +import {injectable, BindingScope} from '@loopback/core'; + +export enum SubscriptionStatus { + ERROR = 'ERREUR', + TO_PROCESS = 'A_TRAITER', + VALIDATED = 'VALIDEE', + REJECTED = 'REJETEE', + DRAFT = 'BROUILLON', +} +export type Subscription = { + id: string; + incentiveId: string; + funderName: string; + incentiveType: string; + incentiveTitle: string; + incentiveTransportList: string[]; + citizenId: string; + lastName: string; + firstName: string; + email: string; + city?: string; + postcode?: string; + birthdate: string; + communityId?: string; + consent: boolean; + status: SubscriptionStatus; + createdAt: string; + updatedAt: string; + funderId: string; + specificFields?: {[prop: string]: object | string}; + isCitizenDeleted: boolean; + enterpriseEmail?: string; + subscriptionValidation?: object; +} + +// TODO: move params & URLs to env +async function getAccessToken(): Promise<string> { + const tokenUrl = process.env.MOB_TOKEN_URL ?? 'http://localhost:9000/auth/realms/mcm/protocol/openid-connect/token' + const clientId = process.env.MOB_CLIENT_ID ?? 'simulation-maas-backend' + const clientSecret = process.env.MOB_CLIENT_SECRET ?? '4x1zfk4p4d7ZdLPAsaWBhd5mu86n5ZWN' + const data = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }); + + try { + const response = await axios.post(tokenUrl, data.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + const accessToken = response.data.access_token; + // console.log('Access Token:', accessToken); + return accessToken; + } catch (error) { + console.error('Error fetching access token:', error.response ? error.response.data : error.message); + } + return '' +} +@injectable({scope: BindingScope.TRANSIENT}) +export class MobService { + constructor(/* Add @inject to inject parameters */) {} + + // TODO: handle errors ? + public async subscriptionsFind(incentiveId?: string, status?: string) { + const headers = {Authorization: `Bearer ${await getAccessToken()}`} + const params : {incentiveId?: string, status?: string} = {} + if (incentiveId) { + params.incentiveId = incentiveId + } + if (status) { + params.status = status + } + const response = await axios.get(process.env.MOB_API_SUBSCRIPTION_URL ?? 'http://localhost:3000/v1/subscriptions', {headers: headers, params: params}) + return response.data as Subscription[] + } +} diff --git a/afapast/src/strategies/api-key.strategy.ts b/afapast/src/strategies/api-key.strategy.ts new file mode 100644 index 0000000..7c78308 --- /dev/null +++ b/afapast/src/strategies/api-key.strategy.ts @@ -0,0 +1,21 @@ +import {AuthenticationStrategy} from '@loopback/authentication'; +import {Request} from 'express'; +import {AuthenticationService} from '../services/authentication.service'; +import {service} from '@loopback/core'; +import { UserProfile, securityId } from '@loopback/security'; + +export class ApiKeyAuthenticationStrategy implements AuthenticationStrategy { + name : string = 'api-key'; + + constructor( + @service(AuthenticationService) + private authenticationService: AuthenticationService, + ) {} + + async authenticate(request: Request): Promise<UserProfile | undefined> { + const apiKey: string = this.authenticationService.extractApiKey(request); + const apiUser : UserProfile = {[securityId]: '', key: apiKey} + + return apiUser; + } +} diff --git a/afapast/src/utils/mob.png b/afapast/src/utils/mob.png new file mode 100644 index 0000000..5a89b7c Binary files /dev/null and b/afapast/src/utils/mob.png differ diff --git a/afapast/src/utils/pdf-certificate-gen.ts b/afapast/src/utils/pdf-certificate-gen.ts new file mode 100644 index 0000000..a057cdd --- /dev/null +++ b/afapast/src/utils/pdf-certificate-gen.ts @@ -0,0 +1,68 @@ +import { Subscription } from '../services/mob.service'; +import PDFDocument from 'pdfkit' +import { PassThrough } from 'stream' +import path from 'path'; + + +const generateHeader = (doc: PDFKit.PDFDocument) => { + const imagePath = path.join(__dirname, '../utils/mob.png'); + doc + .image(imagePath, 50, 45, { width: 50 }) + .fillColor("#444444") + .fontSize(15) + .text("Mon Compte Mobilité", 110, 57) + .fontSize(10) + .text("https://moncomptemobilite.fr", 200, 50, { align: "right", link: "https://moncomptemobilite.fr" }) + .moveDown(); + } +const generateHr = (doc: PDFKit.PDFDocument, y: number) => { + doc + .strokeColor("#aaaaaa") + .lineWidth(1) + .moveTo(50, y) + .lineTo(550, y) + .stroke(); +} + +export const generateCertificatePdf = async (subscription: Subscription) => { + const doc = new PDFDocument(); + + // Pipe the PDF document to a PassThrough stream + const stream = new PassThrough(); + doc.pipe(stream); + + // Add content to the PDF + generateHeader(doc) + doc + .fillColor("#444444") + .fontSize(15) + .text("Objet: Attestation employeur de " + subscription.firstName + " " + subscription.lastName, 50, 160); + + generateHr(doc, 185); + doc.moveDown() + doc + .fontSize(10) + .text("Ce document est généré automatiquement par le service Mon Compte Mobilité suite à une validation manuelle par un responsable de " + subscription.funderName + ".", 50) + .moveDown() + + .text("Il agit de fait comme une attestation que " + subscription.firstName + " " + subscription.lastName + " est actuellement employé chez " + subscription.funderName + ".", 50) + .moveDown() + + .text("Cette attestation marque l'autorisation de prise en charge de l'aide " + subscription.incentiveTitle + ", avec la modalité: " + subscription.specificFields!["Type d'abonnement"] + ".", 50) + .moveDown() + + .text("Date de la validation : " + new Date(subscription.updatedAt).toLocaleString("FR-fr") + ".", 50) + + + // Finalize the PDF and close the stream + doc.end(); + + // Convert the stream to a buffer + const pdfBuffer : Buffer = await new Promise((resolve, reject) => { + const chunks : Uint8Array[] = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); + return pdfBuffer +} \ No newline at end of file diff --git a/afapast/templates/commons/button.ejs b/afapast/templates/commons/button.ejs new file mode 100644 index 0000000..a58d418 --- /dev/null +++ b/afapast/templates/commons/button.ejs @@ -0,0 +1,59 @@ +<table + style=" + margin: 20px auto; + padding: 20px 0px; + text-align: center; + width: auto; + max-width: 640px; + " +> + <tr> + <td> + <!--[if mso]> + <v:roundrect + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:w="urn:schemas-microsoft-com:office:word" + href="<%= link %>" + style="mso-wrap-style: none; mso-position-horizontal: center" + arcsize="50%" + fillcolor="#01bf7d" + stroke="false" + > + <v:textbox style="mso-fit-shape-to-text: true"> + <center + style=" + color: #ffffff; + font-family: sans-serif; + font-size: 13px; + font-weight: bold; + " + > + <%= text %> + </center></v:textbox + ></v:roundrect + > + <![endif]--> + <!--[if !mso]> <!--> + <a + href="<%= link %>" + style=" + background-color: #01bf7d; + color: #ffffff; + font-weight: bold; + text-decoration: none; + text-align: center; + border-radius: 25px; + -webkit-border-radius: 25px; + -moz-border-radius: 25px; + display: inline-block; + padding: 0.05in 0.1in; + font-family: sans-serif; + font-size: 13px; + box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; + padding: 12px 20px; + " + ><%= text %></a + ><!-- <![endif]--> + </td> + </tr> +</table> diff --git a/afapast/templates/commons/footer.ejs b/afapast/templates/commons/footer.ejs new file mode 100644 index 0000000..cc3b1ab --- /dev/null +++ b/afapast/templates/commons/footer.ejs @@ -0,0 +1,22 @@ +<%- include('utils'); %> +<% const websiteLink = getWebsiteLink("mentions-legales-cgu") %> +<% const footerLink = getLink("mob-footer.png") %> +<table + style="height: fit-content;width: 100%;text-align: center;color: white;max-width: 640px;background-color: #464CD0;width:640px;" +> + <tr> + <td> + <p style="margin-top: 15px"> + © Mon Compte Mobilité - Tous droits réservés - + <a style="color: white;" href="<%= websiteLink %>">Mentions légales </a> + </p> + </td> + </tr> + <tr> + <td> + <div style="width: auto; margin: 15px 0px;"> + <img src="<%=footerLink %>" alt="mob footer" height="50"/> + </div> + </td> + </tr> +</table> diff --git a/afapast/templates/commons/header.ejs b/afapast/templates/commons/header.ejs new file mode 100644 index 0000000..a0b84ad --- /dev/null +++ b/afapast/templates/commons/header.ejs @@ -0,0 +1,10 @@ +<%- include('utils'); %> + + <% const headerLink = getLink("logo-with-baseline.png") %> + <table style="margin-bottom: 40px;max-width: 640px;width: 640px;"> + <tr style="text-align: center;"> + <td> + <img style="width: auto;height: 50px;" src="<%=headerLink %>" alt="mob header" /> + </td> + </tr> + </table> \ No newline at end of file diff --git a/afapast/templates/commons/utils.ejs b/afapast/templates/commons/utils.ejs new file mode 100644 index 0000000..fd28978 --- /dev/null +++ b/afapast/templates/commons/utils.ejs @@ -0,0 +1,11 @@ +<% +getLink = function(imgName) { + return `https://static.moncomptemobilite.fr/assets/${imgName}` +} +%> + +<% +getWebsiteLink = function(cgu) { + return `https://static.moncomptemobilite.fr/${cgu}` +} +%> diff --git a/afapast/templates/css/style.ejs b/afapast/templates/css/style.ejs new file mode 100644 index 0000000..2753352 --- /dev/null +++ b/afapast/templates/css/style.ejs @@ -0,0 +1,143 @@ +<style> + * { + font-family: 'sofia-pro', sans-serif; + font-weight: 200; + font-size: 15px; + line-height: 1.5; + overflow-wrap: break-word; + } + + .grid-footer { + display: grid; + grid-template-columns: 600px; + justify-content: center; + height: 150px; + width: 100%; + text-align: center; + padding-top: 30px; + color: white; + margin-top: 20px; + } + + .grid-header { + display: grid; + grid-template-columns: 600px; + justify-content: center; + margin-bottom: 20px; + } + + .grid-container { + display: grid; + grid-template-columns: 600px; + justify-content: center; + } + + .grid-center { + text-align: left; + } + + .header-container { + text-align: center; + } + + .confirm-btn { + color: white; + background-color: #01bf7d; + border-radius: 25px; + outline: none; + padding: 12px 20px; + border: none; + box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; + } + + .btn { + text-align: center; + } + + .confirm-btn a { + text-decoration: none; + color: white; + } + + .footer-logo { + padding-top: 15px; + width: auto; + height: 50px; + object-fit: contain; + } + + .container { + background-color: #464cd0; + } + + .header-logo { + width: auto; + height: 50px; + } + + .italic { + font-style: italic; + font-weight: bold; + } + + .colorLink { + color: white; + } + + /* Mobile */ + + @media screen and (max-width: 600px) { + .grid-footer { + grid-template-columns: 1fr; + text-align: left !important; + height: 100px; + padding-top: 0; + margin-top: 0; + } + + .grid-header { + grid-template-columns: 1fr; + } + + .grid-container { + grid-template-columns: 1fr; + } + .confirm-btn { + padding: 10px 12px; + font-size: 8px; + } + + .container { + position: relative; + width: 100%; + height: 100%; + } + + .footer-img { + position: absolute; + left: 0; + width: 50%; + margin-left: 30px; + } + + .footer-parag { + position: absolute; + right: 0; + width: 40%; + margin-right: 80px; + } + + .footer-logo { + height: 50px; + padding-top: 20px; + } + } + + @media screen and (max-width: 438px) { + .footer-parag { + width: 50%; + font-size: 12px; + margin-right: 60px; + } + } +</style> diff --git a/afapast/templates/rtcr-confirmation.ejs b/afapast/templates/rtcr-confirmation.ejs new file mode 100644 index 0000000..281d490 --- /dev/null +++ b/afapast/templates/rtcr-confirmation.ejs @@ -0,0 +1,30 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + +<table style="max-width: 640px; width: 640px"> + <tr> + <td> + <div style="margin-bottom: 40px"> + <div> + <p>Bonjour,</p> + <p> + Nous vous informons qu'un gestionnaire de l'entreprise <%= subscription.funderName %> vient de valider une demande. + </p> + <ul> + <li>Salarié concerné : <%= subscription.firstName %> <%= subscription.lastName %> (<%= subscription.email %>)</li> + <li>Autorisé à bénéficier de la prise en charge de l'aide <%= subscription.incentiveTitle %></li> + <li>Avec la modalité: <%= subscription.specificFields["Type d'abonnement"] %></li> + </ul> + <p> + Si besoin, vous trouverez ci-joint une attestation employeur reprenant ces informations. + </p> + <div> + <p>Merci pour votre confiance,</p> + <p>L’équipe Mon Compte Mobilité</p> + </div> + </div> + </div> + </td> + </tr> +</table> + +<%- include('commons/footer'); %> diff --git a/afapast/templates/voucher-airweb.ejs b/afapast/templates/voucher-airweb.ejs new file mode 100644 index 0000000..0e927cc --- /dev/null +++ b/afapast/templates/voucher-airweb.ejs @@ -0,0 +1,25 @@ +<%- include('css/style'); %> <%- include('commons/header'); %> + +<table style="max-width: 640px; width: 640px"> + <tr> + <td> + <div style="margin-bottom: 40px"> + <div> + <p>Bonjour <%= username %>,</p> + <p> + Voici votre code promo à utiliser lors de l'achat de votre titre de transport chez Airweb : <%= voucher %> + </p> + <p> + Vous trouverez aussi ci-joint votre attestation employeur. Cette dernière pourra vous être demandée lors du parcours d'achat Airweb. + </p> + <div> + <p>Merci pour votre confiance,</p> + <p>L’équipe Mon Compte Mobilité</p> + </div> + </div> + </div> + </td> + </tr> +</table> + +<%- include('commons/footer'); %> diff --git a/afapast/tsconfig.json b/afapast/tsconfig.json new file mode 100644 index 0000000..c7b8e49 --- /dev/null +++ b/afapast/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/api/src/controllers/subscription.controller.ts b/api/src/controllers/subscription.controller.ts index ffefa70..0a39851 100644 --- a/api/src/controllers/subscription.controller.ts +++ b/api/src/controllers/subscription.controller.ts @@ -62,6 +62,7 @@ import { StatusCode, SECURITY_SPEC_KC_PASSWORD, SECURITY_SPEC_KC_CREDENTIALS_KC_PASSWORD, + SECURITY_SPEC_JWT_KC_PASSWORD_KC_CREDENTIALS, INCENTIVE_TYPE, Roles, SUBSCRIPTION_STATUS, @@ -448,11 +449,11 @@ export class SubscriptionController { * @param citizenId the citizen id * @returns subscription list */ - @authorize({allowedRoles: [Roles.MAAS, Roles.MANAGERS, Roles.CITIZENS]}) + @authorize({allowedRoles: [Roles.MAAS, Roles.MANAGERS, Roles.CITIZENS, Roles.MAAS_BACKEND]}) @get('/v1/subscriptions', { 'x-controller-name': 'Subscriptions', summary: 'Retourne les souscriptions', - security: SECURITY_SPEC_JWT_KC_PASSWORD, + security: SECURITY_SPEC_JWT_KC_PASSWORD_KC_CREDENTIALS, responses: { [StatusCode.Success]: { description: 'La liste des souscriptions',