From d6fa43038fc580f4f9005e2fb231f90bfead9a17 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Tue, 24 Sep 2024 21:48:58 +0200 Subject: [PATCH 01/16] feat: add reservation statuses for more details --- src/domain/Reservation.js | 6 ++++-- src/domain/usecases/NotifyUseCase.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/domain/Reservation.js b/src/domain/Reservation.js index 78498ca..5fb0670 100644 --- a/src/domain/Reservation.js +++ b/src/domain/Reservation.js @@ -4,6 +4,8 @@ const STATUSES = { FORM_SUBMITTED: 'form-submitted', FORM_ERROR: 'form-error', UCPA_VALIDATED: 'validated', + NOTIFIED: 'notified', + RESERVED: 'reserved', COMPLETED: 'completed', }; @@ -38,8 +40,8 @@ class Reservation { this.status = Reservation.STATUSES.UCPA_VALIDATED; } - markAsCompleted() { - this.status = Reservation.STATUSES.COMPLETED; + markAsNotified() { + this.status = Reservation.STATUSES.NOTIFIED; } } diff --git a/src/domain/usecases/NotifyUseCase.js b/src/domain/usecases/NotifyUseCase.js index 1e9debd..fb75525 100644 --- a/src/domain/usecases/NotifyUseCase.js +++ b/src/domain/usecases/NotifyUseCase.js @@ -24,7 +24,7 @@ export class NotifyUseCase { const timeSlots = await this.timeSlotDatasource.getAllAvailable(this.areaId); const convenientTimeSlots = this._getConvientTimeSlots(timeSlots, this.timeSlotsPreferences); await this.notificationClient.notify(convenientTimeSlots); - reservation.markAsCompleted(); + reservation.markAsNotified(); this.reservationRepositories.save(reservation); } From 9737004f25e9ea92ea7553ce94dfc6d8273a8790 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Tue, 24 Sep 2024 22:12:04 +0200 Subject: [PATCH 02/16] feat(reservation): add new attributes to reservation --- ...924193919_add_start_at_and_court_column.js | 28 +++++++++++++++++++ src/domain/Reservation.js | 5 +++- src/infrastructure/ReservationRepositories.js | 16 +++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 db/migrations/20240924193919_add_start_at_and_court_column.js diff --git a/db/migrations/20240924193919_add_start_at_and_court_column.js b/db/migrations/20240924193919_add_start_at_and_court_column.js new file mode 100644 index 0000000..dd182c2 --- /dev/null +++ b/db/migrations/20240924193919_add_start_at_and_court_column.js @@ -0,0 +1,28 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +const up = async function (knex) { + await knex.schema.alterTable('reservations', (table) => { + table.dateTime('start_at').nullable(); + table.string('court').nullable(); + table.string('activity').nullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +const down = async function (knex) { + await knex.schema.alterTable('reservations', (table) => { + table.dropColumn('start_at'); + table.dropColumn('court'); + table.dropColumn('activity'); + }); +}; + +export { + down, + up, +}; diff --git a/src/domain/Reservation.js b/src/domain/Reservation.js index 5fb0670..10804d7 100644 --- a/src/domain/Reservation.js +++ b/src/domain/Reservation.js @@ -10,9 +10,12 @@ const STATUSES = { }; class Reservation { - constructor({ code, status, updatedAt }) { + constructor({ code, status, start, court, activity, updatedAt }) { this.code = code; this.status = status; + this.start = start; + this.court = court; + this.activity = activity; this.updatedAt = updatedAt; } diff --git a/src/infrastructure/ReservationRepositories.js b/src/infrastructure/ReservationRepositories.js index 17e4372..20738ad 100644 --- a/src/infrastructure/ReservationRepositories.js +++ b/src/infrastructure/ReservationRepositories.js @@ -17,8 +17,11 @@ class ReservationRepositories { } async save(reservation) { - const { code, status, updatedAt } = reservation; - await this.#knex('reservations').insert({ code, status, updated_at: updatedAt }).onConflict('code').merge(['status', 'updated_at']); + const { code, status, updatedAt, start, court, activity } = reservation; + await this.#knex('reservations') + .insert({ code, status, updated_at: updatedAt, start_at: start, court, activity }) + .onConflict('code') + .merge(['status', 'updated_at', 'start_at', 'court', 'activity']); } async getActiveReservations() { @@ -30,7 +33,14 @@ class ReservationRepositories { } _toDomain(reservationRaw) { - return new Reservation({ code: reservationRaw.code, status: reservationRaw.status, updatedAt: reservationRaw.updated_at }); + return new Reservation({ + code: reservationRaw.code, + status: reservationRaw.status, + updatedAt: reservationRaw.updated_at, + start: reservationRaw.start_at, + court: reservationRaw.court, + activity: reservationRaw.activity, + }); } } From 4e339f193ffba1142aa205cfa98d86e113732802 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 09:03:31 +0200 Subject: [PATCH 03/16] feat(reservation): allow retrieve by status --- src/infrastructure/ReservationRepositories.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/infrastructure/ReservationRepositories.js b/src/infrastructure/ReservationRepositories.js index 20738ad..b8988e4 100644 --- a/src/infrastructure/ReservationRepositories.js +++ b/src/infrastructure/ReservationRepositories.js @@ -32,6 +32,14 @@ class ReservationRepositories { return reservations.map(reservation => this._toDomain(reservation)); } + async findByStatus(status) { + const reservations = await this.#knex('reservations') + .select('*') + .where('status', '=', status) + .orderBy('created_at', 'asc'); + return reservations.map(reservation => this._toDomain(reservation)); + } + _toDomain(reservationRaw) { return new Reservation({ code: reservationRaw.code, From eeb4f71797495cf0387346e36a9cb1734de5249d Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 09:04:10 +0200 Subject: [PATCH 04/16] fix(env): typo on env names --- config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.js b/config.js index 1f41aa3..f5a3246 100644 --- a/config.js +++ b/config.js @@ -36,8 +36,8 @@ function buildConfiguration() { areaId: env.UCPA_AREA_ID, }, notification: { - url: env.NOTFICATION_URL, - token: env.NOTFICATION_TOKEN, + url: env.NOTIFICATION_URL, + token: env.NOTIFICATION_TOKEN, }, timeSlotsPreferences: JSON.parse(env.TIME_SLOTS_PREFERENCES), }; From 73195c1678a6ea38dd9669c256152672f5323ac6 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 18:08:09 +0200 Subject: [PATCH 05/16] refactor(reservation): remove plurial for repository --- index.js | 8 ++++---- src/domain/usecases/GetActiveReservationsUseCase.js | 6 +++--- src/domain/usecases/HandleNewReservationUseCase.js | 6 +++--- src/domain/usecases/NotifyUseCase.js | 8 ++++---- src/domain/usecases/SubmitFormUseCase.js | 6 +++--- ...eservationRepositories.js => ReservationRepository.js} | 4 ++-- 6 files changed, 19 insertions(+), 19 deletions(-) rename src/infrastructure/{ReservationRepositories.js => ReservationRepository.js} (94%) diff --git a/index.js b/index.js index 422b17a..e2e9245 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,7 @@ import { Browser } from './src/infrastructure/Browser.js'; import { ImapClient } from './src/infrastructure/ImapClient.js'; import { logger } from './src/infrastructure/logger.js'; import { NotificationClient } from './src/infrastructure/NotificationClient.js'; -import { reservationRepositories } from './src/infrastructure/ReservationRepositories.js'; +import { reservationRepository } from './src/infrastructure/ReservationRepository.js'; import { TimeSlotDatasource } from './src/infrastructure/TimeSlotDatasource.js'; const parisTimezone = 'Europe/Paris'; @@ -40,13 +40,13 @@ async function getReservationController() { }); const getActiveReservationsUseCase = new GetActiveReservationsUseCase({ - reservationRepositories, + reservationRepository, }); const browser = await Browser.create(); const submitFormUseCase = new SubmitFormUseCase({ browser, - reservationRepositories, + reservationRepository, formInfo: config.ucpa.formInfo, dryRun: !config.ucpa.formSubmit, }); @@ -57,7 +57,7 @@ async function getReservationController() { const notifyUseCase = new NotifyUseCase({ imapClient: ucpaImapClient, searchQuery: config.ucpa.searchQuery, - reservationRepositories, + reservationRepository, timeSlotDatasource, notificationClient, timeSlotsPreferences: config.timeSlotsPreferences, diff --git a/src/domain/usecases/GetActiveReservationsUseCase.js b/src/domain/usecases/GetActiveReservationsUseCase.js index 7804dd0..e705fc2 100644 --- a/src/domain/usecases/GetActiveReservationsUseCase.js +++ b/src/domain/usecases/GetActiveReservationsUseCase.js @@ -1,9 +1,9 @@ export class GetActiveReservationsUseCase { - constructor({ reservationRepositories }) { - this.reservationRepositories = reservationRepositories; + constructor({ reservationRepository }) { + this.reservationRepository = reservationRepository; } async execute() { - return this.reservationRepositories.getActiveReservations(); + return this.reservationRepository.getActiveReservations(); } } diff --git a/src/domain/usecases/HandleNewReservationUseCase.js b/src/domain/usecases/HandleNewReservationUseCase.js index bc430d7..a1d68f8 100644 --- a/src/domain/usecases/HandleNewReservationUseCase.js +++ b/src/domain/usecases/HandleNewReservationUseCase.js @@ -1,4 +1,4 @@ -import { reservationRepositories } from '../../infrastructure/ReservationRepositories.js'; +import { reservationRepository } from '../../infrastructure/ReservationRepository.js'; import { NotFoundError } from '../NotFoundError.js'; import { Reservation } from '../Reservation.js'; @@ -29,12 +29,12 @@ export class HandleNewReservationUseCase { async _createReservationIfNotExists(code) { try { - await reservationRepositories.get(code); + await reservationRepository.get(code); } catch (e) { if (e instanceof NotFoundError) { const reservation = Reservation.createFromCode(code); - await reservationRepositories.save(reservation); + await reservationRepository.save(reservation); return; } throw e; diff --git a/src/domain/usecases/NotifyUseCase.js b/src/domain/usecases/NotifyUseCase.js index fb75525..0ccf306 100644 --- a/src/domain/usecases/NotifyUseCase.js +++ b/src/domain/usecases/NotifyUseCase.js @@ -2,10 +2,10 @@ const VALIDATION_SUBJECT = 'UCPA Contremarque'; const VALIDATION_TEXT = 'Tu peux dès à présent retrouver ton e-billet sur le site internet de ton centre'; export class NotifyUseCase { - constructor({ imapClient, searchQuery, reservationRepositories, timeSlotDatasource, notificationClient, timeSlotsPreferences, areaId }) { + constructor({ imapClient, searchQuery, reservationRepository, timeSlotDatasource, notificationClient, timeSlotsPreferences, areaId }) { this.imapClient = imapClient; this.searchQuery = searchQuery; - this.reservationRepositories = reservationRepositories; + this.reservationRepository = reservationRepository; this.timeSlotDatasource = timeSlotDatasource; this.notificationClient = notificationClient; this.timeSlotsPreferences = timeSlotsPreferences; @@ -14,7 +14,7 @@ export class NotifyUseCase { async execute(reservation) { if (reservation.isRequiresValidation) { - await this._verifyValidation(reservation, this.reservationRepositories, this.imapClient); + await this._verifyValidation(reservation, this.reservationRepository, this.imapClient); } if (!reservation.isValidated) { @@ -25,7 +25,7 @@ export class NotifyUseCase { const convenientTimeSlots = this._getConvientTimeSlots(timeSlots, this.timeSlotsPreferences); await this.notificationClient.notify(convenientTimeSlots); reservation.markAsNotified(); - this.reservationRepositories.save(reservation); + await this.reservationRepository.save(reservation); } async _verifyValidation(reservation, reservationRepositories, imapClient) { diff --git a/src/domain/usecases/SubmitFormUseCase.js b/src/domain/usecases/SubmitFormUseCase.js index 1129ee0..96d042c 100644 --- a/src/domain/usecases/SubmitFormUseCase.js +++ b/src/domain/usecases/SubmitFormUseCase.js @@ -1,9 +1,9 @@ const CONTREMARQUE_FORM_URL = 'https://sphinx.ucpa.com/surveyserver/s/ucpa/CONTREMARQUE/Gymlib.htm'; export class SubmitFormUseCase { - constructor({ browser, reservationRepositories, formInfo, dryRun }) { + constructor({ browser, reservationRepository, formInfo, dryRun }) { this.browser = browser; - this.reservationRepositories = reservationRepositories; + this.reservationRepository = reservationRepository; this.formInfo = formInfo; this.dryRun = dryRun; } @@ -29,6 +29,6 @@ export class SubmitFormUseCase { await this.browser.browser.close(); reservation.markAsSubmitted(); - await this.reservationRepositories.save(reservation); + await this.reservationRepository.save(reservation); } } diff --git a/src/infrastructure/ReservationRepositories.js b/src/infrastructure/ReservationRepository.js similarity index 94% rename from src/infrastructure/ReservationRepositories.js rename to src/infrastructure/ReservationRepository.js index b8988e4..565790c 100644 --- a/src/infrastructure/ReservationRepositories.js +++ b/src/infrastructure/ReservationRepository.js @@ -2,7 +2,7 @@ import { knex } from '../../db/knex-database-connection.js'; import { NotFoundError } from '../domain/NotFoundError.js'; import { Reservation } from '../domain/Reservation.js'; -class ReservationRepositories { +class ReservationRepository { #knex; constructor(knex) { @@ -52,4 +52,4 @@ class ReservationRepositories { } } -export const reservationRepositories = new ReservationRepositories(knex); +export const reservationRepository = new ReservationRepository(knex); From d536204a5d065f3f55fa171d4327eafc6a77ad38 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 18:08:40 +0200 Subject: [PATCH 06/16] test: add missing tests for ReservationRepository --- .../ReservationRepository_tests.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/integration/infrastructure/ReservationRepository_tests.js diff --git a/tests/integration/infrastructure/ReservationRepository_tests.js b/tests/integration/infrastructure/ReservationRepository_tests.js new file mode 100644 index 0000000..7d1c139 --- /dev/null +++ b/tests/integration/infrastructure/ReservationRepository_tests.js @@ -0,0 +1,46 @@ +import { knex } from '../../../db/knex-database-connection.js'; +import { Reservation } from '../../../src/domain/Reservation.js'; +import { reservationRepository } from '../../../src/infrastructure/ReservationRepository.js'; +import { expect, sinon } from '../../test-helpers.js'; + +describe('Integration | Infrastructure | ReservationRepository', function () { + describe('#save', function () { + let clock; + const now = new Date('2024-01-01'); + + beforeEach(async function () { + await knex('reservations').delete(); + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + }); + + afterEach(async function () { + await knex('reservations').delete(); + clock.restore(); + }); + + it('should save reservation', async function () { + const code = 'ABCD123'; + const reservation = Reservation.createFromCode(code); + + await reservationRepository.save(reservation); + + const { created_at, updated_at, ...reservations } = await knex('reservations').where({ code }).first(); + expect(reservations).to.deep.equal({ code, status: reservation.status, court: null, start_at: null, activity: null }); + }); + + context('when reservation already exists', function () { + it('should update', async function () { + const code = 'ABCD123'; + const reservation = Reservation.createFromCode(code); + + await reservationRepository.save(reservation); + + reservation.markAsReserved({ start: new Date('2024-10-10'), court: '10', activity: 'Badminton' }); + await reservationRepository.save(reservation); + + const { created_at, updated_at, ...reservations } = await knex('reservations').where({ code }).first(); + expect(reservations).to.deep.equal({ code, status: reservation.status, court: '10', start_at: new Date('2024-10-10'), activity: 'Badminton' }); + }); + }); + }); +}); From f5e24c44357b4485e22d4a292312a2a9a733ebe6 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 18:10:17 +0200 Subject: [PATCH 07/16] fix: update updatedAt when status change --- src/domain/Reservation.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/domain/Reservation.js b/src/domain/Reservation.js index 10804d7..5ac31a6 100644 --- a/src/domain/Reservation.js +++ b/src/domain/Reservation.js @@ -37,14 +37,17 @@ class Reservation { markAsSubmitted() { this.status = Reservation.STATUSES.FORM_SUBMITTED; + this.updatedAt = new Date(); } markAsValidated() { this.status = Reservation.STATUSES.UCPA_VALIDATED; + this.updatedAt = new Date(); } markAsNotified() { this.status = Reservation.STATUSES.NOTIFIED; + this.updatedAt = new Date(); } } From 7287c210094a1804e22c0ff1fb3458697374c072 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 18:13:45 +0200 Subject: [PATCH 08/16] feat: add handle scheduled reservation use-case --- index.js | 8 +++ src/application/ReservationController.js | 7 ++- src/domain/Reservation.js | 8 +++ .../HandleScheduledReservationUseCase.js | 62 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/domain/usecases/HandleScheduledReservationUseCase.js diff --git a/index.js b/index.js index e2e9245..f9acdd6 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ import { config } from './config.js'; import { ReservationController } from './src/application/ReservationController.js'; import { GetActiveReservationsUseCase } from './src/domain/usecases/GetActiveReservationsUseCase.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'; @@ -64,11 +65,18 @@ async function getReservationController() { areaId: config.ucpa.areaId, }); + const handleScheduledReservationUseCase = new HandleScheduledReservationUseCase({ + imapClient: ucpaImapClient, + searchQuery: config.ucpa.searchQuery, + reservationRepository, + }); + return new ReservationController({ handleNewReservationUseCase, getActiveReservationsUseCase, submitFormUseCase, notifyUseCase, + handleScheduledReservationUseCase, logger, }); } diff --git a/src/application/ReservationController.js b/src/application/ReservationController.js index cd0bdb9..3bf9ec2 100644 --- a/src/application/ReservationController.js +++ b/src/application/ReservationController.js @@ -1,9 +1,10 @@ export class ReservationController { - constructor({ handleNewReservationUseCase, getActiveReservationsUseCase, submitFormUseCase, notifyUseCase, logger }) { + constructor({ handleNewReservationUseCase, getActiveReservationsUseCase, submitFormUseCase, notifyUseCase, handleScheduledReservationUseCase, logger }) { this.handleNewReservationUseCase = handleNewReservationUseCase; this.getActiveReservationsUseCase = getActiveReservationsUseCase; this.submitFormUseCase = submitFormUseCase; this.notifyUseCase = notifyUseCase; + this.handleScheduledReservationUseCase = handleScheduledReservationUseCase; this.logger = logger; } @@ -29,5 +30,9 @@ export class ReservationController { await this.notifyUseCase.execute(submittedReservation); this.logger.info(`End - Notify for ${submittedReservation.code}`); } + + this.logger.info('Start - HandleScheduledReservations'); + await this.handleScheduledReservationUseCase.execute(); + this.logger.info('End - HandleScheduledReservations'); } } diff --git a/src/domain/Reservation.js b/src/domain/Reservation.js index 5ac31a6..c7e76d7 100644 --- a/src/domain/Reservation.js +++ b/src/domain/Reservation.js @@ -49,6 +49,14 @@ class Reservation { this.status = Reservation.STATUSES.NOTIFIED; this.updatedAt = new Date(); } + + markAsReserved({ start, court, activity }) { + this.start = start; + this.court = court; + this.activity = activity; + this.status = Reservation.STATUSES.RESERVED; + this.updatedAt = new Date(); + } } Reservation.STATUSES = STATUSES; diff --git a/src/domain/usecases/HandleScheduledReservationUseCase.js b/src/domain/usecases/HandleScheduledReservationUseCase.js new file mode 100644 index 0000000..76409ba --- /dev/null +++ b/src/domain/usecases/HandleScheduledReservationUseCase.js @@ -0,0 +1,62 @@ +import { NotFoundError } from '../NotFoundError.js'; +import { Reservation } from '../Reservation.js'; + +export class HandleScheduledReservationUseCase { + constructor({ imapClient, searchQuery, reservationRepository }) { + this.imapClient = imapClient; + this.searchQuery = searchQuery; + this.reservationRepository = reservationRepository; + } + + async execute() { + const messages = await this.imapClient.fetch(this.searchQuery); + for (const message of messages) { + const isScheduledReservationMessage = message.html.includes('MERCI POUR VOTRE RESERVATION !'); + if (!isScheduledReservationMessage) { + continue; + } + const resaInformation = this._getInformation(message); + const resa = await this._getReservationOrCreate(resaInformation.code); + // here we don't check if it's already in reserved status, so we can change the slot. + resa.markAsReserved({ start: resaInformation.start, court: resaInformation.court, activity: resaInformation.activity }); + await this.reservationRepository.save(resa); + } + } + + _getInformation(message) { + const match = message.html.match(/Terrain (?\d+) (?\w+)\s\w+ le (?\d{2}-\d{2}-\d{4}) à (?\d{2}:\d{2})/); + if (!match) { + return null; + } + + const matchCode = message.html.match(/

(?\d+)<\/p>/); + if (!matchCode) { + return null; + } + + const [day, month, year] = match.groups.date.split('-'); + const formattedDate = `${year}-${month}-${day}`; + + return { + code: matchCode.groups.code, + court: match.groups.court, + activity: match.groups.activity, + start: new Date(`${formattedDate}T${match.groups.hour}:00`), + }; + } + + async _getReservationOrCreate(code) { + try { + return await this.reservationRepository.get(code); + } + catch (e) { + if (e instanceof NotFoundError) { + // We create a reservation in the event that not all the initial steps have been taken by this application. + const reservation = Reservation.createFromCode(code); + await this.reservationRepository.save(reservation); + return reservation; + } + throw e; + } + } +} From f20bc0dfb15fdacadf89daf01f20e10e51738c39 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 18:45:11 +0200 Subject: [PATCH 09/16] feat(calendar): add repository --- package-lock.json | 62 +++++++++- package.json | 1 + src/domain/ReservationEvent.js | 17 +++ src/infrastructure/CalendarRepository.js | 42 +++++++ .../CalendarRepository_tests.js | 109 ++++++++++++++++++ 5 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 src/domain/ReservationEvent.js create mode 100644 src/infrastructure/CalendarRepository.js create mode 100644 tests/integration/infrastructure/CalendarRepository_tests.js diff --git a/package-lock.json b/package-lock.json index 3cfd49d..5889b97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,11 +5,12 @@ "requires": true, "packages": { "": { - "name": "ucpa-facilitator", + "name": "gymlib-ucpa-facilitator", "version": "0.0.0", - "license": "ISC", + "license": "AGPL-3.0", "dependencies": { "cron": "^3.1.7", + "ical-generator": "^8.0.0", "imapflow": "^1.0.164", "knex": "^3.1.0", "mailparser": "^3.7.1", @@ -3634,6 +3635,57 @@ "node": ">= 14" } }, + "node_modules/ical-generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-8.0.0.tgz", + "integrity": "sha512-CvVKK3JJrKop6z7i7/NS69FjYdR6ux1LswIeiZ3yaicX7ocMFLQI475JhQabCT7koUkaCVFNVxLaYaxtdPgwww==", + "license": "MIT", + "dependencies": { + "uuid-random": "^1.3.2" + }, + "engines": { + "node": "18 || 20 || >=22.0.0" + }, + "peerDependencies": { + "@touch4it/ical-timezones": ">=1.6.0", + "@types/luxon": ">= 1.26.0", + "@types/mocha": ">= 8.2.1", + "dayjs": ">= 1.10.0", + "luxon": ">= 1.26.0", + "moment": ">= 2.29.0", + "moment-timezone": ">= 0.5.33", + "rrule": ">= 2.6.8" + }, + "peerDependenciesMeta": { + "@touch4it/ical-timezones": { + "optional": true + }, + "@types/luxon": { + "optional": true + }, + "@types/mocha": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-timezone": { + "optional": true + }, + "rrule": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7422,6 +7474,12 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid-random": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/uuid-random/-/uuid-random-1.3.2.tgz", + "integrity": "sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==", + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/package.json b/package.json index 9821988..ecdb3a9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "cron": "^3.1.7", + "ical-generator": "^8.0.0", "imapflow": "^1.0.164", "knex": "^3.1.0", "mailparser": "^3.7.1", diff --git a/src/domain/ReservationEvent.js b/src/domain/ReservationEvent.js new file mode 100644 index 0000000..c864a37 --- /dev/null +++ b/src/domain/ReservationEvent.js @@ -0,0 +1,17 @@ +export class ReservationEvent { + constructor({ code, name, description, start, end }) { + this.code = code; + this.name = name; + this.description = description; + this.start = start; + this.end = end; + } + + static fromReservation(reservation) { + const { code, start, activity, court } = reservation; + const name = `${activity} - Terrain ${court}`; + const end = new Date(new Date(start).setHours(start.getHours() + 1)); + const description = `Code: ${code}`; + return new ReservationEvent({ code, name, start, end, description }); + } +} diff --git a/src/infrastructure/CalendarRepository.js b/src/infrastructure/CalendarRepository.js new file mode 100644 index 0000000..47e1b52 --- /dev/null +++ b/src/infrastructure/CalendarRepository.js @@ -0,0 +1,42 @@ +import { ICalCalendar } from 'ical-generator'; +import { ReservationEvent } from '../domain/ReservationEvent.js'; + +export class CalendarRepository { + constructor(name) { + this.cal = new ICalCalendar({ name }); + } + + save(reservationEvent) { + const { start, end, name: summary, description } = reservationEvent; + this.cal.createEvent({ + start, + end, + summary, + description, + }); + } + + getAll() { + return this.cal.toJSON().events.map(({ summary, description, start, end }) => { + return new ReservationEvent({ + code: _getCodeFromDescription(description), + name: summary, + description: description.plain, + start, + end, + }); + }); + } + + getAllForSubscription() { + return this.cal.toString(); + } +} + +function _getCodeFromDescription(description) { + const match = description.plain.match(/Code: (?\d+)/); + if (!match) { + return null; + } + return match.groups.code; +} diff --git a/tests/integration/infrastructure/CalendarRepository_tests.js b/tests/integration/infrastructure/CalendarRepository_tests.js new file mode 100644 index 0000000..c4ce525 --- /dev/null +++ b/tests/integration/infrastructure/CalendarRepository_tests.js @@ -0,0 +1,109 @@ +import { ReservationEvent } from '../../../src/domain/ReservationEvent.js'; +import { CalendarRepository } from '../../../src/infrastructure/CalendarRepository.js'; +import { expect, sinon } from '../../test-helpers.js'; + +describe('Integration | Infrastructure | CalendarRepository', function () { + let clock; + + beforeEach(function () { + const now = new Date('2024-09-24'); + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + }); + + afterEach(function () { + clock.restore(); + }); + + describe('#save', function () { + it('should save event from ReservationEvent', function () { + const calRepositories = new CalendarRepository('test'); + const start = new Date(); + const end = new Date(new Date().setHours(new Date().getHours() + 1)); + const event = new ReservationEvent({ + start, + end, + name: 'Test Event', + description: 'It works ;)', + }); + + calRepositories.save(event); + + const { id, ...savedEvent } = calRepositories.cal.toJSON().events[0]; + expect(savedEvent).deep.equal( + { + alarms: [], + allDay: false, + attachments: [], + attendees: [], + busystatus: null, + categories: [], + class: null, + created: null, + description: { + plain: 'It works ;)', + }, + end: '2024-09-24T01:00:00.000Z', + floating: false, + lastModified: null, + location: null, + organizer: null, + priority: null, + recurrenceId: null, + repeating: null, + sequence: 0, + stamp: '2024-09-24T00:00:00.000Z', + start: '2024-09-24T00:00:00.000Z', + status: null, + summary: 'Test Event', + timezone: null, + transparency: null, + url: null, + x: [], + }, + ); + }); + }); + + describe('#getAllEvents', function () { + it('should return all saved events', function () { + const calRepositories = new CalendarRepository('test'); + const start = new Date(); + const end = new Date(new Date().setHours(new Date().getHours() + 1)); + const event = new ReservationEvent({ + start, + end, + name: 'Test Event', + description: 'It works ;) \n\n Code: 12345', + }); + const event2 = new ReservationEvent({ + start, + end, + name: 'Test Event 2', + description: 'It works ;) \n\n Code: 56789', + }); + + calRepositories.save(event); + calRepositories.save(event2); + + const events = calRepositories.getAll(); + + expect(events[0]).to.be.instanceOf(ReservationEvent); + expect(events).to.deep.equal([ + { + end: '2024-09-24T01:00:00.000Z', + code: '12345', + name: 'Test Event', + start: '2024-09-24T00:00:00.000Z', + description: 'It works ;) \n\n Code: 12345', + }, + { + end: '2024-09-24T01:00:00.000Z', + code: '56789', + name: 'Test Event 2', + start: '2024-09-24T00:00:00.000Z', + description: 'It works ;) \n\n Code: 56789', + }, + ]); + }); + }); +}); From 3add0bb583a16ba44352f1fef8c5e2c939d6af4f Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 18:45:30 +0200 Subject: [PATCH 10/16] feat(calendar): add use-case --- .../CreateReservationEventsUseCase.js | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/domain/usecases/CreateReservationEventsUseCase.js diff --git a/src/domain/usecases/CreateReservationEventsUseCase.js b/src/domain/usecases/CreateReservationEventsUseCase.js new file mode 100644 index 0000000..dd4351d --- /dev/null +++ b/src/domain/usecases/CreateReservationEventsUseCase.js @@ -0,0 +1,22 @@ +import { Reservation } from '../Reservation.js'; +import { ReservationEvent } from '../ReservationEvent.js'; + +export class CreateReservationEventsUseCase { + constructor({ reservationRepository, calendarRepository }) { + this.reservationRepository = reservationRepository; + this.calendarRepository = calendarRepository; + } + + async execute() { + const reservations = await this.reservationRepository.findByStatus(Reservation.STATUSES.RESERVED); + const reservationEvents = this.calendarRepository.getAll(); + const reservationEventCodes = reservationEvents.map(({ code }) => code); + const missingEvents = reservations + .filter(({ code }) => !reservationEventCodes.includes(code)) + .map(reservation => ReservationEvent.fromReservation(reservation)); + + for (const missingEvent of missingEvents) { + this.calendarRepository.save(missingEvent); + } + } +} From 1f27bfa1e4baed8194e9d2b8483cc9f6693cbeb3 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 19:12:29 +0200 Subject: [PATCH 11/16] feat(calendar): add use-case for calendar subscription --- src/domain/usecases/GetAllEventsUseCase.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/domain/usecases/GetAllEventsUseCase.js diff --git a/src/domain/usecases/GetAllEventsUseCase.js b/src/domain/usecases/GetAllEventsUseCase.js new file mode 100644 index 0000000..1a8fa56 --- /dev/null +++ b/src/domain/usecases/GetAllEventsUseCase.js @@ -0,0 +1,9 @@ +export class GetAllEventsUseCase { + constructor({ calendarRepository }) { + this.calendarRepository = calendarRepository; + } + + async execute() { + return this.calendarRepository.getAllForSubscription(); + } +} From 44454764cad4f1ba97bd90516dc347d91441d33e Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 21:59:31 +0200 Subject: [PATCH 12/16] feat(logger): replace by pino --- package-lock.json | 369 ++++++++++++++++++++++++++++++++++- package.json | 2 + sample.env | 5 +- src/infrastructure/Logger.js | 22 +-- 4 files changed, 379 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5889b97..f2936b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.0.0", "license": "AGPL-3.0", "dependencies": { + "@hapi/hapi": "^21.3.10", "cron": "^3.1.7", "ical-generator": "^8.0.0", "imapflow": "^1.0.164", "knex": "^3.1.0", "mailparser": "^3.7.1", "pg": "^8.13.0", + "pino": "^9.4.0", "puppeteer": "^23.4.0" }, "devDependencies": { @@ -608,6 +610,322 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hapi/accept": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", + "integrity": "sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/ammo": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/ammo/-/ammo-6.0.1.tgz", + "integrity": "sha512-pmL+nPod4g58kXrMcsGLp05O2jF4P2Q3GiL8qYV7nKYEh3cGf+rV4P5Jyi2Uq0agGhVU63GtaSAfBEZOlrJn9w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/b64": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz", + "integrity": "sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bounce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-3.0.1.tgz", + "integrity": "sha512-G+/Pp9c1Ha4FDP+3Sy/Xwg2O4Ahaw3lIZFSX+BL4uWi64CmiETuZPxhKDUD4xBMOUZbBlzvO8HjiK8ePnhBadA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/call": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@hapi/call/-/call-9.0.1.tgz", + "integrity": "sha512-uPojQRqEL1GRZR4xXPqcLMujQGaEpyVPRyBlD8Pp5rqgIwLhtveF9PkixiKru2THXvuN8mUrLeet5fqxKAAMGg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/catbox": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@hapi/catbox/-/catbox-12.1.1.tgz", + "integrity": "sha512-hDqYB1J+R0HtZg4iPH3LEnldoaBsar6bYp0EonBmNQ9t5CO+1CqgCul2ZtFveW1ReA5SQuze9GPSU7/aecERhw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/podium": "^5.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/catbox-memory": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/catbox-memory/-/catbox-memory-6.0.2.tgz", + "integrity": "sha512-H1l4ugoFW/ZRkqeFrIo8p1rWN0PA4MDTfu4JmcoNDvnY975o29mqoZblqFTotxNHlEkMPpIiIBJTV+Mbi+aF0g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/content": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@hapi/content/-/content-6.0.0.tgz", + "integrity": "sha512-CEhs7j+H0iQffKfe5Htdak5LBOz/Qc8TRh51cF+BFv0qnuph3Em4pjGVzJMkI2gfTDdlJKWJISGWS1rK34POGA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.0" + } + }, + "node_modules/@hapi/cryptiles": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.1.tgz", + "integrity": "sha512-9GM9ECEHfR8lk5ASOKG4+4ZsEzFqLfhiryIJ2ISePVB92OHLp/yne4m+zn7z9dgvM98TLpiFebjDFQ0UHcqxXQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/file": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/file/-/file-3.0.0.tgz", + "integrity": "sha512-w+lKW+yRrLhJu620jT3y+5g2mHqnKfepreykvdOcl9/6up8GrQQn+l3FRTsjHTKbkbfQFkuksHpdv2EcpKcJ4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hapi": { + "version": "21.3.10", + "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-21.3.10.tgz", + "integrity": "sha512-CmEcmTREW394MaGGKvWpoOK4rG8tKlpZLs30tbaBzhCrhiL2Ti/HARek9w+8Ya4nMBGcd+kDAzvU44OX8Ms0Jg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/accept": "^6.0.1", + "@hapi/ammo": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.1", + "@hapi/call": "^9.0.1", + "@hapi/catbox": "^12.1.1", + "@hapi/catbox-memory": "^6.0.2", + "@hapi/heavy": "^8.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/mimos": "^7.0.1", + "@hapi/podium": "^5.0.1", + "@hapi/shot": "^6.0.1", + "@hapi/somever": "^4.1.1", + "@hapi/statehood": "^8.1.1", + "@hapi/subtext": "^8.1.0", + "@hapi/teamwork": "^6.0.0", + "@hapi/topo": "^6.0.1", + "@hapi/validate": "^2.0.1" + }, + "engines": { + "node": ">=14.15.0" + } + }, + "node_modules/@hapi/heavy": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@hapi/heavy/-/heavy-8.0.1.tgz", + "integrity": "sha512-gBD/NANosNCOp6RsYTsjo2vhr5eYA3BEuogk6cxY0QdhllkkTaJFYtTXv46xd6qhBVMbMMqcSdtqey+UQU3//w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.4.tgz", + "integrity": "sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/iron": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-7.0.1.tgz", + "integrity": "sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/mimos": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/mimos/-/mimos-7.0.1.tgz", + "integrity": "sha512-b79V+BrG0gJ9zcRx1VGcCI6r6GEzzZUgiGEJVoq5gwzuB2Ig9Cax8dUuBauQCFKvl2YWSWyOc8mZ8HDaJOtkew==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "mime-db": "^1.52.0" + } + }, + "node_modules/@hapi/nigel": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/nigel/-/nigel-5.0.1.tgz", + "integrity": "sha512-uv3dtYuB4IsNaha+tigWmN8mQw/O9Qzl5U26Gm4ZcJVtDdB1AVJOwX3X5wOX+A07qzpEZnOMBAm8jjSqGsU6Nw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/vise": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/pez": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@hapi/pez/-/pez-6.1.0.tgz", + "integrity": "sha512-+FE3sFPYuXCpuVeHQ/Qag1b45clR2o54QoonE/gKHv9gukxQ8oJJZPR7o3/ydDTK6racnCJXxOyT1T93FCJMIg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/content": "^6.0.0", + "@hapi/hoek": "^11.0.2", + "@hapi/nigel": "^5.0.1" + } + }, + "node_modules/@hapi/podium": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/podium/-/podium-5.0.1.tgz", + "integrity": "sha512-eznFTw6rdBhAijXFIlBOMJJd+lXTvqbrBIS4Iu80r2KTVIo4g+7fLy4NKp/8+UnSt5Ox6mJtAlKBU/Sf5080TQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/teamwork": "^6.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/shot": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-6.0.1.tgz", + "integrity": "sha512-s5ynMKZXYoDd3dqPw5YTvOR/vjHvMTxc388+0qL0jZZP1+uwXuUD32o9DuuuLsmTlyXCWi02BJl1pBpwRuUrNA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/somever": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@hapi/somever/-/somever-4.1.1.tgz", + "integrity": "sha512-lt3QQiDDOVRatS0ionFDNrDIv4eXz58IibQaZQDOg4DqqdNme8oa0iPWcE0+hkq/KTeBCPtEOjDOBKBKwDumVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/bounce": "^3.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/statehood": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@hapi/statehood/-/statehood-8.1.1.tgz", + "integrity": "sha512-YbK7PSVUA59NArAW5Np0tKRoIZ5VNYUicOk7uJmWZF6XyH5gGL+k62w77SIJb0AoAJ0QdGQMCQ/WOGL1S3Ydow==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/iron": "^7.0.1", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/subtext": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@hapi/subtext/-/subtext-8.1.0.tgz", + "integrity": "sha512-PyaN4oSMtqPjjVxLny1k0iYg4+fwGusIhaom9B2StinBclHs7v46mIW706Y+Wo21lcgulGyXbQrmT/w4dus6ww==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/content": "^6.0.0", + "@hapi/file": "^3.0.0", + "@hapi/hoek": "^11.0.2", + "@hapi/pez": "^6.1.0", + "@hapi/wreck": "^18.0.1" + } + }, + "node_modules/@hapi/teamwork": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@hapi/teamwork/-/teamwork-6.0.0.tgz", + "integrity": "sha512-05HumSy3LWfXpmJ9cr6HzwhAavrHkJ1ZRCmNE2qJMihdM5YcWreWPfyN0yKT2ZjCM92au3ZkuodjBxOibxM67A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/validate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/validate/-/validate-2.0.1.tgz", + "integrity": "sha512-NZmXRnrSLK8MQ9y/CMqE9WSspgB9xA41/LlYR0k967aSZebWr4yNrpxIbov12ICwKy4APSlWXZga9jN5p6puPA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/topo": "^6.0.1" + } + }, + "node_modules/@hapi/vise": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/vise/-/vise-5.0.1.tgz", + "integrity": "sha512-XZYWzzRtINQLedPYlIkSkUr7m5Ddwlu99V9elh8CSygXstfv3UnWIXT0QD+wmR0VAG34d2Vx3olqcEhRRoTu9A==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -3745,6 +4063,34 @@ "socks": "2.8.3" } }, + "node_modules/imapflow/node_modules/pino": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.2.0.tgz", + "integrity": "sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^3.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/imapflow/node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -5326,6 +5672,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5991,9 +6346,9 @@ } }, "node_modules/pino": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.2.0.tgz", - "integrity": "sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.4.0.tgz", + "integrity": "sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0", @@ -6001,7 +6356,7 @@ "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^7.0.0", - "process-warning": "^3.0.0", + "process-warning": "^4.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", @@ -6182,9 +6537,9 @@ } }, "node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", "license": "MIT" }, "node_modules/progress": { diff --git a/package.json b/package.json index ecdb3a9..994f0ce 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "test": "mocha --exit --recursive --reporter=${MOCHA_REPORTER:-dot} tests" }, "dependencies": { + "@hapi/hapi": "^21.3.10", "cron": "^3.1.7", "ical-generator": "^8.0.0", "imapflow": "^1.0.164", "knex": "^3.1.0", "mailparser": "^3.7.1", "pg": "^8.13.0", + "pino": "^9.4.0", "puppeteer": "^23.4.0" }, "devDependencies": { diff --git a/sample.env b/sample.env index 9febcfd..5ba2fdf 100644 --- a/sample.env +++ b/sample.env @@ -1,3 +1,4 @@ +LOG_ENABLED=true DATABASE_API_URL=postgres://postgres:@localhost:5466/ucpa_facilitator GYMLIB_MAIL_RECEIVER_IMAP_HOST= @@ -40,4 +41,6 @@ TIME_SLOTS_PREFERENCES='{ "19h00", "20h00" ] -}' \ No newline at end of file +}' + +CALENDAR_NAME=ucpa \ No newline at end of file diff --git a/src/infrastructure/Logger.js b/src/infrastructure/Logger.js index 131b2f8..c132c7d 100644 --- a/src/infrastructure/Logger.js +++ b/src/infrastructure/Logger.js @@ -1,12 +1,12 @@ -class Logger { - info(msg) { - // eslint-disable-next-line no-console - console.info(msg); - } +import * as pino from 'pino'; +import { stdSerializers } from 'pino'; +import { config } from '../../config.js'; - error(msg) { - console.error(msg); - } -} - -export const logger = new Logger(); +export const logger = pino.default( + { + level: config.logging.logLevel, + redact: ['req.headers.authorization'], + enabled: config.logging.enabled, + serializers: Object.assign(Object.create(null), stdSerializers), + }, +); From 4d4a7ab2a963b3d268bdfe03207a56a6b5dcfeec Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 22:01:19 +0200 Subject: [PATCH 13/16] feat(server): create --- config.js | 4 ++ index.js | 17 +++++++ server.js | 36 ++++++++++++++ src/application/ReservationController.js | 21 +++++++- src/infrastructure/ReservationInterface.js | 25 ++++++++++ src/infrastructure/pino.js | 58 ++++++++++++++++++++++ src/infrastructure/routes.js | 3 ++ 7 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 server.js create mode 100644 src/infrastructure/ReservationInterface.js create mode 100644 src/infrastructure/pino.js create mode 100644 src/infrastructure/routes.js diff --git a/config.js b/config.js index f5a3246..dbb5452 100644 --- a/config.js +++ b/config.js @@ -9,6 +9,7 @@ function isFeatureEnabled(environmentVariable) { function buildConfiguration() { const config = { environment: env.NODE_ENV || 'development', + port: env.PORT || 3000, logging: { enabled: isFeatureEnabled(env.LOG_ENABLED), logLevel: env.LOG_LEVEL || 'info', @@ -39,6 +40,9 @@ function buildConfiguration() { url: env.NOTIFICATION_URL, token: env.NOTIFICATION_TOKEN, }, + calendar: { + name: env.CALENDAR_NAME, + }, timeSlotsPreferences: JSON.parse(env.TIME_SLOTS_PREFERENCES), }; if (config.environment === 'test') { diff --git a/index.js b/index.js index f9acdd6..430aaba 100644 --- a/index.js +++ b/index.js @@ -2,13 +2,17 @@ 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 { logger } from './src/infrastructure/logger.js'; import { NotificationClient } from './src/infrastructure/NotificationClient.js'; @@ -31,6 +35,8 @@ async function main() { start: true, timeZone: parisTimezone, }); + const server = await createServer({ reservationController }); + await server.start(); } async function getReservationController() { @@ -71,12 +77,23 @@ async function getReservationController() { 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/server.js b/server.js new file mode 100644 index 0000000..16485f3 --- /dev/null +++ b/server.js @@ -0,0 +1,36 @@ +import Hapi from '@hapi/hapi'; +import { config } from './config.js'; +import * as pino from './src/infrastructure/pino.js'; +import { routes } from './src/infrastructure/routes.js'; + +const createBareServer = function () { + const serverConfiguration = { + compression: false, + debug: { request: false, log: false }, + routes: { + cors: { + origin: ['*'], + additionalHeaders: ['X-Requested-With'], + }, + response: { + emptyStatusCode: 204, + }, + }, + port: config.port, + router: { + isCaseSensitive: false, + stripTrailingSlash: true, + }, + }; + + return Hapi.server(serverConfiguration); +}; + +async function createServer(controllers) { + const server = createBareServer(); + await server.register([pino]); + routes.map(Route => new Route(controllers).register(server)); + return server; +} + +export { createServer }; diff --git a/src/application/ReservationController.js b/src/application/ReservationController.js index 3bf9ec2..0f14f02 100644 --- a/src/application/ReservationController.js +++ b/src/application/ReservationController.js @@ -1,13 +1,28 @@ export class ReservationController { - constructor({ handleNewReservationUseCase, getActiveReservationsUseCase, submitFormUseCase, notifyUseCase, handleScheduledReservationUseCase, logger }) { + constructor({ + handleNewReservationUseCase, + getActiveReservationsUseCase, + submitFormUseCase, + notifyUseCase, + handleScheduledReservationUseCase, + createReservationEventsUseCase, + getAllEventsUseCase, + logger, + }) { this.handleNewReservationUseCase = handleNewReservationUseCase; this.getActiveReservationsUseCase = getActiveReservationsUseCase; this.submitFormUseCase = submitFormUseCase; this.notifyUseCase = notifyUseCase; this.handleScheduledReservationUseCase = handleScheduledReservationUseCase; + this.createReservationEventsUseCase = createReservationEventsUseCase; + this.getAllEventsUseCase = getAllEventsUseCase; this.logger = logger; } + async getCalendar() { + return this.getAllEventsUseCase.execute(); + } + async handleReservations() { this.logger.info('Start - HandleNewReservations'); await this.handleNewReservationUseCase.execute(); @@ -34,5 +49,9 @@ export class ReservationController { this.logger.info('Start - HandleScheduledReservations'); await this.handleScheduledReservationUseCase.execute(); this.logger.info('End - HandleScheduledReservations'); + + this.logger.info('Start - CreateReservationEventsUseCase'); + await this.createReservationEventsUseCase.execute(); + this.logger.info('End - CreateReservationEventsUseCase'); } } diff --git a/src/infrastructure/ReservationInterface.js b/src/infrastructure/ReservationInterface.js new file mode 100644 index 0000000..c085fb4 --- /dev/null +++ b/src/infrastructure/ReservationInterface.js @@ -0,0 +1,25 @@ +export class ReservationInterface { + constructor({ reservationController }) { + this.reservationController = reservationController; + } + + register(server) { + server.route([ + { + method: 'GET', + path: '/reservations/calendar', + options: { + handler: async (_, h) => { + const calendar = await this.reservationController.getCalendar(); + const response = h + .response(calendar) + .type('text/calendar; charset=utf-8'); + response.header('Content-Disposition', 'attachment; filename="calendar.ics"'); + return response; + }, + tags: ['api', 'reservations', 'calendar'], + }, + }, + ]); + } +} diff --git a/src/infrastructure/pino.js b/src/infrastructure/pino.js new file mode 100644 index 0000000..2032b90 --- /dev/null +++ b/src/infrastructure/pino.js @@ -0,0 +1,58 @@ +import { logger } from './Logger.js'; + +const plugin = { + name: 'hapi-pino', + register: async (server, options) => { + const logger = options.instance; + + server.ext('onPostStart', async () => { + logger.info(server.info, 'server started'); + }); + + server.ext('onPostStop', async () => { + logger.info(server.info, 'server stopped'); + }); + + server.events.on('log', (event) => { + logger.info({ tags: event.tags, data: event.data }); + }); + + server.events.on('request', (_, event) => { + if (event.channel !== 'error') { + return; + } + if (event.error) { + logger.error( + { + tags: event.tags, + err: event.error, + }, + 'request error', + ); + } + }); + + server.events.on('response', (request) => { + const info = request.info; + + logger.info( + { + queryParams: request.query, + responseTime: + (info.completed !== undefined ? info.completed : info.responded) + - info.received, + payload: request.auth.isAuthenticated ? request.payload : {}, + req: request, + res: request.raw.res, + }, + 'request completed', + ); + }); + }, +}; + +const options = { + instance: logger, +}; + +export { options, plugin }; diff --git a/src/infrastructure/routes.js b/src/infrastructure/routes.js new file mode 100644 index 0000000..6fa8e01 --- /dev/null +++ b/src/infrastructure/routes.js @@ -0,0 +1,3 @@ +import { ReservationInterface } from './ReservationInterface.js'; + +export const routes = [ReservationInterface]; From 94258f5065bfaec0c033fd273dea321e6bcfc2fa Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Wed, 25 Sep 2024 22:19:10 +0200 Subject: [PATCH 14/16] chore: add ci --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ config.js | 19 +++++++++++++---- db/seeds/.gitkeep | 0 package.json | 5 ++++- 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 db/seeds/.gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5223326 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + env: + NODE_ENV: test + TEST_DATABASE_API_URL: postgres://postgres:postgres@localhost:5432/test diff --git a/config.js b/config.js index dbb5452..5df6136 100644 --- a/config.js +++ b/config.js @@ -6,6 +6,13 @@ function isFeatureEnabled(environmentVariable) { return environmentVariable === 'true'; } +function getParsedJson(environmentVariable) { + if (environmentVariable === undefined) { + return undefined; + } + return JSON.parse(environmentVariable); +} + function buildConfiguration() { const config = { environment: env.NODE_ENV || 'development', @@ -22,7 +29,7 @@ function buildConfiguration() { user: env.GYMLIB_MAIL_RECEIVER_IMAP_USER, password: env.GYMLIB_MAIL_RECEIVER_IMAP_PASSWORD, }, - searchQuery: JSON.parse(env.GYMLIB_MAIL_RECEIVER_IMAP_SEARCH_QUERY), + searchQuery: getParsedJson(env.GYMLIB_MAIL_RECEIVER_IMAP_SEARCH_QUERY), }, ucpa: { imapConfig: { @@ -31,8 +38,8 @@ function buildConfiguration() { user: env.UCPA_MAIL_RECEIVER_IMAP_USER, password: env.UCPA_MAIL_RECEIVER_IMAP_PASSWORD, }, - searchQuery: JSON.parse(env.UCPA_MAIL_RECEIVER_IMAP_SEARCH_QUERY), - formInfo: JSON.parse(env.FORM_RESPONSE), + searchQuery: getParsedJson(env.UCPA_MAIL_RECEIVER_IMAP_SEARCH_QUERY), + formInfo: getParsedJson(env.FORM_RESPONSE), formSubmit: isFeatureEnabled(env.FORM_SUBMIT_ENABLED), areaId: env.UCPA_AREA_ID, }, @@ -43,7 +50,7 @@ function buildConfiguration() { calendar: { name: env.CALENDAR_NAME, }, - timeSlotsPreferences: JSON.parse(env.TIME_SLOTS_PREFERENCES), + timeSlotsPreferences: getParsedJson(env.TIME_SLOTS_PREFERENCES), }; if (config.environment === 'test') { config.logging.enabled = false; @@ -56,6 +63,10 @@ function buildConfiguration() { } function verifyConfig(config) { + if (env.NODE_ENV === 'test') { + return true; + } + let allKeysHaveValues = true; function checkDeep(object, path) { diff --git a/db/seeds/.gitkeep b/db/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 994f0ce..20dfaeb 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "license": "AGPL-3.0", "keywords": [], "main": "index.js", + "engines": { + "node": ">=20.0.0" + }, "scripts": { "db:new-migration": "npx knex --knexfile ./db/knexfile.js migrate:make $migrationname", "db:create": "node ./db/create-database.js", @@ -18,7 +21,7 @@ "db:seed": "knex --knexfile ./db/knexfile.js seed:run", "db:reset": "npm run db:prepare && npm run db:seed", "lint": "eslint .", - "test": "mocha --exit --recursive --reporter=${MOCHA_REPORTER:-dot} tests" + "test": "NODE_ENV=test npm run db:reset && NODE_ENV=test mocha --exit --recursive --reporter=${MOCHA_REPORTER:-dot} tests" }, "dependencies": { "@hapi/hapi": "^21.3.10", From ded8239220b70f1d34adc54d194d86e9ddb334df Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Thu, 26 Sep 2024 00:20:16 +0200 Subject: [PATCH 15/16] doc: add missing env --- sample.env | 1 + 1 file changed, 1 insertion(+) diff --git a/sample.env b/sample.env index 5ba2fdf..3987603 100644 --- a/sample.env +++ b/sample.env @@ -1,5 +1,6 @@ LOG_ENABLED=true DATABASE_API_URL=postgres://postgres:@localhost:5466/ucpa_facilitator +TEST_DATABASE_API_URL=postgres://postgres:@localhost:5466/ucpa_facilitator_test GYMLIB_MAIL_RECEIVER_IMAP_HOST= GYMLIB_MAIL_RECEIVER_IMAP_PORT= From 0aab46a9c02299524daf9739f6bb16bba649cf48 Mon Sep 17 00:00:00 2001 From: Vincent Hardouin Date: Thu, 26 Sep 2024 00:23:31 +0200 Subject: [PATCH 16/16] doc: add new step --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8356e79..ef6f1c9 100644 --- a/README.md +++ b/README.md @@ -6,15 +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 3 grandes étapes : +Le projet est composé actuellement de 4 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`. A venir : -1. Créer un évènement de calendrier et créer un pass Apple Wallet +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