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/.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 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 diff --git a/config.js b/config.js index 0e54b7b..491b638 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,9 +54,18 @@ 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; + config.secret = 'SECRET_FOR_TESTS'; + config.pass.passTypeIdentifier = 'pass-identifier'; } if (!verifyConfig(config)) { 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, +}; diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..3f41024 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/index.js b/index.js index 430aaba..a202bbf 100644 --- a/index.js +++ b/index.js @@ -1,30 +1,16 @@ import { CronJob } from 'cron'; import { config } from './config.js'; - import { createServer } from './server.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 { SubmitFormUseCase } from './src/domain/usecases/SubmitFormUseCase.js'; -import { Browser } from './src/infrastructure/Browser.js'; -import { CalendarRepository } from './src/infrastructure/CalendarRepository.js'; -import { ImapClient } from './src/infrastructure/ImapClient.js'; +import { passController, reservationController } from './src/application/index.js'; +import { authService } from './src/infrastructure/AuthService.js'; import { logger } from './src/infrastructure/logger.js'; -import { NotificationClient } from './src/infrastructure/NotificationClient.js'; -import { reservationRepository } from './src/infrastructure/ReservationRepository.js'; -import { TimeSlotDatasource } from './src/infrastructure/TimeSlotDatasource.js'; const parisTimezone = 'Europe/Paris'; main(); async function main() { - const reservationController = await getReservationController(); CronJob.from({ cronTime: config.cronTime, onTick: async () => { @@ -33,67 +19,9 @@ async function main() { logger.info('End job'); }, start: true, + runOnInit: true, timeZone: parisTimezone, }); - const server = await createServer({ reservationController }); + 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 }); - - return new ReservationController({ - handleNewReservationUseCase, - getActiveReservationsUseCase, - submitFormUseCase, - notifyUseCase, - handleScheduledReservationUseCase, - createReservationEventsUseCase, - getAllEventsUseCase, - logger, - }); -} +}; diff --git a/model.pass/background.png b/model.pass/background.png new file mode 100644 index 0000000..88e6779 Binary files /dev/null and b/model.pass/background.png differ diff --git a/model.pass/background@2x.png b/model.pass/background@2x.png new file mode 100644 index 0000000..88e6779 Binary files /dev/null and b/model.pass/background@2x.png differ diff --git a/model.pass/icon.png b/model.pass/icon.png new file mode 100644 index 0000000..a7b90c9 Binary files /dev/null and b/model.pass/icon.png differ diff --git a/model.pass/icon@2x.png b/model.pass/icon@2x.png new file mode 100644 index 0000000..9f44f21 Binary files /dev/null and b/model.pass/icon@2x.png differ diff --git a/model.pass/logo.png b/model.pass/logo.png new file mode 100644 index 0000000..9f10645 Binary files /dev/null and b/model.pass/logo.png differ diff --git a/model.pass/logo@2x.png b/model.pass/logo@2x.png new file mode 100644 index 0000000..9f10645 Binary files /dev/null and b/model.pass/logo@2x.png differ 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/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" 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 new file mode 100644 index 0000000..554b2bc --- /dev/null +++ b/src/application/PassController.js @@ -0,0 +1,80 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import { UnableToCreatePassError } from '../domain/Errors.js'; + +dayjs.extend(utc); + +export class PassController { + constructor({ + registerPassUpdateUseCase, + unregisterPassUpdateUseCase, + getUpdatedPassUseCase, + findUpdatablePassesUseCase, + createPassUseCase, + passAdapter, + logger, + }) { + this.registerPassUpdateUseCase = registerPassUpdateUseCase; + this.unregisterPassUpdateUseCase = unregisterPassUpdateUseCase; + this.getUpdatedPassUseCase = getUpdatedPassUseCase; + this.findUpdatablePassesUseCase = findUpdatablePassesUseCase; + this.createPassUseCase = createPassUseCase; + this.passAdapter = passAdapter; + 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); + } + + async unregister(request, h) { + const { deviceLibraryIdentifier, passTypeIdentifier, serialNumber } = request.params; + 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: `${dayjs(lastUpdated).unix()}` }).code(200); + } + + async log(request, h) { + this.logger.error(request.payload.logs); + return h.response().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`); + } + + 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/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/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/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/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/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) { 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/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/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/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, +}; 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/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/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/domain/usecases/passes/RegisterPassUpdateUseCase.js b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js new file mode 100644 index 0000000..f28e20a --- /dev/null +++ b/src/domain/usecases/passes/RegisterPassUpdateUseCase.js @@ -0,0 +1,45 @@ +import { NotFoundError } from '../../Errors.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; + } + } + } +} 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/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); 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/src/infrastructure/DeviceRepository.js b/src/infrastructure/DeviceRepository.js new file mode 100644 index 0000000..6c1850d --- /dev/null +++ b/src/infrastructure/DeviceRepository.js @@ -0,0 +1,28 @@ +import { knex } from '../../db/knex-database-connection.js'; +import { NotFoundError } from '../domain/Errors.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/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 }); diff --git a/src/infrastructure/JsonWebTokenService.js b/src/infrastructure/JsonWebTokenService.js new file mode 100644 index 0000000..9f660cf --- /dev/null +++ b/src/infrastructure/JsonWebTokenService.js @@ -0,0 +1,46 @@ +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); 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 new file mode 100644 index 0000000..bcc83a3 --- /dev/null +++ b/src/infrastructure/PassInterface.js @@ -0,0 +1,79 @@ +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 + return this.passController.register(request, h); + }, + 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 + return this.passController.findUpdatable(request, h); + }, + 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 + return this.passController.getUpdated(request, h); + }, + 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 + return this.passController.unregister(request, h); + }, + 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'], + }, + }, + ]); + } +} diff --git a/src/infrastructure/PassRepository.js b/src/infrastructure/PassRepository.js new file mode 100644 index 0000000..a662ae5 --- /dev/null +++ b/src/infrastructure/PassRepository.js @@ -0,0 +1,37 @@ +import { knex } from '../../db/knex-database-connection.js'; +import { NotFoundError } from '../domain/Errors.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..6d60314 --- /dev/null +++ b/src/infrastructure/RegistrationRepository.js @@ -0,0 +1,40 @@ +import { knex } from '../../db/knex-database-connection.js'; +import { NotFoundError } from '../domain/Errors.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..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 { @@ -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, 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]; 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, };