From 283dc29ed147951975afce6a7becd597c91ba990 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 29 Sep 2024 17:22:42 +0200 Subject: [PATCH 01/21] feat: add adapter to read file --- src/infrastructure/FileAdapter.js | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/infrastructure/FileAdapter.js diff --git a/src/infrastructure/FileAdapter.js b/src/infrastructure/FileAdapter.js new file mode 100644 index 0000000..1f55f0e --- /dev/null +++ b/src/infrastructure/FileAdapter.js @@ -0,0 +1,32 @@ +import * as fs from 'node:fs/promises'; +import { logger } from './Logger.js'; + +class FileAdapter { + constructor({ fs, logger }) { + this.fs = fs; + this.logger = logger; + } + + async readFile(filePath) { + try { + await this.fs.access(filePath, this.fs.constants.F_OK | this.fs.constants.R_OK); + + const data = await this.fs.readFile(filePath, 'utf8'); + return data; + } + catch (err) { + if (err.code === 'ENOENT') { + this.logger.error('Error: File does not exist'); + } + else if (err.code === 'EACCES') { + this.logger.error('Error: No permission to read the file'); + } + else { + this.logger.error('Error reading file:', err); + } + throw err; + } + } +} + +export const fileAdapter = new FileAdapter({ fs, logger }); From e21f2f9356e8adca0b7d8f6012e5c346a7751fbe Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 29 Sep 2024 17:23:36 +0200 Subject: [PATCH 02/21] chore: add certs to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3ec544c..34a7b44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ -.env \ No newline at end of file +.env +certs/ \ No newline at end of file From 1c3c3728dcd503264941e751d9c65d3e51e94bac Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 29 Sep 2024 18:13:13 +0200 Subject: [PATCH 03/21] chore: add multiple lib --- package-lock.json | 198 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 + 2 files changed, 202 insertions(+) diff --git a/package-lock.json b/package-lock.json index f2936b8..7acb7b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,16 @@ "version": "0.0.0", "license": "AGPL-3.0", "dependencies": { + "@hapi/boom": "^10.0.1", "@hapi/hapi": "^21.3.10", "cron": "^3.1.7", + "dayjs": "^1.11.13", "ical-generator": "^8.0.0", "imapflow": "^1.0.164", + "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "mailparser": "^3.7.1", + "passkit-generator": "^3.1.11", "pg": "^8.13.0", "pino": "^9.4.0", "puppeteer": "^23.4.0" @@ -30,6 +34,9 @@ "nock": "^14.0.0-beta.14", "sinon": "^19.0.2", "sinon-chai": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@antfu/eslint-config": { @@ -1091,6 +1098,33 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -1903,6 +1937,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -2274,6 +2314,12 @@ "node": ">= 14" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -2398,6 +2444,12 @@ "node": ">=0.3.1" } }, + "node_modules/do-not-zip": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/do-not-zip/-/do-not-zip-1.0.0.tgz", + "integrity": "sha512-Pgd81ET43bhAGaN2Hq1zluSX1FmD7kl7KcV9ER/lawiLsRUB9pRA5y8r6us29Xk6BrINZETO8TjhYwtwafWUww==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2479,6 +2531,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.23", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.23.tgz", @@ -4307,6 +4368,34 @@ "dev": true, "license": "ISC" }, + "node_modules/joi": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.2.tgz", + "integrity": "sha512-Lm56PP+n0+Z2A2rfRvsfWVDXGEWjXxatPopkQ8qQ5mxCEhwHG+Ettgg5o98FFaxilOxozoa14cFhrE/hOzh/Nw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.0", + "@sideway/formula": "^3.0.0", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/joi/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/joi/node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4450,6 +4539,28 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/just-extend": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", @@ -4457,6 +4568,27 @@ "dev": true, "license": "MIT" }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4667,6 +4799,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4674,6 +4842,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -5910,6 +6084,15 @@ "node": ">= 18" } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", @@ -6163,6 +6346,21 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/passkit-generator": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/passkit-generator/-/passkit-generator-3.1.11.tgz", + "integrity": "sha512-R6xSkkbzojouvJOPt/F1sRBgSawrIetd4XVHaIhYxRxX7pNHbbdpwwQMwPIuKRgmTzUAi4hFiFlN4CTCd0NAZg==", + "license": "MIT", + "dependencies": { + "do-not-zip": "^1.0.0", + "joi": "17.4.2", + "node-forge": "^1.3.0", + "tslib": "^2.3.1" + }, + "engines": { + "node": ">=14.18.1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", diff --git a/package.json b/package.json index 0ab5e54..7da175b 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,16 @@ "test": "NODE_ENV=test npm run db:reset && NODE_ENV=test mocha --exit --recursive --reporter=${MOCHA_REPORTER:-dot} tests" }, "dependencies": { + "@hapi/boom": "^10.0.1", "@hapi/hapi": "^21.3.10", "cron": "^3.1.7", + "dayjs": "^1.11.13", "ical-generator": "^8.0.0", "imapflow": "^1.0.164", + "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", "mailparser": "^3.7.1", + "passkit-generator": "^3.1.11", "pg": "^8.13.0", "pino": "^9.4.0", "puppeteer": "^23.4.0" From 8e9219c48b397020c6bbd2694d04a4defb0672d5 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 29 Sep 2024 18:13:27 +0200 Subject: [PATCH 04/21] feat: add JsonWebTokenService --- src/infrastructure/JsonWebTokenService.js | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/infrastructure/JsonWebTokenService.js diff --git a/src/infrastructure/JsonWebTokenService.js b/src/infrastructure/JsonWebTokenService.js new file mode 100644 index 0000000..882cc6f --- /dev/null +++ b/src/infrastructure/JsonWebTokenService.js @@ -0,0 +1,45 @@ +import jsonwebtoken from 'jsonwebtoken'; +import { config } from '../../config.js'; + +class JsonWebTokenService { + constructor(jsonwebtoken, config) { + this.jsonwebtoken = jsonwebtoken; + this.config = config; + } + + async generateToken(passInfo) { + return this.jsonwebtoken.sign(passInfo, config.secret); + } + + extractTokenFromHeader(request) { + if (!request.headers.authorization) { + return ''; + } + const authorizationHeader = request.headers.authorization; + if (!authorizationHeader) { + return ''; + } else if (!authorizationHeader.startsWith('Bearer ') && !authorizationHeader.startsWith('ApplePass ')) { + return ''; + } + return authorizationHeader.replace('Bearer ', '').replace('ApplePass ', ''); + } + + extractTokenFromQuery(request) { + if (!request.query.token) { + return ''; + } + + return request.query.token; + } + + getDecodedToken(token) { + try { + return this.jsonwebtoken.verify(token, config.secret); + } + catch { + return null; + } + } +} + +export const jsonWebTokenService = new JsonWebTokenService(jsonwebtoken, config.authentication); From 9aef24eacf87cac88432527083d2ba0368c00d00 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 29 Sep 2024 18:19:20 +0200 Subject: [PATCH 05/21] feat: add auth service for pass --- src/infrastructure/AuthService.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/infrastructure/AuthService.js diff --git a/src/infrastructure/AuthService.js b/src/infrastructure/AuthService.js new file mode 100644 index 0000000..4bf3603 --- /dev/null +++ b/src/infrastructure/AuthService.js @@ -0,0 +1,28 @@ +import * as boom from '@hapi/boom'; +import { jsonWebTokenService } from './JsonWebTokenService.js'; + +class AuthService { + constructor(jsonWebTokenService) { + this.jsonWebTokenService = jsonWebTokenService; + } + + validateFromPass(request, h) { + const accessTokenFromHeader = this.jsonWebTokenService.extractTokenFromHeader(request); + const accessTokenFromQuery = this.jsonWebTokenService.extractTokenFromQuery(request); + + const accessToken = accessTokenFromHeader !== '' ? accessTokenFromHeader : accessTokenFromQuery; + + if (!accessToken) { + return boom.unauthorized('Invalid token'); + } + + const decodedAccessToken = this.jsonWebTokenService.getDecodedToken(accessToken); + if (!decodedAccessToken) { + return boom.unauthorized('Invalid token'); + } + + return h.response(true); + } +} + +export const authService = new AuthService(jsonWebTokenService); From ba81f37d8cce58aded24707e66bc8d64b763a878 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 29 Sep 2024 18:19:44 +0200 Subject: [PATCH 06/21] feat: add pass interface without implementation --- src/infrastructure/PassInterface.js | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/infrastructure/PassInterface.js diff --git a/src/infrastructure/PassInterface.js b/src/infrastructure/PassInterface.js new file mode 100644 index 0000000..19aa1c2 --- /dev/null +++ b/src/infrastructure/PassInterface.js @@ -0,0 +1,55 @@ +export class PassInterface { + constructor({ passController, authService }) { + this.passController = passController; + this.authService = authService; + } + + register(server) { + server.route([ + { + method: 'POST', + path: `/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}`, + options: { + pre: [{ method: (request, h) => { return this.authService.validateFromPass(request, h); } }], + handler: async (request, h) => { + // cf: https://developer.apple.com/documentation/walletpasses/register-a-pass-for-update-notifications + }, + tags: ['api', 'pass'], + }, + }, + { + method: 'GET', + path: '/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}', + options: { + handler: async (request, h) => { + // cf: https://developer.apple.com/documentation/walletpasses/get-the-list-of-updatable-passes + const { passesUpdatedSince } = request.query; + }, + tags: ['api', 'pass'], + }, + }, + { + method: 'GET', + path: '/v1/passes/{passTypeIdentifier}/{serialNumber}', + options: { + pre: [{ method: (request, h) => { return this.authService.validateFromPass(request, h); } }], + handler: async (request, h) => { + // cf: https://developer.apple.com/documentation/walletpasses/send-an-updated-pass + }, + tags: ['api', 'pass'], + }, + }, + { + method: 'DELETE', + path: '/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}', + options: { + pre: [{ method: (request, h) => { return this.authService.validateFromPass(request, h); } }], + handler: async (request, h) => { + // cf: https://developer.apple.com/documentation/walletpasses/unregister-a-pass-for-update-notifications + }, + tags: ['api', 'pass'], + }, + }, + ]); + } +} From 81d70f911ceba1b61ee27c14a768a946f2aab3e5 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 19:01:27 +0200 Subject: [PATCH 07/21] feat: add pass routes to routes file --- src/infrastructure/routes.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/routes.js b/src/infrastructure/routes.js index 6fa8e01..13abcf3 100644 --- a/src/infrastructure/routes.js +++ b/src/infrastructure/routes.js @@ -1,3 +1,4 @@ +import { PassInterface } from './PassInterface.js'; import { ReservationInterface } from './ReservationInterface.js'; -export const routes = [ReservationInterface]; +export const routes = [ReservationInterface, PassInterface]; From 4f7e2be16edc246d8983a45e77902305376775ea Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 29 Sep 2024 18:27:39 +0200 Subject: [PATCH 08/21] feat: add migration for passes tables --- .../20240929100654_create_pass_tables.js | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 db/migrations/20240929100654_create_pass_tables.js diff --git a/db/migrations/20240929100654_create_pass_tables.js b/db/migrations/20240929100654_create_pass_tables.js new file mode 100644 index 0000000..cdb8317 --- /dev/null +++ b/db/migrations/20240929100654_create_pass_tables.js @@ -0,0 +1,40 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +const up = async function (knex) { + await knex.schema.createTable('devices', (table) => { + table.string('deviceLibraryIdentifier').primary(); + table.string('pushToken').notNullable(); + table.dateTime('created_at').notNullable().defaultTo(knex.fn.now()); + }); + + await knex.schema.createTable('passes', (table) => { + table.string('passTypeIdentifier').notNullable(); + table.string('serialNumber').notNullable().unique(); + table.string('nextEvent').defaultTo(null); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + }); + + await knex.schema.createTable('registrations', (table) => { + table.string('passTypeIdentifier'); + table.string('serialNumber').references('passes.serialNumber'); + table.string('deviceLibraryIdentifier').references('devices.deviceLibraryIdentifier'); + table.dateTime('created_at').notNullable().defaultTo(knex.fn.now()); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +const down = async function (knex) { + await knex.schema.dropTable('registrations'); + await knex.schema.dropTable('passes'); + await knex.schema.dropTable('devices'); +}; + +export { + down, + up, +}; From fa011470798534aa95a822c2450cd8c82ebf35c2 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 13:35:33 +0200 Subject: [PATCH 09/21] feat: add repositories --- src/infrastructure/DeviceRepository.js | 28 ++++++++++++++ src/infrastructure/PassRepository.js | 37 ++++++++++++++++++ src/infrastructure/RegistrationRepository.js | 40 ++++++++++++++++++++ src/infrastructure/ReservationRepository.js | 13 +++++++ 4 files changed, 118 insertions(+) create mode 100644 src/infrastructure/DeviceRepository.js create mode 100644 src/infrastructure/PassRepository.js create mode 100644 src/infrastructure/RegistrationRepository.js diff --git a/src/infrastructure/DeviceRepository.js b/src/infrastructure/DeviceRepository.js new file mode 100644 index 0000000..248b4c6 --- /dev/null +++ b/src/infrastructure/DeviceRepository.js @@ -0,0 +1,28 @@ +import { knex } from '../../db/knex-database-connection.js'; +import { NotFoundError } from '../domain/NotFoundError.js'; + +class DeviceRepository { + #knex; + + constructor(knex) { + this.#knex = knex; + } + + async get({ deviceLibraryIdentifier }) { + const device = await this.#knex('devices').where({ deviceLibraryIdentifier }).first(); + if (!device) { + throw new NotFoundError('Device not found'); + } + return device; + } + + async save({ deviceLibraryIdentifier, pushToken }) { + await this.#knex('devices').insert({ deviceLibraryIdentifier, pushToken }); + } + + async delete({ deviceLibraryIdentifier }) { + await this.#knex('devices').delete().where({ deviceLibraryIdentifier }); + } +} + +export const deviceRepository = new DeviceRepository(knex); diff --git a/src/infrastructure/PassRepository.js b/src/infrastructure/PassRepository.js new file mode 100644 index 0000000..538913a --- /dev/null +++ b/src/infrastructure/PassRepository.js @@ -0,0 +1,37 @@ +import { knex } from '../../db/knex-database-connection.js'; +import { NotFoundError } from '../domain/NotFoundError.js'; + +class PassRepository { + #knex; + + constructor(knex) { + this.#knex = knex; + } + + async get({ passTypeIdentifier, serialNumber }) { + const pass = await this.#knex('passes').where({ passTypeIdentifier, serialNumber }).first(); + if (!pass) { + throw new NotFoundError(); + } + return pass; + } + + async save({ passTypeIdentifier, serialNumber }) { + await this.#knex('passes').insert({ passTypeIdentifier, serialNumber }); + } + + async findUpdated({ deviceLibraryIdentifier, passTypeIdentifier, passesUpdatedSince }) { + return this.#knex('passes') + .select('passes.serialNumber', 'passes.updated_at') + .innerJoin('registrations', 'passes.passTypeIdentifier', 'registrations.passTypeIdentifier') + .where({ deviceLibraryIdentifier }) + .andWhere('passes.passTypeIdentifier', passTypeIdentifier) + .andWhere('updated_at', '>', passesUpdatedSince); + } + + async updateAll({ nextEvent }) { + await this.#knex('passes').update({ nextEvent, updated_at: new Date() }); + } +} + +export const passRepository = new PassRepository(knex); diff --git a/src/infrastructure/RegistrationRepository.js b/src/infrastructure/RegistrationRepository.js new file mode 100644 index 0000000..0ffb722 --- /dev/null +++ b/src/infrastructure/RegistrationRepository.js @@ -0,0 +1,40 @@ +import { knex } from '../../db/knex-database-connection.js'; +import { NotFoundError } from '../domain/NotFoundError.js'; + +class RegistrationRepository { + #knex; + + constructor(knex) { + this.#knex = knex; + } + + async get({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }) { + const registration = await this.#knex('registrations').where({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }).first(); + if (!registration) { + throw new NotFoundError(); + } + return registration; + } + + async find({ deviceLibraryIdentifier, passTypeIdentifier }) { + const query = this.#knex('registrations') + .where({ deviceLibraryIdentifier }) + .orderBy('created_at', 'desc'); + + if (passTypeIdentifier) { + query.andWhere({ passTypeIdentifier }); + } + + return query; + } + + async save({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }) { + await this.#knex('registrations').insert({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }); + } + + async delete({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }) { + await this.#knex('registrations').where({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }).delete(); + } +} + +export const registrationRepository = new RegistrationRepository(knex); diff --git a/src/infrastructure/ReservationRepository.js b/src/infrastructure/ReservationRepository.js index 565790c..4a96f63 100644 --- a/src/infrastructure/ReservationRepository.js +++ b/src/infrastructure/ReservationRepository.js @@ -40,6 +40,19 @@ class ReservationRepository { return reservations.map(reservation => this._toDomain(reservation)); } + async getNextEvent() { + const reservation = await this.#knex('reservations') + .select('*') + .where('status', '=', Reservation.STATUSES.RESERVED) + .andWhere('start_at', '>', new Date()) + .orderBy('start_at', 'asc') + .first(); + if (!reservation) { + throw new NotFoundError(); + } + return this._toDomain(reservation); + } + _toDomain(reservationRaw) { return new Reservation({ code: reservationRaw.code, From a51ef3375c8e8522bc2c7290ad79191a470fc4a8 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 18:57:39 +0200 Subject: [PATCH 10/21] feat: add register device for update --- src/application/PassController.js | 25 ++++++++++ .../passes/RegisterPassUpdateUseCase.js | 46 +++++++++++++++++++ src/infrastructure/PassInterface.js | 1 + 3 files changed, 72 insertions(+) create mode 100644 src/application/PassController.js create mode 100644 src/domain/usecases/passes/RegisterPassUpdateUseCase.js diff --git a/src/application/PassController.js b/src/application/PassController.js new file mode 100644 index 0000000..f3fdfbc --- /dev/null +++ b/src/application/PassController.js @@ -0,0 +1,25 @@ +export class PassController { + constructor({ + registerPassUpdateUseCase, + unregisterPassUpdateUseCase, + getUpdatedPassUseCase, + findUpdatablePassesUseCase, + logger, + }) { + this.registerPassUpdateUseCase = registerPassUpdateUseCase; + this.unregisterPassUpdateUseCase = unregisterPassUpdateUseCase; + this.getUpdatedPassUseCase = getUpdatedPassUseCase; + this.findUpdatablePassesUseCase = findUpdatablePassesUseCase; + this.logger = logger; + } + + async register(request, h) { + const { deviceLibraryIdentifier, passTypeIdentifier, serialNumber } = request.params; + const { pushToken } = request.payload; + const { creation } = await this.registerPassUpdateUseCase.execute({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber, pushToken }); + if (creation) { + return h.response().code(201); + } + return h.response().code(200); + } +} diff --git a/src/domain/usecases/passes/RegisterPassUpdateUseCase.js b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js new file mode 100644 index 0000000..beb6053 --- /dev/null +++ b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js @@ -0,0 +1,46 @@ +import { NotFoundError } from '../../NotFoundError.js'; + +export class RegisterPassUpdateUseCase { + constructor({ deviceRepository, registrationRepository, passRepository }) { + this.deviceRepository = deviceRepository; + this.registrationRepository = registrationRepository; + this.passRepository = passRepository; + } + + async execute({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber, pushToken }) { + await this.createDeviceIfNotExist({ deviceLibraryIdentifier, pushToken }); + await this.passRepository.get({ passTypeIdentifier, serialNumber }); + return this.createRegistrationIfNotExist({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }); + } + + async createDeviceIfNotExist({ deviceLibraryIdentifier, pushToken }) { + try { + await this.deviceRepository.get({ deviceLibraryIdentifier }); + } + catch (e) { + if (e instanceof NotFoundError) { + await this.deviceRepository.save({ deviceLibraryIdentifier, pushToken }); + } + else { + throw e; + } + } + } + + async createRegistrationIfNotExist({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }) { + try { + await this.registrationRepository.get({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }); + return { creation: false }; + } + catch (e) { + if (e instanceof NotFoundError) { + await this.registrationRepository.save({ serialNumber, deviceLibraryIdentifier, passTypeIdentifier }); + return { creation: true }; + } + else { + throw e; + } + } + } + +} \ No newline at end of file diff --git a/src/infrastructure/PassInterface.js b/src/infrastructure/PassInterface.js index 19aa1c2..510eb76 100644 --- a/src/infrastructure/PassInterface.js +++ b/src/infrastructure/PassInterface.js @@ -13,6 +13,7 @@ export class PassInterface { pre: [{ method: (request, h) => { return this.authService.validateFromPass(request, h); } }], handler: async (request, h) => { // cf: https://developer.apple.com/documentation/walletpasses/register-a-pass-for-update-notifications + return this.passController.register(request, h); }, tags: ['api', 'pass'], }, From ae9ba832a47d1ccad7bba850477b90ba0057f6a0 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 18:58:09 +0200 Subject: [PATCH 11/21] feat: add unregister device for update --- src/application/PassController.js | 6 ++++++ .../usecases/passes/UnregisterPassUpdateUseCase.js | 14 ++++++++++++++ src/infrastructure/PassInterface.js | 1 + 3 files changed, 21 insertions(+) create mode 100644 src/domain/usecases/passes/UnregisterPassUpdateUseCase.js diff --git a/src/application/PassController.js b/src/application/PassController.js index f3fdfbc..d28f871 100644 --- a/src/application/PassController.js +++ b/src/application/PassController.js @@ -22,4 +22,10 @@ export class PassController { } return h.response().code(200); } + + async unregister(request, h) { + const { deviceLibraryIdentifier, passTypeIdentifier, serialNumber } = request.params; + await this.unregisterPassUpdateUseCase.execute({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + return h.response().code(200); + } } diff --git a/src/domain/usecases/passes/UnregisterPassUpdateUseCase.js b/src/domain/usecases/passes/UnregisterPassUpdateUseCase.js new file mode 100644 index 0000000..6ff94c0 --- /dev/null +++ b/src/domain/usecases/passes/UnregisterPassUpdateUseCase.js @@ -0,0 +1,14 @@ +export class UnregisterPassUpdateUseCase { + constructor({ registrationRepository, deviceRepository }) { + this.registrationRepository = registrationRepository; + this.deviceRepository = deviceRepository; + } + + async execute({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }) { + await this.registrationRepository.delete({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + const registrations = await this.registrationRepository.find({ deviceLibraryIdentifier }); + if (registrations.length === 0) { + await this.deviceRepository.delete({ deviceLibraryIdentifier }); + } + } +} diff --git a/src/infrastructure/PassInterface.js b/src/infrastructure/PassInterface.js index 510eb76..98bd36b 100644 --- a/src/infrastructure/PassInterface.js +++ b/src/infrastructure/PassInterface.js @@ -47,6 +47,7 @@ export class PassInterface { pre: [{ method: (request, h) => { return this.authService.validateFromPass(request, h); } }], handler: async (request, h) => { // cf: https://developer.apple.com/documentation/walletpasses/unregister-a-pass-for-update-notifications + return this.passController.unregister(request, h); }, tags: ['api', 'pass'], }, From 096187c36a11a631e700f5a8ac65d327b83d989d Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 18:59:09 +0200 Subject: [PATCH 12/21] feat: add find updatable passes --- src/application/PassController.js | 11 +++++++ .../passes/FindUpdatablePassesUseCase.js | 33 +++++++++++++++++++ src/infrastructure/PassInterface.js | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/domain/usecases/passes/FindUpdatablePassesUseCase.js diff --git a/src/application/PassController.js b/src/application/PassController.js index d28f871..e644181 100644 --- a/src/application/PassController.js +++ b/src/application/PassController.js @@ -28,4 +28,15 @@ export class PassController { await this.unregisterPassUpdateUseCase.execute({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); return h.response().code(200); } + + async findUpdatable(request, h) { + const { deviceLibraryIdentifier, passTypeIdentifier } = request.params; + const { passesUpdatedSince } = request.query; + + const { serialNumbers, lastUpdated } = await this.findUpdatablePassesUseCase.execute({ deviceLibraryIdentifier, passTypeIdentifier, passesUpdatedSince }); + if (serialNumbers.length === 0) { + return h.response().code(204); + } + return h.response({ serialNumbers, lastUpdated }).code(200); + } } diff --git a/src/domain/usecases/passes/FindUpdatablePassesUseCase.js b/src/domain/usecases/passes/FindUpdatablePassesUseCase.js new file mode 100644 index 0000000..f3be265 --- /dev/null +++ b/src/domain/usecases/passes/FindUpdatablePassesUseCase.js @@ -0,0 +1,33 @@ +import dayjs from 'dayjs'; + +export class FindUpdatablePassesUseCase { + constructor({ passRepository, registrationRepository }) { + this.passRepository = passRepository; + this.registrationRepository = registrationRepository; + } + + async execute({ deviceLibraryIdentifier, passTypeIdentifier, passesUpdatedSince }) { + if (!passesUpdatedSince) { + const registrations = await this.registrationRepository.find({ deviceLibraryIdentifier, passTypeIdentifier }); + const lastRegistration = registrations[0]; + passesUpdatedSince = lastRegistration.created_at.toISOString(); + } + else { + passesUpdatedSince = dayjs.unix(passesUpdatedSince).toISOString(); + } + const updatedPasses = await this.passRepository.findUpdated({ deviceLibraryIdentifier, passTypeIdentifier, passesUpdatedSince }); + + if (updatedPasses.length === 0) { + return { + serialNumbers: [], + lastUpdated: passesUpdatedSince, + }; + } + const lastUpdatedPass = updatedPasses.sort((a, b) => a.updated_at - b.updated_at)[0]; + + return { + serialNumbers: updatedPasses.map(({ serialNumber }) => serialNumber), + lastUpdated: lastUpdatedPass.updated_at, + }; + } +} diff --git a/src/infrastructure/PassInterface.js b/src/infrastructure/PassInterface.js index 98bd36b..bcd6156 100644 --- a/src/infrastructure/PassInterface.js +++ b/src/infrastructure/PassInterface.js @@ -24,7 +24,7 @@ export class PassInterface { options: { handler: async (request, h) => { // cf: https://developer.apple.com/documentation/walletpasses/get-the-list-of-updatable-passes - const { passesUpdatedSince } = request.query; + return this.passController.findUpdatable(request, h); }, tags: ['api', 'pass'], }, From 44027dc4df4339ddb03214965b8032be101ce3f4 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 21:26:21 +0200 Subject: [PATCH 13/21] feat: add get updated pass use-case --- config.js | 9 +++ docs/favicon.ico | Bin 0 -> 4286 bytes index.js | 7 ++ model.pass/background.png | Bin 0 -> 9096 bytes model.pass/background@2x.png | Bin 0 -> 9096 bytes model.pass/icon.png | Bin 0 -> 4573 bytes model.pass/icon@2x.png | Bin 0 -> 9008 bytes model.pass/logo.png | Bin 0 -> 4194 bytes model.pass/logo@2x.png | Bin 0 -> 4194 bytes model.pass/pass.json | 20 +++++ sample.env | 7 +- src/application/PassController.js | 15 ++++ src/application/ReservationController.js | 6 ++ src/domain/ReservationPass.js | 11 +++ .../usecases/HandleNextReservationUseCase.js | 16 ++++ .../usecases/passes/GetUpdatedPassUseCase.js | 14 ++++ src/infrastructure/PassAdapter.js | 73 ++++++++++++++++++ src/infrastructure/PassCertificatesAdapter.js | 35 +++++++++ src/infrastructure/PassInterface.js | 1 + 19 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 docs/favicon.ico create mode 100644 model.pass/background.png create mode 100644 model.pass/background@2x.png create mode 100644 model.pass/icon.png create mode 100644 model.pass/icon@2x.png create mode 100644 model.pass/logo.png create mode 100644 model.pass/logo@2x.png create mode 100644 model.pass/pass.json create mode 100644 src/domain/ReservationPass.js create mode 100644 src/domain/usecases/HandleNextReservationUseCase.js create mode 100644 src/domain/usecases/passes/GetUpdatedPassUseCase.js create mode 100644 src/infrastructure/PassAdapter.js create mode 100644 src/infrastructure/PassCertificatesAdapter.js diff --git a/config.js b/config.js index 0e54b7b..49de6db 100644 --- a/config.js +++ b/config.js @@ -17,6 +17,8 @@ function buildConfiguration() { const config = { environment: env.NODE_ENV || 'development', port: env.PORT || 3000, + baseURL: env.BASE_URL || 'http://example.net', + secret: env.SECRET, logging: { enabled: isFeatureEnabled(env.LOG_ENABLED), logLevel: env.LOG_LEVEL || 'info', @@ -52,6 +54,13 @@ function buildConfiguration() { id: env.CALENDAR_ID, }, timeSlotsPreferences: getParsedJson(env.TIME_SLOTS_PREFERENCES), + certificates: { + signerKeyPassphrase: env.CERTIFICATES_SIGNER_KEY_PASSPHRASE, + }, + pass: { + passTypeIdentifier: env.PASS_TYPE_IDENTIFIER, + teamIdentifier: env.PASS_TEAM_IDENTIFIER, + }, }; if (config.environment === 'test') { config.logging.enabled = false; diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3f410240e6a32213d5c501464236b55315fb2e16 GIT binary patch literal 4286 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 Hu0mjfCO@4m literal 0 HcmV?d00001 diff --git a/model.pass/icon@2x.png b/model.pass/icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9f44f21d0ca9a863dfb6dbda26065cc6e84899cf GIT binary patch literal 9008 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 W@(@Wy^Le)b0000U8)w^90y+z;X1c@3g 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 bbVJ0s-=Gra|4RGz83O1$(buR^vwQa+vR8{w literal 0 HcmV?d00001 diff --git a/model.pass/logo@2x.png b/model.pass/logo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9f10645102a53bf93bc938c14ab8a8573d9d8636 GIT binary patch literal 4194 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 bbVJ0s-=Gra|4RGz83O1$(buR^vwQa+vR8{w literal 0 HcmV?d00001 diff --git a/model.pass/pass.json b/model.pass/pass.json new file mode 100644 index 0000000..a37714d --- /dev/null +++ b/model.pass/pass.json @@ -0,0 +1,20 @@ +{ + "formatVersion": 1, + "locations": [ + { + "longitude": 2.371645, + "latitude": 48.896370 + } + ], + "organizationName": "Vincent Hardouin", + "description": "UCPA Reservation ticket", + "labelColor": "rgb(255, 255, 255)", + "foregroundColor": "rgb(255, 255, 255)", + "backgroundColor": "rgb(76, 19, 223)", + "eventTicket": { + "primaryFields": [ + ], + "secondaryFields": [ + ] + } +} diff --git a/sample.env b/sample.env index 853296a..6cee90d 100644 --- a/sample.env +++ b/sample.env @@ -2,6 +2,8 @@ LOG_ENABLED=true DATABASE_API_URL=postgres://postgres:@localhost:5466/ucpa_facilitator TEST_DATABASE_API_URL=postgres://postgres:@localhost:5466/ucpa_facilitator_test +BASE_URL=https://example.net + GYMLIB_MAIL_RECEIVER_IMAP_HOST= GYMLIB_MAIL_RECEIVER_IMAP_PORT= GYMLIB_MAIL_RECEIVER_IMAP_USER= @@ -54,4 +56,7 @@ CALENDAR_NAME=ucpa # Generate a new uuid to make the calendar url unpredictable # Ex: node --eval "console.log(require('crypto').randomUUID());" -CALENDAR_ID=5f67a0f9-3c63-4be5-b86a-c064ebcea491 \ No newline at end of file +CALENDAR_ID=5f67a0f9-3c63-4be5-b86a-c064ebcea491 + +PASS_TYPE_IDENTIFIER= +PASS_TEAM_IDENTIFIER= \ No newline at end of file diff --git a/src/application/PassController.js b/src/application/PassController.js index e644181..023c3d5 100644 --- a/src/application/PassController.js +++ b/src/application/PassController.js @@ -1,15 +1,22 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; + +dayjs.extend(utc); + export class PassController { constructor({ registerPassUpdateUseCase, unregisterPassUpdateUseCase, getUpdatedPassUseCase, findUpdatablePassesUseCase, + passAdapter, logger, }) { this.registerPassUpdateUseCase = registerPassUpdateUseCase; this.unregisterPassUpdateUseCase = unregisterPassUpdateUseCase; this.getUpdatedPassUseCase = getUpdatedPassUseCase; this.findUpdatablePassesUseCase = findUpdatablePassesUseCase; + this.passAdapter = passAdapter; this.logger = logger; } @@ -39,4 +46,12 @@ export class PassController { } return h.response({ serialNumbers, lastUpdated }).code(200); } + + async getUpdated(request, h) { + const { passTypeIdentifier, serialNumber } = request.params; + const updatedReservationPass = await this.getUpdatedPassUseCase.execute({ passTypeIdentifier, serialNumber }); + const pass = await this.passAdapter.get(updatedReservationPass); + const lastUpdated = dayjs(updatedReservationPass.updatedAt).utc().format('ddd, DD, MMM, YYYY HH:mm:ss'); + return h.response(pass).code(200).type('application/vnd.apple.pkpass').header('Last-Updated', `${lastUpdated} GMT`); + } } diff --git a/src/application/ReservationController.js b/src/application/ReservationController.js index 0f14f02..5b461f8 100644 --- a/src/application/ReservationController.js +++ b/src/application/ReservationController.js @@ -7,6 +7,7 @@ export class ReservationController { handleScheduledReservationUseCase, createReservationEventsUseCase, getAllEventsUseCase, + handleNextReservationUseCase, logger, }) { this.handleNewReservationUseCase = handleNewReservationUseCase; @@ -16,6 +17,7 @@ export class ReservationController { this.handleScheduledReservationUseCase = handleScheduledReservationUseCase; this.createReservationEventsUseCase = createReservationEventsUseCase; this.getAllEventsUseCase = getAllEventsUseCase; + this.handleNextReservationUseCase = handleNextReservationUseCase; this.logger = logger; } @@ -53,5 +55,9 @@ export class ReservationController { this.logger.info('Start - CreateReservationEventsUseCase'); await this.createReservationEventsUseCase.execute(); this.logger.info('End - CreateReservationEventsUseCase'); + + this.logger.info('Start - HandleNextReservationUseCase'); + await this.handleNextReservationUseCase.execute(); + this.logger.info('End - HandleNextReservationUseCase'); } } diff --git a/src/domain/ReservationPass.js b/src/domain/ReservationPass.js new file mode 100644 index 0000000..66e12e4 --- /dev/null +++ b/src/domain/ReservationPass.js @@ -0,0 +1,11 @@ +export class ReservationPass { + constructor({ code, start, court, activity, updatedAt, serialNumber, passTypeIdentifier }) { + this.code = code; + this.serialNumber = serialNumber; + this.passTypeIdentifier = passTypeIdentifier; + this.title = `UCPA - ${activity}`; + this.court = court; + this.start = start; + this.updatedAt = updatedAt; + } +} diff --git a/src/domain/usecases/HandleNextReservationUseCase.js b/src/domain/usecases/HandleNextReservationUseCase.js new file mode 100644 index 0000000..d504995 --- /dev/null +++ b/src/domain/usecases/HandleNextReservationUseCase.js @@ -0,0 +1,16 @@ +import { Reservation } from '../Reservation.js'; + +export class HandleNextReservationUseCase { + constructor({ reservationRepository, passRepository }) { + this.reservationRepository = reservationRepository; + this.passRepository = passRepository; + } + + async execute() { + const reservations = await this.reservationRepository.findByStatus(Reservation.STATUSES.RESERVED); + const now = new Date(); + const nextReservations = reservations.filter(({ start }) => now < start); + const nextReservation = nextReservations.sort((reservationA, reservationB) => reservationA.start - reservationB.start)[0]; + await this.passRepository.updateAll({ nextEvent: nextReservation.code }); + } +} diff --git a/src/domain/usecases/passes/GetUpdatedPassUseCase.js b/src/domain/usecases/passes/GetUpdatedPassUseCase.js new file mode 100644 index 0000000..25ff2bf --- /dev/null +++ b/src/domain/usecases/passes/GetUpdatedPassUseCase.js @@ -0,0 +1,14 @@ +import { ReservationPass } from '../../ReservationPass.js'; + +export class GetUpdatedPassUseCase { + constructor({ reservationRepository, passRepository }) { + this.reservationRepository = reservationRepository; + this.passRepository = passRepository; + } + + async execute({ passTypeIdentifier, serialNumber }) { + const { nextEvent } = await this.passRepository.get({ passTypeIdentifier, serialNumber }); + const reservation = await this.reservationRepository.get(nextEvent); + return new ReservationPass({ ...reservation, passTypeIdentifier, serialNumber }); + } +} diff --git a/src/infrastructure/PassAdapter.js b/src/infrastructure/PassAdapter.js new file mode 100644 index 0000000..9a716f2 --- /dev/null +++ b/src/infrastructure/PassAdapter.js @@ -0,0 +1,73 @@ +import passkit from 'passkit-generator'; +import { config } from '../../config.js'; +import { jsonWebTokenService } from './JsonWebTokenService.js'; +import { passCertificatesAdapter } from './PassCertificatesAdapter.js'; + +const PKPass = passkit.PKPass; + +class PassAdapter { + constructor({ passCertificatesAdapter, PKPass, baseURL, jsonWebTokenService, config }) { + this.passCertificatesAdapter = passCertificatesAdapter; + this.PKPass = PKPass; + this.baseURL = baseURL; + this.jsonWebTokenService = jsonWebTokenService; + this.config = config; + } + + async get({ title, start, court, code, serialNumber, passTypeIdentifier }) { + this.certificates = await this.passCertificatesAdapter.get(); + const token = await this.jsonWebTokenService.generateToken({}); + this.pass = await this.PKPass.from( + { + model: './model.pass', + certificates: this.certificates, + }, + { + serialNumber, + passTypeIdentifier, + teamIdentifier: this.config.teamIdentifier, + logoText: title, + relevantDate: start, + webServiceURL: this.baseURL, + authenticationToken: token, + }, + ); + + this.pass.primaryFields.push( + { + key: 'date', + label: 'date', + value: start, + dateStyle: 'PKDateStyleMedium', + }, + { + key: 'time', + label: 'time', + value: start, + timeStyle: 'PKDateStyleShort', + }, + ); + + this.pass.secondaryFields.push( + { + key: 'court', + label: 'court', + value: court, + }, + ); + + this.pass.auxiliaryFields.push({ + key: 'code', + label: 'code', + value: code, + }); + + this.pass.setBarcodes({ + message: code, + format: 'PKBarcodeFormatQR', + }); + return this.pass.getAsBuffer(); + } +} + +export const passAdapter = new PassAdapter({ passCertificatesAdapter, PKPass, baseURL: config.baseURL, jsonWebTokenService, config: config.pass }); diff --git a/src/infrastructure/PassCertificatesAdapter.js b/src/infrastructure/PassCertificatesAdapter.js new file mode 100644 index 0000000..04e2126 --- /dev/null +++ b/src/infrastructure/PassCertificatesAdapter.js @@ -0,0 +1,35 @@ +import { resolve } from 'node:path'; +import { config } from '../../config.js'; +import { fileAdapter } from './FileAdapter.js'; + +class PassCertificatesAdapter { + constructor({ signerKeyPassphrase, fileAdapter }) { + this.fileAdapter = fileAdapter; + this.signerKeyPassphrase = signerKeyPassphrase; + this.cache = null; + } + + async get() { + if (this.cache !== null) { + return this.cache; + } + + const signerCert = await this.fileAdapter.readFile(resolve(import.meta.dirname, '../../certs/signerCert.pem')); + const signerKey = await this.fileAdapter.readFile(resolve(import.meta.dirname, '../../certs/signerKey.pem')); + const wwdr = await this.fileAdapter.readFile(resolve(import.meta.dirname, '../../certs/wwdr.pem')); + + this.cache = { + signerCert, + signerKey, + wwdr, + signerKeyPassphrase: this.signerKeyPassphrase, + }; + + return this.cache; + } +} + +export const passCertificatesAdapter = new PassCertificatesAdapter({ + signerKeyPassphrase: config.certificates.signerKeyPassphrase, + fileAdapter, +}); diff --git a/src/infrastructure/PassInterface.js b/src/infrastructure/PassInterface.js index bcd6156..b8c57bb 100644 --- a/src/infrastructure/PassInterface.js +++ b/src/infrastructure/PassInterface.js @@ -36,6 +36,7 @@ export class PassInterface { pre: [{ method: (request, h) => { return this.authService.validateFromPass(request, h); } }], handler: async (request, h) => { // cf: https://developer.apple.com/documentation/walletpasses/send-an-updated-pass + return this.passController.getUpdated(request, h); }, tags: ['api', 'pass'], }, From 404fee974c1a26f262cab840a47edd67c60facd4 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 21:48:03 +0200 Subject: [PATCH 14/21] feat: add get pass endpoint --- src/application/PassController.js | 25 ++++++++++++++++- .../usecases/passes/CreatePassUseCase.js | 27 +++++++++++++++++++ src/infrastructure/PassInterface.js | 21 +++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/domain/usecases/passes/CreatePassUseCase.js diff --git a/src/application/PassController.js b/src/application/PassController.js index 023c3d5..554b2bc 100644 --- a/src/application/PassController.js +++ b/src/application/PassController.js @@ -1,5 +1,6 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; +import { UnableToCreatePassError } from '../domain/Errors.js'; dayjs.extend(utc); @@ -9,6 +10,7 @@ export class PassController { unregisterPassUpdateUseCase, getUpdatedPassUseCase, findUpdatablePassesUseCase, + createPassUseCase, passAdapter, logger, }) { @@ -16,6 +18,7 @@ export class PassController { this.unregisterPassUpdateUseCase = unregisterPassUpdateUseCase; this.getUpdatedPassUseCase = getUpdatedPassUseCase; this.findUpdatablePassesUseCase = findUpdatablePassesUseCase; + this.createPassUseCase = createPassUseCase; this.passAdapter = passAdapter; this.logger = logger; } @@ -41,10 +44,16 @@ export class PassController { const { passesUpdatedSince } = request.query; const { serialNumbers, lastUpdated } = await this.findUpdatablePassesUseCase.execute({ deviceLibraryIdentifier, passTypeIdentifier, passesUpdatedSince }); + if (serialNumbers.length === 0) { return h.response().code(204); } - return h.response({ serialNumbers, lastUpdated }).code(200); + return h.response({ serialNumbers, lastUpdated: `${dayjs(lastUpdated).unix()}` }).code(200); + } + + async log(request, h) { + this.logger.error(request.payload.logs); + return h.response().code(200); } async getUpdated(request, h) { @@ -54,4 +63,18 @@ export class PassController { const lastUpdated = dayjs(updatedReservationPass.updatedAt).utc().format('ddd, DD, MMM, YYYY HH:mm:ss'); return h.response(pass).code(200).type('application/vnd.apple.pkpass').header('Last-Updated', `${lastUpdated} GMT`); } + + async create(request, h) { + try { + const reservationPass = await this.createPassUseCase.execute(); + const pass = await this.passAdapter.get(reservationPass); + return h.response(pass).code(201).type('application/vnd.apple.pkpass'); + } + catch (e) { + if (e instanceof UnableToCreatePassError) { + return h.response('Unable to create a pass as there are no upcoming events').code(503); + } + throw e; + } + } } diff --git a/src/domain/usecases/passes/CreatePassUseCase.js b/src/domain/usecases/passes/CreatePassUseCase.js new file mode 100644 index 0000000..a500233 --- /dev/null +++ b/src/domain/usecases/passes/CreatePassUseCase.js @@ -0,0 +1,27 @@ +import { randomUUID } from 'node:crypto'; +import { NotFoundError, UnableToCreatePassError } from '../../Errors.js'; +import { ReservationPass } from '../../ReservationPass.js'; + +export class CreatePassUseCase { + constructor({ passRepository, reservationRepository, config }) { + this.passRepository = passRepository; + this.reservationRepository = reservationRepository; + this.config = config; + } + + async execute() { + try { + const reservation = await this.reservationRepository.getNextEvent(); + const serialNumber = randomUUID(); + const passTypeIdentifier = this.config.passTypeIdentifier; + await this.passRepository.save({ passTypeIdentifier, serialNumber }); + return new ReservationPass({ ...reservation, passTypeIdentifier, serialNumber }); + } + catch (e) { + if (e instanceof NotFoundError) { + throw new UnableToCreatePassError(); + } + throw e; + } + } +} diff --git a/src/infrastructure/PassInterface.js b/src/infrastructure/PassInterface.js index b8c57bb..bcc83a3 100644 --- a/src/infrastructure/PassInterface.js +++ b/src/infrastructure/PassInterface.js @@ -53,6 +53,27 @@ export class PassInterface { tags: ['api', 'pass'], }, }, + { + method: 'POST', + path: '/v1/log', + options: { + handler: async (request, h) => { + // cf: https://developer.apple.com/documentation/walletpasses/log-a-message + return this.passController.log(request, h); + }, + tags: ['api', 'pass'], + }, + }, + { + method: 'GET', + path: '/pass', + options: { + handler: async (request, h) => { + return this.passController.create(request, h); + }, + tags: ['api', 'pass'], + }, + }, ]); } } From a2331ab37702220ab0ee58f9e915818f3d81622c Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Mon, 30 Sep 2024 21:58:40 +0200 Subject: [PATCH 15/21] feat: instantiate pass controller in index --- index.js | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 9818954..1a5e3f9 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,8 @@ import { CronJob } from 'cron'; import { config } from './config.js'; - import { createServer } from './server.js'; +import { PassController } from './src/application/PassController.js'; import { ReservationController } from './src/application/ReservationController.js'; import { CreateReservationEventsUseCase } from './src/domain/usecases/CreateReservationEventsUseCase.js'; import { GetActiveReservationsUseCase } from './src/domain/usecases/GetActiveReservationsUseCase.js'; @@ -10,12 +10,21 @@ import { GetAllEventsUseCase } from './src/domain/usecases/GetAllEventsUseCase.j import { HandleNewReservationUseCase } from './src/domain/usecases/HandleNewReservationUseCase.js'; import { HandleScheduledReservationUseCase } from './src/domain/usecases/HandleScheduledReservationUseCase.js'; import { NotifyUseCase } from './src/domain/usecases/NotifyUseCase.js'; +import { FindUpdatablePassesUseCase } from './src/domain/usecases/passes/FindUpdatablePassesUseCase.js'; +import { GetUpdatedPassUseCase } from './src/domain/usecases/passes/GetUpdatedPassUseCase.js'; +import { RegisterPassUpdateUseCase } from './src/domain/usecases/passes/RegisterPassUpdateUseCase.js'; +import { UnregisterPassUpdateUseCase } from './src/domain/usecases/passes/UnregisterPassUpdateUseCase.js'; import { SubmitFormUseCase } from './src/domain/usecases/SubmitFormUseCase.js'; +import { authService } from './src/infrastructure/AuthService.js'; import { Browser } from './src/infrastructure/Browser.js'; import { CalendarRepository } from './src/infrastructure/CalendarRepository.js'; +import { deviceRepository } from './src/infrastructure/DeviceRepository.js'; import { ImapClient } from './src/infrastructure/ImapClient.js'; import { logger } from './src/infrastructure/logger.js'; import { NotificationClient } from './src/infrastructure/NotificationClient.js'; +import { passAdapter } from './src/infrastructure/PassAdapter.js'; +import { passRepository } from './src/infrastructure/PassRepository.js'; +import { registrationRepository } from './src/infrastructure/RegistrationRepository.js'; import { reservationRepository } from './src/infrastructure/ReservationRepository.js'; import { TimeSlotDatasource } from './src/infrastructure/TimeSlotDatasource.js'; import {HandleNextReservationUseCase} from './src/domain/usecases/HandleNextReservationUseCase.js'; @@ -26,6 +35,7 @@ main(); async function main() { const reservationController = await getReservationController(); + const passController = getPassController(); CronJob.from({ cronTime: config.cronTime, onTick: async () => { @@ -36,7 +46,7 @@ async function main() { start: true, timeZone: parisTimezone, }); - const server = await createServer({ reservationController }); + const server = await createServer({ reservationController, authService, passController }); await server.start(); } @@ -104,3 +114,32 @@ async function getReservationController() { logger, }); } + +function getPassController() { + const registerPassUpdateUseCase = new RegisterPassUpdateUseCase({ + deviceRepository, + registrationRepository, + passRepository, + }); + + const unregisterPassUpdateUseCase = new UnregisterPassUpdateUseCase({ + registrationRepository, + }); + + const getUpdatedPassUseCase = new GetUpdatedPassUseCase({ + reservationRepository, + }); + + const findUpdatablePassesUseCase = new FindUpdatablePassesUseCase({ + passRepository, + }); + + return new PassController({ + registerPassUpdateUseCase, + unregisterPassUpdateUseCase, + getUpdatedPassUseCase, + findUpdatablePassesUseCase, + passAdapter, + logger, + }); +} From 7ef66421b49581b95837a4ab42f04d00b7eb49d0 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sat, 5 Oct 2024 20:54:02 +0200 Subject: [PATCH 16/21] refactor: create index with initialized class --- index.js | 124 +---------------------------------- src/application/index.js | 45 +++++++++++++ src/domain/usecases/index.js | 113 +++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 121 deletions(-) create mode 100644 src/application/index.js create mode 100644 src/domain/usecases/index.js diff --git a/index.js b/index.js index 1a5e3f9..a202bbf 100644 --- a/index.js +++ b/index.js @@ -2,40 +2,15 @@ import { CronJob } from 'cron'; import { config } from './config.js'; import { createServer } from './server.js'; -import { PassController } from './src/application/PassController.js'; -import { ReservationController } from './src/application/ReservationController.js'; -import { CreateReservationEventsUseCase } from './src/domain/usecases/CreateReservationEventsUseCase.js'; -import { GetActiveReservationsUseCase } from './src/domain/usecases/GetActiveReservationsUseCase.js'; -import { GetAllEventsUseCase } from './src/domain/usecases/GetAllEventsUseCase.js'; -import { HandleNewReservationUseCase } from './src/domain/usecases/HandleNewReservationUseCase.js'; -import { HandleScheduledReservationUseCase } from './src/domain/usecases/HandleScheduledReservationUseCase.js'; -import { NotifyUseCase } from './src/domain/usecases/NotifyUseCase.js'; -import { FindUpdatablePassesUseCase } from './src/domain/usecases/passes/FindUpdatablePassesUseCase.js'; -import { GetUpdatedPassUseCase } from './src/domain/usecases/passes/GetUpdatedPassUseCase.js'; -import { RegisterPassUpdateUseCase } from './src/domain/usecases/passes/RegisterPassUpdateUseCase.js'; -import { UnregisterPassUpdateUseCase } from './src/domain/usecases/passes/UnregisterPassUpdateUseCase.js'; -import { SubmitFormUseCase } from './src/domain/usecases/SubmitFormUseCase.js'; +import { passController, reservationController } from './src/application/index.js'; import { authService } from './src/infrastructure/AuthService.js'; -import { Browser } from './src/infrastructure/Browser.js'; -import { CalendarRepository } from './src/infrastructure/CalendarRepository.js'; -import { deviceRepository } from './src/infrastructure/DeviceRepository.js'; -import { ImapClient } from './src/infrastructure/ImapClient.js'; import { logger } from './src/infrastructure/logger.js'; -import { NotificationClient } from './src/infrastructure/NotificationClient.js'; -import { passAdapter } from './src/infrastructure/PassAdapter.js'; -import { passRepository } from './src/infrastructure/PassRepository.js'; -import { registrationRepository } from './src/infrastructure/RegistrationRepository.js'; -import { reservationRepository } from './src/infrastructure/ReservationRepository.js'; -import { TimeSlotDatasource } from './src/infrastructure/TimeSlotDatasource.js'; -import {HandleNextReservationUseCase} from './src/domain/usecases/HandleNextReservationUseCase.js'; const parisTimezone = 'Europe/Paris'; main(); async function main() { - const reservationController = await getReservationController(); - const passController = getPassController(); CronJob.from({ cronTime: config.cronTime, onTick: async () => { @@ -44,102 +19,9 @@ async function main() { logger.info('End job'); }, start: true, + runOnInit: true, timeZone: parisTimezone, }); const server = await createServer({ reservationController, authService, passController }); await server.start(); -} - -async function getReservationController() { - const gymlibImapClient = new ImapClient(config.gymlib.imapConfig); - const handleNewReservationUseCase = new HandleNewReservationUseCase({ - imapClient: gymlibImapClient, - searchQuery: config.gymlib.searchQuery, - }); - - const getActiveReservationsUseCase = new GetActiveReservationsUseCase({ - reservationRepository, - }); - - const browser = await Browser.create(); - const submitFormUseCase = new SubmitFormUseCase({ - browser, - reservationRepository, - formInfo: config.ucpa.formInfo, - dryRun: !config.ucpa.formSubmit, - }); - - const ucpaImapClient = new ImapClient(config.ucpa.imapConfig); - const timeSlotDatasource = new TimeSlotDatasource(); - const notificationClient = new NotificationClient(config.notification); - const notifyUseCase = new NotifyUseCase({ - imapClient: ucpaImapClient, - searchQuery: config.ucpa.searchQuery, - reservationRepository, - timeSlotDatasource, - notificationClient, - timeSlotsPreferences: config.timeSlotsPreferences, - areaId: config.ucpa.areaId, - }); - - const handleScheduledReservationUseCase = new HandleScheduledReservationUseCase({ - imapClient: ucpaImapClient, - searchQuery: config.ucpa.searchQuery, - reservationRepository, - }); - - const calendarRepository = new CalendarRepository(config.calendar.name); - - const createReservationEventsUseCase = new CreateReservationEventsUseCase({ - reservationRepository, - calendarRepository, - }); - - const getAllEventsUseCase = new GetAllEventsUseCase({ calendarRepository }); - - const handleNextReservationUseCase = new HandleNextReservationUseCase({ - reservationRepository, - passRepository, - }) - - return new ReservationController({ - handleNewReservationUseCase, - getActiveReservationsUseCase, - submitFormUseCase, - notifyUseCase, - handleScheduledReservationUseCase, - createReservationEventsUseCase, - getAllEventsUseCase, - handleNextReservationUseCase, - logger, - }); -} - -function getPassController() { - const registerPassUpdateUseCase = new RegisterPassUpdateUseCase({ - deviceRepository, - registrationRepository, - passRepository, - }); - - const unregisterPassUpdateUseCase = new UnregisterPassUpdateUseCase({ - registrationRepository, - }); - - const getUpdatedPassUseCase = new GetUpdatedPassUseCase({ - reservationRepository, - }); - - const findUpdatablePassesUseCase = new FindUpdatablePassesUseCase({ - passRepository, - }); - - return new PassController({ - registerPassUpdateUseCase, - unregisterPassUpdateUseCase, - getUpdatedPassUseCase, - findUpdatablePassesUseCase, - passAdapter, - logger, - }); -} +}; diff --git a/src/application/index.js b/src/application/index.js new file mode 100644 index 0000000..64dda9d --- /dev/null +++ b/src/application/index.js @@ -0,0 +1,45 @@ +import { + createPassUseCase, + createReservationEventsUseCase, + findUpdatablePassesUseCase, + getActiveReservationsUseCase, + getAllEventsUseCase, + getUpdatedPassUseCase, + handleNewReservationUseCase, + handleNextReservationUseCase, + handleScheduledReservationUseCase, + notifyUseCase, + registerPassUpdateUseCase, + submitFormUseCase, + unregisterPassUpdateUseCase, +} from '../domain/usecases/index.js'; +import { logger } from '../infrastructure/Logger.js'; +import { passAdapter } from '../infrastructure/PassAdapter.js'; +import { PassController } from './PassController.js'; +import { ReservationController } from './ReservationController.js'; + +const passController = new PassController({ + registerPassUpdateUseCase, + unregisterPassUpdateUseCase, + findUpdatablePassesUseCase, + getUpdatedPassUseCase, + createPassUseCase, + passAdapter, +}); + +const reservationController = new ReservationController({ + handleNewReservationUseCase, + getActiveReservationsUseCase, + submitFormUseCase, + notifyUseCase, + handleScheduledReservationUseCase, + createReservationEventsUseCase, + getAllEventsUseCase, + handleNextReservationUseCase, + logger, +}); + +export { + passController, + reservationController, +}; diff --git a/src/domain/usecases/index.js b/src/domain/usecases/index.js new file mode 100644 index 0000000..dd9c80b --- /dev/null +++ b/src/domain/usecases/index.js @@ -0,0 +1,113 @@ +import { config } from '../../../config.js'; +import { Browser } from '../../infrastructure/Browser.js'; +import { CalendarRepository } from '../../infrastructure/CalendarRepository.js'; +import { deviceRepository } from '../../infrastructure/DeviceRepository.js'; +import { ImapClient } from '../../infrastructure/ImapClient.js'; +import { NotificationClient } from '../../infrastructure/NotificationClient.js'; +import { passRepository } from '../../infrastructure/PassRepository.js'; +import { registrationRepository } from '../../infrastructure/RegistrationRepository.js'; +import { reservationRepository } from '../../infrastructure/ReservationRepository.js'; +import { TimeSlotDatasource } from '../../infrastructure/TimeSlotDatasource.js'; +import { CreateReservationEventsUseCase } from './CreateReservationEventsUseCase.js'; +import { GetActiveReservationsUseCase } from './GetActiveReservationsUseCase.js'; +import { GetAllEventsUseCase } from './GetAllEventsUseCase.js'; +import { HandleNewReservationUseCase } from './HandleNewReservationUseCase.js'; +import { HandleNextReservationUseCase } from './HandleNextReservationUseCase.js'; +import { HandleScheduledReservationUseCase } from './HandleScheduledReservationUseCase.js'; +import { NotifyUseCase } from './NotifyUseCase.js'; +import { CreatePassUseCase } from './passes/CreatePassUseCase.js'; +import { FindUpdatablePassesUseCase } from './passes/FindUpdatablePassesUseCase.js'; +import { GetUpdatedPassUseCase } from './passes/GetUpdatedPassUseCase.js'; +import { RegisterPassUpdateUseCase } from './passes/RegisterPassUpdateUseCase.js'; +import { UnregisterPassUpdateUseCase } from './passes/UnregisterPassUpdateUseCase.js'; +import { SubmitFormUseCase } from './SubmitFormUseCase.js'; + +const registerPassUpdateUseCase = new RegisterPassUpdateUseCase({ + deviceRepository, + registrationRepository, + passRepository, +}); +const unregisterPassUpdateUseCase = new UnregisterPassUpdateUseCase({ + registrationRepository, + deviceRepository, +}); +const findUpdatablePassesUseCase = new FindUpdatablePassesUseCase({ + passRepository, + registrationRepository, +}); +const getUpdatedPassUseCase = new GetUpdatedPassUseCase({ + reservationRepository, + passRepository, +}); +const createPassUseCase = new CreatePassUseCase({ + passRepository, + reservationRepository, + config: config.pass, +}); + +const gymlibImapClient = new ImapClient(config.gymlib.imapConfig); +const handleNewReservationUseCase = new HandleNewReservationUseCase({ + imapClient: gymlibImapClient, + searchQuery: config.gymlib.searchQuery, +}); + +const getActiveReservationsUseCase = new GetActiveReservationsUseCase({ + reservationRepository, +}); + +const browser = await Browser.create(); +const submitFormUseCase = new SubmitFormUseCase({ + browser, + reservationRepository, + formInfo: config.ucpa.formInfo, + dryRun: !config.ucpa.formSubmit, +}); + +const ucpaImapClient = new ImapClient(config.ucpa.imapConfig); +const timeSlotDatasource = new TimeSlotDatasource(); +const notificationClient = new NotificationClient(config.notification); +const notifyUseCase = new NotifyUseCase({ + imapClient: ucpaImapClient, + searchQuery: config.ucpa.searchQuery, + reservationRepository, + timeSlotDatasource, + notificationClient, + timeSlotsPreferences: config.timeSlotsPreferences, + areaId: config.ucpa.areaId, +}); + +const handleScheduledReservationUseCase = new HandleScheduledReservationUseCase({ + imapClient: ucpaImapClient, + searchQuery: config.ucpa.searchQuery, + reservationRepository, +}); + +const calendarRepository = new CalendarRepository(config.calendar.name); + +const createReservationEventsUseCase = new CreateReservationEventsUseCase({ + reservationRepository, + calendarRepository, +}); + +const getAllEventsUseCase = new GetAllEventsUseCase({ calendarRepository }); + +const handleNextReservationUseCase = new HandleNextReservationUseCase({ + reservationRepository, + passRepository, +}); + +export { + createPassUseCase, + createReservationEventsUseCase, + findUpdatablePassesUseCase, + getActiveReservationsUseCase, + getAllEventsUseCase, + getUpdatedPassUseCase, + handleNewReservationUseCase, + handleNextReservationUseCase, + handleScheduledReservationUseCase, + notifyUseCase, + registerPassUpdateUseCase, + submitFormUseCase, + unregisterPassUpdateUseCase, +}; From 8070cf0674733ab428434249b51bdc1bc03787e1 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sat, 5 Oct 2024 21:01:06 +0200 Subject: [PATCH 17/21] fix: allow to not have all days in time slot preferences --- src/domain/TimeSlot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/TimeSlot.js b/src/domain/TimeSlot.js index 8cfef6f..f1c124d 100644 --- a/src/domain/TimeSlot.js +++ b/src/domain/TimeSlot.js @@ -19,7 +19,7 @@ export class TimeSlot { } isConvenient(timeSlotsPreferences) { - return timeSlotsPreferences[this.dayOfWeek].includes(this.startTime); + return timeSlotsPreferences[this.dayOfWeek]?.includes(this.startTime); } isMoreConvenient(slotToCompare, timeSlotsPreferences) { From 686e2a79e0149c4bbb15adaadf18598bf1a8c101 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 6 Oct 2024 18:40:49 +0200 Subject: [PATCH 18/21] refactor: rename NotFoundError to Errors --- src/domain/Errors.js | 7 +++++++ src/domain/NotFoundError.js | 4 ---- src/domain/usecases/HandleNewReservationUseCase.js | 2 +- src/domain/usecases/HandleScheduledReservationUseCase.js | 2 +- src/domain/usecases/passes/RegisterPassUpdateUseCase.js | 2 +- src/infrastructure/DeviceRepository.js | 2 +- src/infrastructure/PassRepository.js | 2 +- src/infrastructure/RegistrationRepository.js | 2 +- src/infrastructure/ReservationRepository.js | 2 +- 9 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 src/domain/Errors.js delete mode 100644 src/domain/NotFoundError.js diff --git a/src/domain/Errors.js b/src/domain/Errors.js new file mode 100644 index 0000000..48898f5 --- /dev/null +++ b/src/domain/Errors.js @@ -0,0 +1,7 @@ +class NotFoundError extends Error { +} + +class UnableToCreatePassError extends Error { +} + +export { NotFoundError, UnableToCreatePassError }; diff --git a/src/domain/NotFoundError.js b/src/domain/NotFoundError.js deleted file mode 100644 index e56cef8..0000000 --- a/src/domain/NotFoundError.js +++ /dev/null @@ -1,4 +0,0 @@ -class NotFoundError extends Error { -} - -export { NotFoundError }; diff --git a/src/domain/usecases/HandleNewReservationUseCase.js b/src/domain/usecases/HandleNewReservationUseCase.js index 2dca5aa..6036568 100644 --- a/src/domain/usecases/HandleNewReservationUseCase.js +++ b/src/domain/usecases/HandleNewReservationUseCase.js @@ -1,5 +1,5 @@ import { reservationRepository } from '../../infrastructure/ReservationRepository.js'; -import { NotFoundError } from '../NotFoundError.js'; +import { NotFoundError } from '../Errors.js'; import { Reservation } from '../Reservation.js'; const CODE_REGEXP = /Voici votre code de réservation UCPA : (?\d+)/; diff --git a/src/domain/usecases/HandleScheduledReservationUseCase.js b/src/domain/usecases/HandleScheduledReservationUseCase.js index 2fabc58..90ff0d5 100644 --- a/src/domain/usecases/HandleScheduledReservationUseCase.js +++ b/src/domain/usecases/HandleScheduledReservationUseCase.js @@ -1,4 +1,4 @@ -import { NotFoundError } from '../NotFoundError.js'; +import { NotFoundError } from '../Errors.js'; import { Reservation } from '../Reservation.js'; const RESERVATION_ACCEPTED_MESSAGE_CONTENT = 'MERCI POUR VOTRE RESERVATION !'; diff --git a/src/domain/usecases/passes/RegisterPassUpdateUseCase.js b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js index beb6053..e0328d7 100644 --- a/src/domain/usecases/passes/RegisterPassUpdateUseCase.js +++ b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js @@ -1,4 +1,4 @@ -import { NotFoundError } from '../../NotFoundError.js'; +import { NotFoundError } from '../../Errors.js'; export class RegisterPassUpdateUseCase { constructor({ deviceRepository, registrationRepository, passRepository }) { diff --git a/src/infrastructure/DeviceRepository.js b/src/infrastructure/DeviceRepository.js index 248b4c6..6c1850d 100644 --- a/src/infrastructure/DeviceRepository.js +++ b/src/infrastructure/DeviceRepository.js @@ -1,5 +1,5 @@ import { knex } from '../../db/knex-database-connection.js'; -import { NotFoundError } from '../domain/NotFoundError.js'; +import { NotFoundError } from '../domain/Errors.js'; class DeviceRepository { #knex; diff --git a/src/infrastructure/PassRepository.js b/src/infrastructure/PassRepository.js index 538913a..a662ae5 100644 --- a/src/infrastructure/PassRepository.js +++ b/src/infrastructure/PassRepository.js @@ -1,5 +1,5 @@ import { knex } from '../../db/knex-database-connection.js'; -import { NotFoundError } from '../domain/NotFoundError.js'; +import { NotFoundError } from '../domain/Errors.js'; class PassRepository { #knex; diff --git a/src/infrastructure/RegistrationRepository.js b/src/infrastructure/RegistrationRepository.js index 0ffb722..6d60314 100644 --- a/src/infrastructure/RegistrationRepository.js +++ b/src/infrastructure/RegistrationRepository.js @@ -1,5 +1,5 @@ import { knex } from '../../db/knex-database-connection.js'; -import { NotFoundError } from '../domain/NotFoundError.js'; +import { NotFoundError } from '../domain/Errors.js'; class RegistrationRepository { #knex; diff --git a/src/infrastructure/ReservationRepository.js b/src/infrastructure/ReservationRepository.js index 4a96f63..443e6fb 100644 --- a/src/infrastructure/ReservationRepository.js +++ b/src/infrastructure/ReservationRepository.js @@ -1,5 +1,5 @@ import { knex } from '../../db/knex-database-connection.js'; -import { NotFoundError } from '../domain/NotFoundError.js'; +import { NotFoundError } from '../domain/Errors.js'; import { Reservation } from '../domain/Reservation.js'; class ReservationRepository { From dcf3abbd8f2849df10b88d0fab395387e86f91b0 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 6 Oct 2024 18:34:30 +0200 Subject: [PATCH 19/21] chore: fix lint --- .github/workflows/ci.yml | 2 +- src/domain/usecases/passes/RegisterPassUpdateUseCase.js | 3 +-- src/infrastructure/JsonWebTokenService.js | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b69140f..bd4696a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' + cache: npm - name: Install run: npm ci diff --git a/src/domain/usecases/passes/RegisterPassUpdateUseCase.js b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js index e0328d7..f28e20a 100644 --- a/src/domain/usecases/passes/RegisterPassUpdateUseCase.js +++ b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js @@ -42,5 +42,4 @@ export class RegisterPassUpdateUseCase { } } } - -} \ No newline at end of file +} diff --git a/src/infrastructure/JsonWebTokenService.js b/src/infrastructure/JsonWebTokenService.js index 882cc6f..9f660cf 100644 --- a/src/infrastructure/JsonWebTokenService.js +++ b/src/infrastructure/JsonWebTokenService.js @@ -18,7 +18,8 @@ class JsonWebTokenService { const authorizationHeader = request.headers.authorization; if (!authorizationHeader) { return ''; - } else if (!authorizationHeader.startsWith('Bearer ') && !authorizationHeader.startsWith('ApplePass ')) { + } + else if (!authorizationHeader.startsWith('Bearer ') && !authorizationHeader.startsWith('ApplePass ')) { return ''; } return authorizationHeader.replace('Bearer ', '').replace('ApplePass ', ''); From 207ff68e4a1e9b5354b45d20fce40cae712d5718 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 6 Oct 2024 18:50:40 +0200 Subject: [PATCH 20/21] tests: add passes acceptance tests --- config.js | 2 + src/infrastructure/Browser.js | 4 + tests/acceptance/passes_tests.js | 255 +++++++++++++++++++++++++++++++ tests/test-helpers.js | 7 + 4 files changed, 268 insertions(+) create mode 100644 tests/acceptance/passes_tests.js diff --git a/config.js b/config.js index 49de6db..491b638 100644 --- a/config.js +++ b/config.js @@ -64,6 +64,8 @@ function buildConfiguration() { }; if (config.environment === 'test') { config.logging.enabled = false; + config.secret = 'SECRET_FOR_TESTS'; + config.pass.passTypeIdentifier = 'pass-identifier'; } if (!verifyConfig(config)) { diff --git a/src/infrastructure/Browser.js b/src/infrastructure/Browser.js index a8c64a5..b1f1ab0 100644 --- a/src/infrastructure/Browser.js +++ b/src/infrastructure/Browser.js @@ -1,3 +1,4 @@ +import { env } from 'node:process'; import puppeteer from 'puppeteer'; export class Browser { @@ -7,6 +8,9 @@ export class Browser { } static async create() { + if (env.NODE_ENV === 'test') { + return; + } const browser = await puppeteer.launch({ headless: true }); const page = await browser.newPage(); const customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0'; diff --git a/tests/acceptance/passes_tests.js b/tests/acceptance/passes_tests.js new file mode 100644 index 0000000..039ddd3 --- /dev/null +++ b/tests/acceptance/passes_tests.js @@ -0,0 +1,255 @@ +import dayjs from 'dayjs'; +import { knex } from '../../db/knex-database-connection.js'; +import { createServer } from '../../server.js'; +import { passController } from '../../src/application/index.js'; +import { authService } from '../../src/infrastructure/AuthService.js'; +import { expect, generateAuthorizationToken, sinon } from '../test-helpers.js'; + +describe('Acceptance | Endpoints | Passes', function () { + let server; + + beforeEach(async function () { + passController.passAdapter = { + get: async () => {}, + }; + server = await createServer({ passController, authService }); + await knex('reservations').delete(); + await knex('registrations').delete(); + await knex('devices').delete(); + await knex('passes').delete(); + await knex('passes').insert({ passTypeIdentifier: 'passId', serialNumber: '12345' }); + }); + + afterEach(async function () { + await knex('reservations').delete(); + await knex('registrations').delete(); + await knex('devices').delete(); + await knex('passes').delete(); + }); + + describe('Register pass', function () { + context('when registration does not exist', function () { + it('should save device and register pass', async function () { + const deviceLibraryIdentifier = 'deviceId'; + const passTypeIdentifier = 'passId'; + const serialNumber = '12345'; + const token = await generateAuthorizationToken(); + + const response = await server.inject({ + method: 'POST', + url: `/v1/devices/${deviceLibraryIdentifier}/registrations/${passTypeIdentifier}/${serialNumber}`, + headers: { authorization: token }, + payload: { + pushToken: 'push-token', + }, + }); + + expect(response.statusCode).to.equal(201); + const savedRegistrations = await knex('registrations') + .select('deviceLibraryIdentifier', 'passTypeIdentifier', 'serialNumber') + .where({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }) + .first(); + expect(savedRegistrations).to.be.deep.equal({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + }); + }); + + context('when registration exists', function () { + it('should do nothing', async function () { + const deviceLibraryIdentifier = 'deviceId'; + const passTypeIdentifier = 'passId'; + const serialNumber = '12345'; + const pushToken = 'push-token'; + const token = await generateAuthorizationToken(); + + await knex('devices').insert({ deviceLibraryIdentifier, pushToken }); + await knex('registrations').insert({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + + const response = await server.inject({ + method: 'POST', + url: `/v1/devices/${deviceLibraryIdentifier}/registrations/${passTypeIdentifier}/${serialNumber}`, + headers: { authorization: token }, + payload: { + pushToken, + }, + }); + + expect(response.statusCode).to.equal(200); + }); + }); + }); + + describe('Unregister pass', function () { + it('should unregister pass', async function () { + const deviceLibraryIdentifier = 'deviceId'; + const passTypeIdentifier = 'passId'; + const serialNumber = '12345'; + const anotherSerialNumber = '6789'; + const pushToken = 'push-token'; + const token = await generateAuthorizationToken(); + + await knex('passes').insert({ passTypeIdentifier, serialNumber: anotherSerialNumber }); + await knex('devices').insert({ deviceLibraryIdentifier, pushToken }); + await knex('registrations').insert({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + await knex('registrations').insert({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber: anotherSerialNumber }); + + const response = await server.inject({ + method: 'DELETE', + url: `/v1/devices/${deviceLibraryIdentifier}/registrations/${passTypeIdentifier}/${serialNumber}`, + headers: { authorization: token }, + }); + + expect(response.statusCode).to.equal(200); + const deletedRegistration = await knex('registrations').where({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + expect(deletedRegistration).to.have.lengthOf(0); + + const keptRegistrations = await knex('registrations').where({ deviceLibraryIdentifier, passTypeIdentifier }); + expect(keptRegistrations).to.have.lengthOf(1); + }); + + context('when there is the last device registration', function () { + it('should also remove device', async function () { + const deviceLibraryIdentifier = 'deviceId'; + const passTypeIdentifier = 'passId'; + const serialNumber = '12345'; + const pushToken = 'push-token'; + const token = await generateAuthorizationToken(); + + await knex('devices').insert({ deviceLibraryIdentifier, pushToken }); + await knex('registrations').insert({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + + const response = await server.inject({ + method: 'DELETE', + url: `/v1/devices/${deviceLibraryIdentifier}/registrations/${passTypeIdentifier}/${serialNumber}`, + headers: { authorization: token }, + }); + + expect(response.statusCode).to.equal(200); + const deletedRegistration = await knex('registrations').where({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + expect(deletedRegistration).to.have.lengthOf(0); + + const deletedDevices = await knex('devices').where({ deviceLibraryIdentifier, pushToken }); + expect(deletedDevices).to.have.lengthOf(0); + }); + }); + }); + + describe('Get Updatable Passes', function () { + context('when it is the first call', function () { + it('should return updatable pass without passesUpdatedSince', async function () { + const deviceLibraryIdentifier = 'deviceId'; + const passTypeIdentifier = 'passId'; + const serialNumber = '12345'; + const pushToken = 'push-token'; + + await knex('devices').insert({ deviceLibraryIdentifier, pushToken }); + await knex('registrations').insert({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + + const response = await server.inject({ + method: 'GET', + url: `/v1/devices/${deviceLibraryIdentifier}/registrations/${passTypeIdentifier}`, + }); + + expect(response.statusCode).to.equal(204); + }); + }); + + context('when it is not the first time', function () { + let now; + let clock; + + beforeEach(function () { + now = new Date('2024-01-01'); + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + }); + + afterEach(function () { + clock.restore(); + }); + + it('should return updatable pass based on passesUpdatedSince', async function () { + const deviceLibraryIdentifier = 'deviceId'; + const passTypeIdentifier = 'passId'; + const serialNumber = '12345'; + const pushToken = 'push-token'; + + await knex('passes').update({ updated_at: new Date('2024-01-02') }).where({ passTypeIdentifier, serialNumber }); + + await knex('devices').insert({ deviceLibraryIdentifier, pushToken }); + await knex('registrations').insert({ deviceLibraryIdentifier, passTypeIdentifier, serialNumber }); + + const passesUpdatedSince = dayjs('2024-01-01').unix(); + + const response = await server.inject({ + method: 'GET', + url: `/v1/devices/${deviceLibraryIdentifier}/registrations/${passTypeIdentifier}?passesUpdatedSince=${passesUpdatedSince}`, + }); + + expect(response.statusCode).to.equal(200); + expect(response.result).to.deep.equal({ serialNumbers: ['12345'], lastUpdated: '1704153600' }); + }); + }); + }); + + describe('Get updated pass', function () { + it('should return updated pass', async function () { + const passTypeIdentifier = 'passId'; + const serialNumber = '12345'; + const token = await generateAuthorizationToken(); + + const nextEvent = '123'; + + await knex('passes').update({ nextEvent }).where({ passTypeIdentifier, serialNumber }); + await knex('reservations').insert({ code: nextEvent, start_at: new Date('2024-01-10'), court: '10', activity: 'Badminton', status: 'reserved', updated_at: new Date('2024-01-02') }); + + const response = await server.inject({ + method: 'GET', + url: `/v1/passes/${passTypeIdentifier}/${serialNumber}`, + headers: { authorization: token }, + }); + + expect(response.statusCode).to.equal(200); + const { 'content-type': contentType, 'last-updated': lastUpdated } = response.headers; + expect(contentType).to.equal('application/vnd.apple.pkpass'); + expect(lastUpdated).to.equal('Tue, 02, Jan, 2024 00:00:00 GMT'); + }); + }); + + describe('Create passe', function () { + context('when next event exists', function () { + let now; + let clock; + + beforeEach(function () { + now = new Date('2024-01-01'); + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + }); + + afterEach(function () { + clock.restore(); + }); + + it('should return pass', async function () { + await knex('reservations').insert({ code: '12345', start_at: new Date('2024-01-10'), court: '10', activity: 'Badminton', status: 'reserved', updated_at: new Date('2024-01-02') }); + + const response = await server.inject({ + method: 'GET', + url: '/pass', + }); + + expect(response.statusCode).to.equal(201); + expect(response.headers['content-type']).to.equal('application/vnd.apple.pkpass'); + }); + }); + + context('when next event does not exist', function () { + it('should return 503', async function () { + const response = await server.inject({ + method: 'GET', + url: '/pass', + }); + + expect(response.statusCode).to.equal(503); + }); + }); + }); +}); diff --git a/tests/test-helpers.js b/tests/test-helpers.js index dadf50f..ed0fad8 100644 --- a/tests/test-helpers.js +++ b/tests/test-helpers.js @@ -2,6 +2,7 @@ import * as chai from 'chai'; import nock from 'nock'; import * as sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { jsonWebTokenService } from '../src/infrastructure/JsonWebTokenService.js'; const expect = chai.expect; chai.use(sinonChai); @@ -17,9 +18,15 @@ afterEach(function () { nock.cleanAll(); }); +async function generateAuthorizationToken() { + const token = await jsonWebTokenService.generateToken({ serialNumber: '123456' }); + return `ApplePass ${token}`; +} + // eslint-disable-next-line mocha/no-exports export { expect, + generateAuthorizationToken, nock, sinon, }; From 1b9e29c1502aa355c0da34217793446393d92f3a Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Sun, 6 Oct 2024 21:48:38 +0200 Subject: [PATCH 21/21] doc: add new step --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ef6f1c9..bc67ca6 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,16 @@ Voici les différentes étapes pour pouvoir préparer correctement sa venue : ![Les différentes étapes de réservation](./docs/étapes-reservation.png) -Le projet est composé actuellement de 4 grandes étapes : +Le projet est composé actuellement de 5 grandes étapes : 1. Vérifier qu'une nouvelle réservation a été demandée sur Gymlib 2. Remplir le formulaire de contremarque UCPA 3. Recevoir une notification dès que l'UCPA a validé les informations avec des créneaux arrangeants qui sont disponibles 4. Créer des évènements dans un calendrier et proposer une url pour s'abonner au calendrier `/reservations/calendar`. +5. Créer un pass Apple Wallet qui se met à jour pour chaque réservation A venir : -1. Créer un pass Apple Wallet pour chaque évènement 2. Me notifier de créneaux qui m'arrangent qui se libèrent La réservation du créneau se fait donc toujours manuellement sur le site de l'UCPA, mais toutes les étapes contraignantes