diff --git a/.dockerignore b/.dockerignore index d15df29..fb438e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules # npm-debug.log -# Dockerfile # .dockerignore .git -# .gitignore +# .gitignore \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0ec05ca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# @see http://editorconfig.org/ + +# This is the top-most .editorconfig file; do not search in parent directories. +root = true + +# All files. +[*] +end_of_line = lf +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.env.dist b/.env.dist index 20ef4a1..2b6968f 100644 --- a/.env.dist +++ b/.env.dist @@ -1,21 +1,28 @@ -ENVIRONMENT=dev -FASTIFY_PORT=3000 -MONGODB=mongodb://mongodb:27017/hakuvahti +# Environment configuration +ENVIRONMENT=local + +# Sentry error monitoring SENTRY_DSN= + +# External services ELASTIC_PROXY_URL=https://elastic-helfi-rekry.docker.so -BASE_URL=https://helfi-rekry.docker.so -BASE_URL_EN=https://helfi-rekry.docker.so/en -BASE_URL_FI=https://helfi-rekry.docker.so/fi -BASE_URL_SV=https://helfi-rekry.docker.so/sv ATV_API_KEY=xxx -ATV_API_URL=https://atv-api-hki-kanslia-atv-test.agw.arodevtest.hel.fi -SUBSCRIPTION_MAX_AGE=90 -UNCONFIRMED_SUBSCRIPTION_MAX_AGE=5 -SUBSCRIPTION_EXPIRY_NOTIFICATION_DAYS=3 +ATV_API_URL=https://atv.api.test.hel.ninja + +# Mail server configuration MAIL_FROM=noreply@hel.fi MAIL_HOST=host-machine.local MAIL_PORT=1025 MAIL_SECURE= MAIL_AUTH_USER= MAIL_AUTH_PASS= -MAIL_TEMPLATE_PATH=rekry + +# Elisa Dialogi SMS service +DIALOGI_API_URL=https://viestipalvelu-api.elisa.fi/api/v1 +DIALOGI_API_KEY= +DIALOGI_SENDER= + +# Testing +TEST_SMS_NUMBER= + +HAKUVAHTI_API_KEY='123' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..cb68486 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +on: + pull_request: + push: + branches: ['main', 'dev'] +name: CI +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + repository: druidfi/stonehenge + + - name: Install and start Stonehenge + run: make up + + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Start hakuvahti + run: make fresh + + - name: Lint + run: docker compose exec app bash -c "npx biome ci src --reporter=github --colors=off" + + - name: Run tests + run: make test-ci + + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: NPM Audit + run: docker compose exec app bash -c "npm audit" diff --git a/.gitignore b/.gitignore index d4ffd49..4100bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,8 @@ test/types/index.js # compiled app dist + +# Misc +.less-history-file +.lesshst +.bash_history diff --git a/.nvmrc b/.nvmrc index 9de2256..deed13c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/iron +lts/jod diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0f9a9ac..0000000 --- a/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM registry.access.redhat.com/ubi9/nodejs-20 - -ENV npm_config_cache="$HOME/.npm" -ENV APP_NAME rekry-hakuvahti - -RUN mkdir -p "$HOME/node_modules" "$HOME/logs" -COPY --chmod=755 entrypoint.sh / - -EXPOSE 3000 - -ENTRYPOINT [ "/entrypoint.sh" ] - -CMD [ "npm", "run", "start" ] diff --git a/README.md b/README.md index 167d873..bd2cf2e 100644 --- a/README.md +++ b/README.md @@ -21,54 +21,162 @@ Pre-requisities to use Hakuvahti are: performing API actions and collecting results from ElasticSearch does not depend on possible ATV errors or network lag, or availability of SMTP server. -- Adding, confirming and deleting subscriptions happens through REST api, while: +- Adding, confirming, and deleting subscriptions happens through REST api, while: - ElasticProxy queries and sending emails happen through cron scripts. - Subscriptions are also removed through cron script, based on expiration - days in `.env` configuration. + days in site configuration. - Email templates are located under `src/templates/something/*.html` - Templates are suffixed with lang code, which is set per subscription. - Templates can be modified for different sites by copying them - to a different folder, ieg. `src/templates/something2` and changing - `MAIL_TEMPLATE_PATH` envvar. + to a different folder, i.e. `src/templates/something2` and updating + the `mail.templatePath` in the site configuration. -## Installing and running Hakuvahti with Docker (Druid Tools) +## Development setup - Copy `.env.dist` as `.env` and configure: - - MongoDB (defaults in .env.dist should work with docker), - - ElasticProxy (defaults in .env.dist should work with docker), - - SMTP settings for email sending (https://mailpit.docker.so/ should work with docker), - - [ATV integration](https://github.com/City-of-Helsinki/atv) - - Make sure the `ATV_API_KEY` is set, otherwise the local Hakuvahti cannot connect to ATV and will trigger an error. - - Subscription days, etc settings -- `make up` to build and start the docker - - hakuvahti should be available to Docker containers through Rekry docker network (easier to run with drupal dockers) but running locally recommended for development. -- `make down`to tear down the environment -- Hakuvahti server should work at `http://localhost:3000` -- Local environment does not run cron scripts automatically. Start a shell into docker image and run the commands manually when testing them. - -## Installing and running Hakuvahti locally - -- `npm i` to install dependencies -- Copy `.env.dist` as `.env` and configure: - - MongoDB, - - ElasticProxy, - - SMTP settings for email sending, - - [ATV integration](https://github.com/City-of-Helsinki/atv) - - Make sure the `ATV_API_KEY` is set, otherwise the local Hakuvahti cannot connect to ATV and will trigger an error. - - Subscription days, etc settings -- Create MongoDB collections: `npm run hav:init-mongodb` -- `npm start` (or `npm run dev` for development) -- Hakuvahti should now be running in port `:3000` (by default) -- For production environment, add following commands to cron: - - `npm run hav:populate-email-queue` (this should be run once per hour or at least daily) - - `npm run hav:send-emails-in-queue` (this should be run at least once per minute) + - ElasticProxy (default to local rekry elasticsearch), + - `ATV_API_KEY` (Hakuvahti will trigger an error if ATV cannot be reached) +- Configure site-specific settings in `conf/` directory (see Configuration section below) + +Start the local environment with: + +```bash +make fresh +``` + +Hakuvahti should be availabe at `https://hakuvahti.docker.so`. + +Get a shell inside the container: + +```bash +make shell +``` + +The local environment does not run cron scripts automatically. Run scripts manually when testing, see [`package.json`](./package.json) for available commands. + +Shutdown the container with: + +```bash +make down +``` + +## Configuration + +### Queue Population Script + +The `hav:populate-queue` script checks for new search results and queues notification emails and SMS messages. It supports site-specific processing and dry-run mode for testing. + +**Usage:** + +```bash +# Process all sites +npm run hav:populate-queue + +# Process specific site only +npm run hav:populate-queue -- --site=rekry + +# Preview what would happen without making changes (dry run) +npm run hav:populate-queue -- --dry-run + +# Dry run for specific site +npm run hav:populate-queue -- --site=rekry --dry-run +``` + +**CLI Parameters:** +- `--site=` - Process only the specified site (omit to process all sites) +- `--dry-run` - Preview mode that shows what would happen without making any database changes + +**OpenShift Crontab Examples:** + +```yaml +# Rekry site - check at 6 AM daily +- name: populate-rekry + schedule: "0 6 * * *" + command: ["npm", "run", "hav:populate-queue", "--", "--site=rekry"] + +# General site - check hourly +- name: populate-general + schedule: "0 * * * *" + command: ["npm", "run", "hav:populate-queue", "--", "--site=general"] + +# Queue processor runs every minute (processes all sites) +- name: send-emails + schedule: "* * * * *" + command: ["npm", "run", "hav:send-emails-in-queue"] +``` + +**Note:** Each site can have its own schedule. The `--site` parameter allows you to control when each site's results are collected, which is useful when different sites want notifications at different times or to spread the load on ElasticSearch. + +### Site Configuration Files + +Create JSON configuration files in the `conf/` directory. Each file represents a site and should be named `{site-id}.json` (e.g., `rekry.json`). + +Example configuration structure: + +```json +{ + "name": "rekry", + "dev": { + "urls": { + "base": "https://helfi-rekry.docker.so", + "en": "https://helfi-rekry.docker.so/en", + "fi": "https://helfi-rekry.docker.so/fi", + "sv": "https://helfi-rekry.docker.so/sv" + }, + "subscription": { + "maxAge": 90, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 3 + }, + "mail": { + "templatePath": "rekry" + } + }, + "prod": { + "urls": { + "base": "https://hel.fi", + "en": "https://hel.fi/en", + "fi": "https://hel.fi/fi", + "sv": "https://hel.fi/sv" + }, + "subscription": { + "maxAge": 90, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 3 + }, + "mail": { + "templatePath": "rekry" + } + } +} +``` + +### Environment Selection + +The system automatically selects the correct environment configuration based on the `ENVIRONMENT` variable: +- Defaults to `local` if `ENVIRONMENT` is not set +- Use `ENVIRONMENT=production` for production deployment +- Sites usually have `local`, `dev`, `staging` and `production` environments + +### Configuration Properties + +- **`name`**: Human-readable site name +- **`urls`**: Localized URLs for the site + - `base`: Main site URL + - `en`, `fi`, `sv`: Language-specific URLs +- **`subscription`**: Subscription lifecycle settings + - `maxAge`: Maximum subscription age in days + - `unconfirmedMaxAge`: Days before unconfirmed subscriptions are removed + - `expiryNotificationDays`: Days before expiry to send notification +- **`mail`**: Email template configuration + - `templatePath`: Template directory under `src/templates/` ## Environment variables ### Core `ENVIRONMENT` Either `production`, `staging` or `dev`. This is used by Sentry and/or other services that need environment info. -`FASTIFY_PORT` Port where Hakuvahti runs (for example `3000`). If you change the envvar, remember to update Dockerfile and compose.yaml. +`FASTIFY_PORT` Port where Hakuvahti runs. Do not change this. ### Website `BASE_URL` Website that uses Hakuvahti (for example https://www.hel.fi) @@ -111,6 +219,26 @@ Pre-requisities to use Hakuvahti are: `MAIL_AUTH_PASS` (Password to authenticate at SMTP server) +### Elisa Dialogi SMS Service (Optional) + +Hakuvahti supports sending SMS notifications via Elisa Dialogi API. SMS notifications are optional and work alongside email notifications. + +`DIALOGI_API_URL` Set the Elisa Dialogi API base URL (for example `https://viestipalvelu-api.elisa.fi/api/v1/`) + +`DIALOGI_API_KEY` Set the API key/bearer token for Dialogi authentication + +`DIALOGI_SENDER` Set the SMS sender identifier (international number with +, shortcode, or alphanumeric max 11 characters) + +**Note:** If these environment variables are not set, SMS functionality will be disabled and only email notifications will be sent. The system will log a warning on startup if Dialogi is not configured. + +For SMS notifications to work: +1. Users must provide their phone number in E.164 international format (e.g., `+358501234567`) when subscribing +2. Run the SMS queue processor: `npm run hav:send-sms-in-queue` (should be run at least once per minute in production) + +### Testing + +`TEST_SMS_NUMBER` Set your phone number in E.164 format for testing SMS sending (e.g., `+358501234567`). Used by `npm run hav:test-sms-sending` to verify Dialogi API integration. + # REST Endpoints: ## Add Subscription @@ -119,7 +247,7 @@ Pre-requisities to use Hakuvahti are: Adds new Hakuvahti subscription: -``` +```json { "elastic_query": "", "search_description": "", @@ -135,7 +263,7 @@ Adds new Hakuvahti subscription: Confirms a subscription. To confirm a subscription, user must know both the id and hash (`hash` field in collection). -Subscriptions that are not confirmed, will not be checked during `npm run hav:populate-email-queue ` command. +Subscriptions that are not confirmed, will not be checked during `npm run hav:populate-queue` command. ## Delete a subscription @@ -161,19 +289,52 @@ Initialize MongoDB collections. Required before running populate or send command ### Query for new results for subscriptions -`npm run hav:populate-email-queue` +`npm run hav:populate-queue` -Queries all Hakuvahti entries and checks for new results in ElasticSearch. This populates the email queue. +Queries all Hakuvahti entries and checks for new results in ElasticSearch. This populates the email and SMS queues. Removes expired subscriptions. -Adds following emails to the email queue: +Adds following notifications to queues: -- New results from ElasticQuery queries -- Notifications if subscription is going to expire +- **Email queue**: New results from ElasticQuery queries and expiry notifications +- **SMS queue**: New results notifications (only for subscriptions with SMS in ATV) ### Sends emails from queue `npm run hav:send-emails-in-queue` -Sends emails in queue that were generated by `hav:populate-email-queue` +Sends emails in queue that were generated by `hav:populate-queue` + +### Sends SMS from queue + +`npm run hav:send-sms-in-queue` + +Sends SMS messages in queue that were generated by `hav:populate-queue`. Only processes subscriptions that have SMS stored in ATV. + +### Test SMS Sending + +`npm run hav:test-sms-sending` + +Test script to verify Elisa Dialogi SMS API integration. Sends test SMS messages in all supported languages (fi, sv, en) to a specified phone number. + +**Prerequisites:** +- Set `TEST_SMS_NUMBER` in your `.env` file (e.g., `TEST_SMS_NUMBER=+358501234567`) +- Configure `DIALOGI_API_URL` and `DIALOGI_API_KEY` +- Build the project: `npm run build:ts` + +**Example usage:** +```bash +# Add to .env file: +TEST_SMS_NUMBER=+358501234567 + +# Build and run test +npm run build:ts +npm run hav:test-sms-sending +``` + +The script will send three test SMS messages (one per language) with dummy search data to verify the integration is working correctly. + +### Mock server + +See [dialogi-server.md](./documentation/dialogi-server.md). \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d944308 --- /dev/null +++ b/biome.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noGlobalIsFinite": "off" + }, + "correctness": { + "noUnusedVariables": "warn" + }, + "style": { + "noUnusedTemplateLiteral": "warn" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120, + "expand": "auto" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "single", + "semicolons": "always", + "expand": "auto" + } + } +} diff --git a/compose.yaml b/compose.yaml index 3207d09..f75c845 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,30 +6,44 @@ services: ports: - "27017:27017" networks: - - hav-internal - - helfi-hakuvahti-network + - internal - nodejs: - user: root + app: + user: "${DOCKER_UID:-1000}:${DOCKER_GID:-1000}" build: context: . - dockerfile: Dockerfile + dockerfile: openshift/Dockerfile + target: development + hostname: hakuvahti + environment: + FASTIFY_PORT: 3000 + MONGODB: mongodb://mongodb:27017/hakuvahti volumes: - - .:/opt/app-root/src:delegated + - .:/app:delegated ports: - "3000:3000" depends_on: - mongodb networks: - - hav-internal - - helfi-hakuvahti-network + - internal + - stonehenge-network extra_hosts: - "helfi-rekry.docker.so:host-gateway" - "elastic-helfi-rekry.docker.so:host-gateway" - "host-machine.local:host-gateway" + labels: + - "traefik.enable=true" + - "traefik.http.routers.hakuvahti-app.entrypoints=https" + - "traefik.http.routers.hakuvahti-app.rule=Host(`hakuvahti.docker.so`)" + - "traefik.http.routers.hakuvahti-app.tls=true" + - "traefik.http.services.hakuvahti-app.loadbalancer.server.port=3000" + - "traefik.docker.network=stonehenge-network" + +volumes: + node_modules: networks: - hav-internal: + internal: internal: true - helfi-hakuvahti-network: - driver: bridge + stonehenge-network: + external: true diff --git a/conf/kymp.json b/conf/kymp.json new file mode 100644 index 0000000..fb411a1 --- /dev/null +++ b/conf/kymp.json @@ -0,0 +1,339 @@ +{ + "name": "kymp", + "translations": { + "copyright_holder": { + "fi": "Helsingin kaupunki", + "en": "City of Helsinki", + "sv": "Helsingfors stad" + }, + "email_logo": { + "fi": "https://makasiini.hel.ninja/helsinki-logos/helsinki-logo-black-h45.png", + "en": "https://makasiini.hel.ninja/helsinki-logos/helsinki-logo-black-h45.png", + "sv": "https://makasiini.hel.ninja/helsinki-logos/helsinki-logo-black-sv-h45.png" + }, + "email_subject_confirmation": { + "fi": "Vahvista ajoneuvojen siirtovahdin tilaus", + "en": "Confirm your Vehicle Removal Alert Service subscription", + "sv": "Bekräfta beställningen av flyttningsvakten för fordon" + }, + "email_subject_expiry": { + "fi": "Ajoneuvojen siirtovahtisi on vanhentumassa", + "en": "Your Vehicle Removal Alert Service subscription is about to expire", + "sv": "Din flyttningsvakt för fordon håller på att gå ut" + }, + "email_subject_newhits": { + "fi": "Uusia osumia ajoneuvojen siirtovahdillasi", + "en": "The Vehicle Removal Alert Service has found new matches for you", + "sv": "Nya träffar från din flyttningsvakt för fordon" + }, + "instructions_text": { + "fi": "Työpaikkojen hakuvahdin käyttöohjeet", + "en": "Instructions for using saved searches for jobs", + "sv": "Anvisning för användning av sökvakten för arbetsplatser" + }, + "instructions_link": { + "fi": "https://www.hel.fi/fi/avoimet-tyopaikat/nain-me-rekrytoimme/hakuvahdin-ohjeet", + "en": "https://www.hel.fi/en/open-jobs/this-is-how-we-recruit/job-alert-instructions", + "sv": "https://www.hel.fi/sv/lediga-jobb/sa-har-rekryterar-vi/sokvaktens-anvisningar" + }, + "email_confirmation_title": { + "fi": "Vahvista ajoneuvojen siirtovahdin tilaus Helsingin kaupungilta", + "en": "Confirm your Vehicle Removal Alert Service subscription with the City of Helsinki", + "sv": "Bekräfta beställning av flyttningsvakten för fordon från Helsingfors stad" + }, + "email_confirmation_intro": { + "fi": "Tälle sähköpostille luotiin hakuvahti Helsingin kaupungin verkkosivustolla.", + "en": "A search alert was created with this email address on the City of Helsinki website.", + "sv": "En sökvakt på Helsingfors stads webbplats har skapats för den här e-postadressen." + }, + "email_confirmation_search_description": { + "fi": "Hakuehdot", + "en": "Search criteria", + "sv": "Sökvillkor" + }, + "email_confirmation_no_criteria": { + "fi": "Et ole valinnut yhtään hakuehtoa.", + "en": "You have not selected any search criteria.", + "sv": "Du har inte valt några sökvillkor." + }, + "email_confirmation_button": { + "fi": "Vahvista hakuvahti", + "en": "Confirm your search alert", + "sv": "Bekräfta sökvakten" + }, + "email_confirmation_ignore": { + "fi": "Jos et tilannut hakuvahtia, voit jättää tämän sähköpostin huomiotta.", + "en": "If you did not subscribe to the search alert, you can ignore this email.", + "sv": "Om du inte har beställt sökvakten, kan du strunta i det här e-postmeddelandet." + }, + "email_generic_automatically_sent": { + "fi": "Tämä viesti on lähetetty automaattisesti, eikä siihen voi vastata.", + "en": "This message has been sent automatically and cannot be replied to.", + "sv": "Detta meddelande har skickats automatiskt och kan inte besvaras." + }, + "email_generic_kind_regards": { + "fi": "Ystävällisin terveisin,", + "en": "Kind regards,", + "sv": "Med vänlig hälsning," + }, + "email_generic_signature": { + "fi": "Helsingin kaupunki", + "en": "City of Helsinki", + "sv": "Helsingfors stad" + }, + "email_generic_remove_link": { + "fi": "Poista hakuvahti", + "en": "Remove the search alert", + "sv": "Radera sökvakten" + }, + "email_generic_your_search_terms": { + "fi": "Hakuehtosi", + "en": "Your search criteria", + "sv": "Dina sökvillkor" + }, + "email_expiry_intro_before_date": { + "fi": "Hakuvahtisi Helsingin kaupungin sivustolla on päättymässä", + "en": "Your saved search on the City of Helsinki website is about to expire on", + "sv": "Din sökvakt på Helsingfors stads webbplats kommer att gå ut den" + }, + "email_expiry_intro_after_date": { + "fi": "mutta voit tehdä sen uudelleen.", + "en": "but you can renew it, if you wish.", + "sv": "men du kan skapa den på nytt." + }, + "email_expiry_intro_before_search_description": { + "fi": "Hakuehdot:", + "en": "Search criteria:", + "sv": "Sökvillkor:" + }, + "email_expiry_intro_after_search_description": { + "fi": ".", + "en": ".", + "sv": "." + }, + "email_expiry_button": { + "fi": "Tee uusi hakuvahti", + "en": "Save a new search", + "sv": "Spara en ny sökvakt" + }, + "email_expiry_title_header": { + "fi": "Ajoneuvojen siirtovahtisi Helsingin kaupungilta on vanhentumassa", + "en": "Your Vehicle Removal Alert Service subscription with the City of Helsinki is about to expire", + "sv": "Din flyttningsvakt för fordon från Helsingfors stad håller på att gå ut" + }, + "email_expiry_prefix": { + "fi": "Hakuvahtisi voimassaolo päättyy", + "en": "Your subscription will expire on", + "sv": "Din sökvakt på Helsingfors stads webbplats upphör den" + }, + "email_expiry_suffix": { + "fi": ". Voit jatkaa hakuvahdin tilausta sen voimassaoloaikana. Hakuvahdin vanhenemisen jälkeen voit tehdä uuden hakuvahdin.", + "en": ". You can extend your subscription to the search alert during its period of validity. When the search alert expires, you can create a new one.", + "sv": ". Du kan valfritt förlänga beställningen av sökvakten så länge den är i kraft. Efter att sökvakten gått ut kan du skapa en ny sökvakt." + }, + "email_expiry_renewal_button": { + "fi": "Jatka hakuvahdin tilausta", + "en": "Continue your search alert subscription", + "sv": "Förläng beställningen av sökvakten" + }, + "email_expiry_new_link": { + "fi": "Tee uusi hakuvahti", + "en": "Create a new search alert", + "sv": "Skapa en ny sökvakt" + }, + "email_newhits_intro_prefix": { + "fi": "Listauksessa näytetään enintään kymmenen uutta hakuvahtiosumaa.", + "en": "The list shows up to ten new results corresponding to your saved search.", + "sv": "I listan visas högst tio nya träffar med sökvakten." + }, + "email_newhits_header": { + "fi": "Uusia ajoneuvojen siirtovahdin osumia Helsingin kaupungilta", + "en": "New Vehicle Removal Alert Service matches from the City of Helsinki", + "sv": "Nya träffar från din flyttningsvakt för fordon från Helsingfors stad" + }, + "email_newhits_link_text": { + "fi": "Katso kaikki hakuvahtitulokset", + "en": "See all saved search results", + "sv": "Se alla sökvaktens resultat" + }, + "email_newhits_expiry_prefix": { + "fi": "Hakuvahti on voimassa", + "en": "The search alert will be valid until", + "sv": "Sökvakten är i kraft fram till" + }, + "email_newhits_expiry_suffix": { + "fi": " saakka.", + "en": ".", + "sv": "." + }, + "email_newhits_expiry_instructions": { + "fi": "Sait tämän sähköpostin, koska olet tilannut Helsingin kaupungin hakuvahdin. Jos et enää halua saada ilmoituksia, voit perua hakuvahdin tilauksen alla olevasta linkistä. Voit koska tahansa tilata uusia hakuvahteja.", + "en": "You have received this email because you have subscribed to the City of Helsinki's search alert service. If you no longer wish to receive notifications, you can unsubscribe to the service via the link below. You can subscribe to new search alerts at any time.", + "sv": "Du fick detta e-postmeddelande eftersom du har beställt Helsingfors stads sökvakt. Om du inte längre vill få meddelanden kan du radera sökvakten via länken nedan. Du kan när som helst beställa nya sökvakter." + }, + "email_newhits_do_not_reply": { + "fi": "Tähän viestiin ei voi vastata.", + "en": "Please do not reply to this message.", + "sv": "Detta meddelande kan inte besvaras." + }, + "sms_confirmation_intro": { + "fi": "Vahvista ajoneuvojen siirtovahdin tilaus Helsingin kaupungilta", + "en": "Confirm your Vehicle Removal Alert Service subscription with the City of Helsinki", + "sv": "Bekräfta beställningen av flyttningsvakten för fordon från Helsingfors stad" + }, + "sms_confirmation_code_label": { + "fi": "Vahvistuskoodi:", + "en": "Confirmation code:", + "sv": "Bekräftelsekod:" + }, + "sms_confirmation_code_validity": { + "fi": "Vahvistuskoodi on voimassa 60 minuuttia", + "en": "The confirmation code will be valid for 60 minutes", + "sv": "Bekräftelsekoden är giltig i 60 minuter" + }, + "sms_confirmation_link": { + "fi": "hel.fi/vahvista-siirtovahti", + "en": "hel.fi/confirm-saved-search", + "sv": "hel.fi/bekrafta-sokvakten" + }, + "sms_newhits_intro": { + "fi": "Uusia osumia ajoneuvojen siirtovahdillasi", + "en": "The Vehicle Removal Alert Service has found new matches for you", + "sv": "Nya träffar från din flyttningsvakt för fordon" + }, + "sms_newhits_remove_text": { + "fi": "Poista hakuvahti osoitteessa", + "en": "Remove your search alert at", + "sv": "Radera sökvakten på adressen" + }, + "sms_newhits_remove_link": { + "fi": "hel.fi/poista-siirtovahti", + "en": "hel.fi/delete-saved-search", + "sv": "hel.fi/radera-sokvakten" + }, + "sms_newhits_code_label": { + "fi": "Vahvistuskoodi:", + "en": "Confirmation code:", + "sv": "Bekräftelsekod:" + }, + "sms_newhits_code_validity": { + "fi": "Vahvistuskoodi on voimassa 24 tuntia.", + "en": "The confirmation code will be valid for 24 hours.", + "sv": "Bekräftelsekoden är giltig i 24 timmar." + }, + "sms_renewal_intro": { + "fi": "Ajoneuvojen siirtovahtisi Helsingin kaupungilta on vanhentumassa", + "en": "Your Vehicle Removal Alert Service subscription with the City of Helsinki will expire on", + "sv": "Din flyttningsvakt för fordon från Helsingfors stad går ut" + }, + "sms_renewal_search_label": { + "fi": "Hakuehtosi:", + "en": "Your search criteria:", + "sv": "Dina sökvillkor:" + }, + "sms_renewal_text": { + "fi": "Voit jatkaa hakuvahdin tilausta osoitteessa", + "en": "You can continue your search alert subscription at", + "sv": "Du kan fortsätta beställningen av sökvakten på adressen" + }, + "sms_renewal_link": { + "fi": "hel.fi/jatka-siirtovahti", + "en": "hel.fi/extend-saved-search", + "sv": "hel.fi/forlang-sokvakten" + }, + "sms_renewal_code_label": { + "fi": "Vahvistuskoodi:", + "en": "Confirmation code:", + "sv": "Bekräftelsekod:" + }, + "sms_renewal_code_validity": { + "fi": "Vahvistuskoodi on voimassa 24 tuntia.", + "en": "The confirmation code will be valid for 24 hours.", + "sv": "Bekräftelsekoden är giltig i 24 timmar." + } + }, + "dev": { + "urls": { + "base": "https://www.test.hel.ninja/", + "en": "https://www.test.hel.ninja/en/urban-environment-and-traffic", + "fi": "https://www.test.hel.ninja/fi/kaupunkiymparisto-ja-liikenne", + "sv": "https://www.test.hel.ninja/sv/stadsmiljo-och-trafik" + }, + "subscription": { + "maxAge": 365, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 5, + "enableSms": true, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 2440 + }, + "mail": { + "templatePath": "kymp", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "https://liikenne-elastic-proxy.test.hel.ninja/mobilenote_data" + }, + "staging": { + "urls": { + "base": "https://www.stage.hel.ninja", + "en": "https://www.stage.hel.ninja/en/urban-environment-and-traffic", + "fi": "https://www.stage.hel.ninja/fi/kaupunkiymparisto-ja-liikenne", + "sv": "https://www.stage.hel.ninja/sv/stadsmiljo-och-trafik" + }, + "subscription": { + "maxAge": 365, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 5, + "enableSms": true, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 2440 + }, + "mail": { + "templatePath": "kymp", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "https://liikenne-elastic-proxy.stage.hel.ninja/mobilenote_data" + }, + "production": { + "urls": { + "base": "https://www.hel.fi", + "en": "https://www.hel.fi/en/urban-environment-and-traffic", + "fi": "https://www.hel.fi/fi/kaupunkiymparisto-ja-liikenne", + "sv": "https://www.hel.fi/sv/stadsmiljo-och-trafik" + }, + "subscription": { + "maxAge": 365, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 5, + "enableSms": true, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 2440 + }, + "mail": { + "templatePath": "kymp", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "https://liikenne-elastic-proxy.api.hel.ninja/mobilenote_data" + }, + "local": { + "urls": { + "base": "https://helfi-kymp.docker.so", + "en": "https://helfi-kymp.docker.so/en", + "fi": "https://helfi-kymp.docker.so/fi", + "sv": "https://helfi-kymp.docker.so/sv" + }, + "subscription": { + "maxAge": 90, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 3, + "enableSms": true, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 720 + }, + "mail": { + "templatePath": "kymp", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "http://helfi-kymp-elastic-proxy:8080/mobilenote_data" + } +} \ No newline at end of file diff --git a/conf/rekry.json b/conf/rekry.json new file mode 100644 index 0000000..a9f494f --- /dev/null +++ b/conf/rekry.json @@ -0,0 +1,269 @@ +{ + "name": "rekry", + "translations": { + "copyright_holder": { + "fi": "Helsingin kaupunki", + "en": "City of Helsinki", + "sv": "Helsingfors stad" + }, + "email_logo": { + "fi": "https://makasiini.hel.ninja/helsinki-logos/helsinki-logo-black-h45.png", + "en": "https://makasiini.hel.ninja/helsinki-logos/helsinki-logo-black-h45.png", + "sv": "https://makasiini.hel.ninja/helsinki-logos/helsinki-logo-black-sv-h45.png" + }, + "email_subject_confirmation": { + "fi": "Vahvista työpaikkojen hakuvahdin tilaus", + "en": "Confirm your saved search for jobs", + "sv": "Bekräfta beställningen av sökvakten för arbetsplatser" + }, + "email_subject_expiry": { + "fi": "Työpaikkojen hakuvahtisi on vanhentumassa", + "en": "Your saved search for jobs is about to expire", + "sv": "Tiden för din sökvakt för arbetsplatser håller på att gå ut" + }, + "email_subject_newhits": { + "fi": "Uusia osumia työpaikkojen hakuvahdillasi", + "en": "New jobs corresponding to your saved search", + "sv": "Nya träffar med din sökvakt för arbetsplatser" + }, + "instructions_text": { + "fi": "Työpaikkojen hakuvahdin käyttöohjeet", + "en": "Instructions for using saved searches for jobs", + "sv": "Anvisning för användning av sökvakten för arbetsplatser" + }, + "instructions_link": { + "fi": "https://www.hel.fi/fi/avoimet-tyopaikat/nain-me-rekrytoimme/hakuvahdin-ohjeet", + "en": "https://www.hel.fi/en/open-jobs/this-is-how-we-recruit/job-alert-instructions", + "sv": "https://www.hel.fi/sv/lediga-jobb/sa-har-rekryterar-vi/sokvaktens-anvisningar" + }, + "email_confirmation_title": { + "fi": "Vahvista työpaikkojen hakuvahdin tilaus Helsingin kaupungilta", + "en": "Confirm your saved search for City of Helsinki jobs", + "sv": "Bekräfta beställningen av sökvakten för arbetsplatser från Helsingfors stad" + }, + "email_confirmation_intro": { + "fi": "Tälle sähköpostille luotiin hakuvahti Helsingin kaupungin verkkosivustolla.", + "en": "This email address was used to save a search on the City of Helsinki website.", + "sv": "En sökvakt på Helsingfors stads webbplats har skapats för den här e-postadressen." + }, + "email_confirmation_search_description": { + "fi": "Hakuehdot", + "en": "Search criteria", + "sv": "Sökvillkor" + }, + "email_confirmation_button": { + "fi": "Vahvista hakuvahti", + "en": "Confirm saved search", + "sv": "Bekräfta sökvakten" + }, + "email_confirmation_ignore": { + "fi": "Jos et tilannut hakuvahtia, voit jättää tämän sähköpostin huomiotta.", + "en": "If you did not save a search, you can ignore this message.", + "sv": "Om du inte har beställt sökvakten, kan du strunta i det här e-postmeddelandet." + }, + "email_generic_automatically_sent": { + "fi": "Tämä viesti on lähetetty automaattisesti, eikä siihen voi vastata.", + "en": "This message was sent automatically and cannot be replied to.", + "sv": "Detta meddelande har skickats automatiskt och kan inte besvaras." + }, + "email_generic_kind_regards": { + "fi": "Ystävällisin terveisin,", + "en": "Kind regards,", + "sv": "Med vänlig hälsning," + }, + "email_generic_signature": { + "fi": "Helsingin kaupunki", + "en": "City of Helsinki", + "sv": "Helsingfors stad" + }, + "email_generic_remove_link": { + "fi": "Poista hakuvahti", + "en": "Delete saved search", + "sv": "Radera sökvakten" + }, + "email_generic_your_search_terms": { + "fi": "Hakuehtosi", + "en": "Your search criteria", + "sv": "Dina sökvillkor" + }, + "email_expiry_intro_before_date": { + "fi": "Hakuvahtisi Helsingin kaupungin sivustolla on päättymässä", + "en": "Your saved search on the City of Helsinki website is about to expire on", + "sv": "Din sökvakt på Helsingfors stads webbplats kommer att gå ut den" + }, + "email_expiry_intro_after_date": { + "fi": "mutta voit tehdä sen uudelleen.", + "en": "but you can renew it, if you wish.", + "sv": "men du kan skapa den på nytt." + }, + "email_expiry_intro_before_search_description": { + "fi": "Hakuehdot:", + "en": "Search criteria:", + "sv": "Sökvillkor:" + }, + "email_expiry_intro_after_search_description": { + "fi": ".", + "en": ".", + "sv": "." + }, + "email_expiry_button": { + "fi": "Tee uusi hakuvahti", + "en": "Save a new search", + "sv": "Spara en ny sökvakt" + }, + "email_expiry_title_header": { + "fi": "Työpaikkojen hakuvahtisi Helsingin kaupungilta on vanhentumassa", + "en": "Your saved search for City of Helsinki jobs is about to expire", + "sv": "Tiden för din sökvakt för arbetsplatser från Helsingfors stad håller på att gå ut" + }, + "email_expiry_prefix": { + "fi": "Hakuvahtisi Helsingin kaupungin verkkosivustolla päättyy", + "en": "Your saved search on the City of Helsinki website will expire on", + "sv": "Din sökvakt på Helsingfors stads webbplats upphör den" + }, + "email_expiry_suffix": { + "fi": ". Voit halutessasi jatkaa hakuvahdin tilausta niin kauan kuin se on voimassa. Hakuvahdin vanhenemisen jälkeen voit tehdä uuden hakuvahdin.", + "en": ". If you wish, you can extend the saved search during its period of validity. Once your saved search expires, you can save a new search.", + "sv": ". Du kan valfritt förlänga beställningen av sökvakten så länge den är i kraft. Efter att sökvakten gått ut kan du skapa en ny sökvakt." + }, + "email_expiry_renewal_button": { + "fi": "Jatka hakuvahdin tilausta", + "en": "Extend your saved search", + "sv": "Förläng beställningen av sökvakten" + }, + "email_expiry_new_link": { + "fi": "Tee uusi hakuvahti", + "en": "Save a new search", + "sv": "Skapa en ny sökvakt" + }, + "email_newhits_intro_prefix": { + "fi": "Listauksessa näytetään enintään kymmenen uutta hakuvahtiosumaa.", + "en": "The list shows up to ten new results corresponding to your saved search.", + "sv": "I listan visas högst tio nya träffar med sökvakten." + }, + "email_newhits_header": { + "fi": "Uusia työpaikkojen hakuvahtiosumia Helsingin kaupungilta", + "en": "The City of Helsinki has new job listings corresponding to your saved search", + "sv": "Nya träffar med sökvakten för arbetsplatser från Helsingfors stad" + }, + "email_newhits_link_text": { + "fi": "Katso kaikki hakuvahtitulokset", + "en": "See all saved search results", + "sv": "Se alla sökvaktens resultat" + }, + "email_newhits_expiry_prefix": { + "fi": "Hakuvahti on voimassa", + "en": "This saved search is valid until", + "sv": "Sökvakten är i kraft fram till" + }, + "email_newhits_expiry_suffix": { + "fi": " saakka.", + "en": ".", + "sv": "." + }, + "email_newhits_expiry_instructions": { + "fi": "Sait tämän sähköpostin, koska olet tilannut Helsingin kaupungin hakuvahdin. Jos et enää halua saada ilmoituksia, voit lopettaa hakuvahdin tilauksen alla olevasta linkistä. Voit tilata hakuvahdin uudelleen milloin tahansa.", + "en": "You received this email because you have saved a search for City of Helsinki jobs. If you no longer wish to receive these alerts, you can unsubscribe by clicking the link below. You can save your search again at any time.", + "sv": "Du fick detta e-postmeddelande eftersom du har beställt Helsingfors stads sökvakt. Om du inte längre vill få meddelanden kan du radera sökvakten via länken nedan. Du kan beställa en ny sökvakt när som helst." + }, + "email_newhits_do_not_reply": { + "fi": "Tähän viestiin ei voi vastata.", + "en": "Please do not reply to this message.", + "sv": "Detta meddelande kan inte besvaras." + }, + "sms_newhits_intro": { + "fi": "Hakuvahti: Uusia tuloksia haulle", + "en": "Search alert: New results for", + "sv": "Sökbevakning: Nya resultat för sökningen" + }, + "sms_newhits_cta": { + "fi": "Katso tulokset", + "en": "View results", + "sv": "Se resultat" + } + }, + "local": { + "urls": { + "base": "https://helfi-rekry.docker.so", + "en": "https://helfi-rekry.docker.so/en", + "fi": "https://helfi-rekry.docker.so/fi", + "sv": "https://helfi-rekry.docker.so/sv" + }, + "subscription": { + "maxAge": 90, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 3, + "enableSms": true, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 720 + }, + "mail": { + "templatePath": "rekry", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "http://helfi-rekry-elastic-proxy:8080/job_listings" + }, + "dev": { + "urls": { + "base": "https://www.test.hel.ninja", + "en": "https://www.test.hel.ninja/en/open-jobs", + "fi": "https://www.test.hel.ninja/fi/avoimet-tyopaikat", + "sv": "https://www.test.hel.ninja/sv/lediga-jobb" + }, + "subscription": { + "maxAge": 5, + "unconfirmedMaxAge": 2, + "expiryNotificationDays": 4, + "enableSms": false, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 720 + }, + "mail": { + "templatePath": "rekry", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "https://helfi-rekry-elastic-proxy.test.hel.ninja/job_listings" + }, + "staging": { + "urls": { + "base": "https://www.stage.hel.ninja", + "en": "https://www.stage.hel.ninja/en/open-jobs", + "fi": "https://www.stage.hel.ninja/fi/avoimet-tyopaikat", + "sv": "https://www.stage.hel.ninja/sv/lediga-jobb" + }, + "subscription": { + "maxAge": 5, + "unconfirmedMaxAge": 2, + "expiryNotificationDays": 4, + "enableSms": false, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 720 + }, + "mail": { + "templatePath": "rekry", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "https://rekry-elastic-proxy-staging.api.hel.ninja/job_listings" + }, + "production": { + "urls": { + "base": "https://www.hel.fi", + "en": "https://www.hel.fi/en/open-jobs", + "fi": "https://www.hel.fi/fi/avoimet-tyopaikat", + "sv": "https://www.hel.fi/sv/lediga-jobb" + }, + "subscription": { + "maxAge": 180, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 5, + "enableSms": false, + "smsCodeExpireConfirmMinutes": 60, + "smsCodeExpireActionMinutes": 720 + }, + "mail": { + "templatePath": "rekry", + "maxHitsInEmail": 10 + }, + "elasticProxyUrl": "https://helfi-rekry-elastic-proxy.api.hel.ninja/job_listings" + } +} \ No newline at end of file diff --git a/cron/populate.sh b/cron/populate.sh deleted file mode 100644 index d0d8b97..0000000 --- a/cron/populate.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh - -cd /app - -echo "Populating email queue" - -npm run hav:populate-email-queue - diff --git a/cron/queue.sh b/cron/queue.sh deleted file mode 100644 index d78ab2b..0000000 --- a/cron/queue.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -cd /app - -echo "Sending emails in queue" - -npm run hav:send-emails-in-queue diff --git a/crontab b/crontab deleted file mode 100644 index e4af216..0000000 --- a/crontab +++ /dev/null @@ -1,10 +0,0 @@ -# do daily/weekly/monthly maintenance -# min hour day month weekday command -* * * * * run-parts /etc/periodic/1min/ -*/15 * * * * run-parts /etc/periodic/15min/ -*/30 * * * * run-parts /etc/periodic/30min/ -0 * * * * run-parts /etc/periodic/hourly/ -0 */12 * * * run-parts /etc/periodic/12hour/ -0 2 * * * run-parts /etc/periodic/daily/ -0 3 * * 6 run-parts /etc/periodic/weekly/ -0 5 1 * * run-parts /etc/periodic/monthly/ diff --git a/documentation/dialogi-server.md b/documentation/dialogi-server.md new file mode 100644 index 0000000..c6412cb --- /dev/null +++ b/documentation/dialogi-server.md @@ -0,0 +1,37 @@ +### Mock Dialogi Server (Local Development) + +`npm run hav:run-dialogi-test-server` + +Runs a mock Dialogi API server for local testing when you don't have access to the real Dialogi API (requires static IP). + +**Usage:** +```bash +# Terminal 1: Start the mock server +npm run hav:run-dialogi-test-server + +(or after starting hakuvahti with make up, you can start server with: +"docker compose exec nodejs npm run hav:run-dialogi-test-server") + +# Terminal 2: Configure your .env to use the mock server +DIALOGI_API_URL=http://localhost:3001/sms +DIALOGI_API_KEY=any-value-works +DIALOGI_SENDER=TestSender + +# Now test the full SMS pipeline locally +npm run hav:test-sms-sending +``` + +The mock server: +- Runs on `http://localhost:3001` +- Accepts POST requests to `/sms` +- Returns valid Dialogi-like responses +- Logs all "sent" SMS messages to console +- Allows testing the entire SMS pipeline without the real API + +### Migration + +To migrate existing subscriptions to have `site_id` field, run: + +`npm run hav:migrate-site-id rekry` + +`npm run hav:update-schema` diff --git a/documentation/testing.md b/documentation/testing.md new file mode 100644 index 0000000..917674e --- /dev/null +++ b/documentation/testing.md @@ -0,0 +1,128 @@ +# Test Instructions for Setting Up and Testing Hakuvahti with Rekry + +This guide provides step-by-step instructions for installing, configuring, and testing the Hakuvahti integration with the Rekry application. Follow these steps carefully to ensure a successful setup and test of the job subscription and email notification functionality. + +## Prerequisites + +- Access to a development environment with Docker and command-line tools +- Rekry website (https://helfi-rekry.docker.so) +- Ensure Elasticsearch and Mailpit are configured and accessible + +## Step-by-Step Instructions + +### Step 1: Install Rekry with Helbit Integration + +**Goal:** Install the Rekry by following the official instructions provided in the [GitHub repository](https://github.com/City-of-Helsinki/drupal-helfi-rekry). + +**Action:** Clone the repository and set up the application as per the provided guidelines. + +**Post-Installation:** Index Elasticsearch to ensure the search functionality is ready. + +```bash +drush sapi-rt +drush sapi-c +drush sapi-i +drush cr +``` + +**Note:** These commands reset, clear, index, and rebuild the cache for Elasticsearch. Run them in the Rekry project directory. + +### Step 2: Set Up Hakuvahti + +**Goal:** Configure the Hakuvahti subscription service by following the Hakuvahti installation instructions. + +**Action:** Complete all steps to install and run Hakuvahti locally. + +**Note:** Ensure the Hakuvahti service is properly connected to the Rekry application. + +### Step 3: Create a Hakuvahti Subscription + +**Goal:** Create a new job subscription (Hakuvahti) using the Rekry website's job search page at https://helfi-rekry.docker.so/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja. + +**Action:** Perform a simple search using a keyword (e.g., "opettaja") to create a test subscription. + +**Tip:** Use broad or simple search terms to ensure test job listings can match the subscription criteria easily. + +### Step 4: Access the Hakuvahti Node Server + +**Goal:** Enter the Hakuvahti Node.js server environment to execute commands. + +**Action:** Run the following command in the Hakuvahti project directory: + +```bash +make shell +``` + +**Note:** This command opens a shell session within the Hakuvahti Node server container. + +### Step 5: Send the Hakuvahti Signup Email + +**Goal:** Populate the email queue and send the subscription confirmation email. + +**Action:** In the Hakuvahti shell, run: + +```bash +npm run hav:populate-queue +npm run hav:send-emails-in-queue +``` + +**Purpose:** These commands generate and send the signup confirmation email to the user. + +### Step 6: Access and Read Emails in Mailpit + +**Goal:** Check the signup email sent by Hakuvahti using the Mailpit interface. + +**Action:** Navigate to https://mailpit.docker.so/ in your browser. + +**Note:** Ensure Mailpit is running and configured to capture emails from the Hakuvahti service. + +### Step 7: Confirm the Hakuvahti Subscription + +**Goal:** Verify and activate the subscription using the confirmation link in the email. + +**Action:** Open the signup email in Mailpit and click the confirmation link to activate the Hakuvahti subscription. + +**Note:** Confirmation is required for the subscription to become active and receive job notifications. + +### Step 8: Add a Matching Job Listing in Rekry + +**Goal:** Create a new job listing in Rekry that matches the criteria of your Hakuvahti subscription. + +**Action:** Log in to the Rekry admin interface and add a job listing at https://helfi-rekry.docker.so/fi/avoimet-tyopaikat/node/add/job_listing. + +**Tip:** Ensure the job details (e.g., keywords, location) align with the subscription created in Step 3. + +### Step 9: Re-Index Elasticsearch in Rekry + +**Goal:** Update the Elasticsearch index to include the new job listing. + +**Action:** Run the following commands in the Rekry project directory: + +```bash +drush sapi-i +drush cr +``` + +**Verification:** Check the Rekry search page https://helfi-rekry.docker.so/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja to confirm the new job listing appears. + +### Step 10: Send Job Notification Emails + +**Goal:** Trigger Hakuvahti to send an email notification for the new job listing that matches the subscription. + +**Action:** In the Hakuvahti shell (access via `make shell` if needed), run: + +```bash +npm run hav:populate-queue +npm run hav:send-emails-in-queue +``` + +**Verification:** Return to https://mailpit.docker.so/ and confirm that a new email containing the job listing details has been received. + +## Additional Notes + +- **Environment:** Ensure all commands are executed in the correct project directories (Rekry or Hakuvahti) +- **Testing Tip:** Use unique keywords in your job listings and subscriptions to avoid confusion during testing or check the number of results for the search. + +## Conclusion + +By following these steps, you will have successfully installed Rekry and Hakuvahti, created a job subscription, added a matching job listing, and verified email notifications. If you encounter issues, refer to the respective GitHub repositories for additional documentation or support. diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index 4bcca84..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -npm i - -# Start the main application process -exec "$@" - diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 219b27e..679d993 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -1,21 +1,48 @@ -FROM node:20-alpine +FROM registry.access.redhat.com/ubi9/nodejs-22 AS builder -ENV npm_config_cache=/app/.npm -ENV APP_NAME rekry-hakuvahti +ENV npm_config_cache="/tmp/.npm" + +WORKDIR "/app" +COPY --chown=default:0 / "/app/" + +RUN npm ci + +RUN \ + npm run copy:assets && \ + npx tsc + +# Development image -RUN mkdir -p /app/node_modules -RUN mkdir -p /app/logs +FROM registry.access.redhat.com/ubi9/nodejs-22 AS development + +ENV npm_config_cache="/tmp/.npm" +ENV APP_NAME hakuvahti WORKDIR /app -COPY package.json . -COPY package-lock.json . -COPY . . -RUN npm install -RUN npm cache clean --force +COPY tools/entrypoint /entrypoint EXPOSE 3000 -RUN chown -R :0 /app && chmod -R g+wx /app + +ENTRYPOINT ["/entrypoint"] + +# Production image + +FROM registry.access.redhat.com/ubi9/nodejs-22 + +ENV npm_config_cache="/tmp/.npm" +ENV APP_NAME rekry-hakuvahti + +WORKDIR "/app" + +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --chown=default:0 / "/app/" +COPY --from=builder --chown=default:0 /app/dist /app/dist + +EXPOSE 3000 + USER nobody:0 -CMD [ "npm", "run", "start" ] +CMD ["npx", "fastify", "start", "-l", "info", "dist/app.js"] diff --git a/package-lock.json b/package-lock.json index be595cb..aadc3b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,30 +9,33 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@fastify/autoload": "^5.0.0", - "@fastify/mongodb": "^8.0.0", - "@fastify/sensible": "^5.0.0", - "@fastify/type-provider-typebox": "^4.0.0", - "@immobiliarelabs/fastify-sentry": "^8.0.1", - "@sinclair/typebox": "^0.32.9", + "@fastify/autoload": "^6.3.1", + "@fastify/mongodb": "^9.0.2", + "@fastify/sensible": "^6.0.3", + "@fastify/type-provider-typebox": "^6.1.0", + "@immobiliarelabs/fastify-sentry": "^9.0.1", + "@sinclair/typebox": "^0.34.41", "axios": "^1.6.7", - "c8": "^9.1.0", "dotenv": "^16.3.1", - "fastify": "^4.0.0", - "fastify-cli": "^6.0.1", + "fastify": "^5.6.2", + "fastify-cli": "^7.4.1", "fastify-mailer": "^2.3.1", - "fastify-plugin": "^4.0.0", + "fastify-plugin": "^5.1.0", + "google-libphonenumber": "^3.2.44", "jsdom": "^24.0.0", - "nodemailer": "^6.9.9", + "minimist": "^1.2.8", + "nodemailer": "^7.0.10", "sprightly": "^2.0.1" }, "devDependencies": { + "@biomejs/biome": "^2.2.4", + "@types/google-libphonenumber": "^7.4.30", "@types/jsdom": "^21.1.6", - "@types/node": "^20.4.4", + "@types/minimist": "^1.2.5", + "@types/node": "^22.19.1", "@types/nodemailer": "^6.4.14", - "@types/tap": "^15.0.5", "concurrently": "^8.2.2", - "fastify-tsconfig": "^2.0.0", + "fastify-tsconfig": "^3.0.0", "ts-node": "^10.4.0", "typescript": "^5.2.2" } @@ -50,10 +53,168 @@ "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" + "node_modules/@biomejs/biome": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.5.tgz", + "integrity": "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.2.5", + "@biomejs/cli-darwin-x64": "2.2.5", + "@biomejs/cli-linux-arm64": "2.2.5", + "@biomejs/cli-linux-arm64-musl": "2.2.5", + "@biomejs/cli-linux-x64": "2.2.5", + "@biomejs/cli-linux-x64-musl": "2.2.5", + "@biomejs/cli-win32-arm64": "2.2.5", + "@biomejs/cli-win32-x64": "2.2.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.5.tgz", + "integrity": "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.5.tgz", + "integrity": "sha512-FLIEl73fv0R7dI10EnEiZLw+IMz3mWLnF95ASDI0kbx6DDLJjWxE5JxxBfmG+udz1hIDd3fr5wsuP7nwuTRdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.5.tgz", + "integrity": "sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.5.tgz", + "integrity": "sha512-5Ov2wgAFwqDvQiESnu7b9ufD1faRa+40uwrohgBopeY84El2TnBDoMNXx6iuQdreoFGjwW8vH6k68G21EpNERw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.5.tgz", + "integrity": "sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.5.tgz", + "integrity": "sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.5.tgz", + "integrity": "sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.5.tgz", + "integrity": "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -68,55 +229,187 @@ } }, "node_modules/@fastify/ajv-compiler": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz", - "integrity": "sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, "node_modules/@fastify/autoload": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@fastify/autoload/-/autoload-5.8.0.tgz", - "integrity": "sha512-bF86vl+1Kk91S41WIL9NrKhcugGQg/cQ959aTaombkCjA+9YAbgVCKKu2lRqtMsosDZ0CNRfVnaLYoHQIDUI2A==" + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@fastify/autoload/-/autoload-6.3.1.tgz", + "integrity": "sha512-0fsG+lO3m5yEZVjXKpltCe+2eHhM6rfAPQhvlGUgLUFTw/N2wA9WqPTObMtrF3oUCUrxbSDv60HlUIoh+aFM1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" }, "node_modules/@fastify/deepmerge": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-1.3.0.tgz", - "integrity": "sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.1.0.tgz", + "integrity": "sha512-lCVONBQINyNhM6LLezB6+2afusgEYR4G8xenMsfe+AT+iZ7Ca6upM5Ha8UkZuYSnuMw3GWl/BiPXnLMi/gSxuQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" }, "node_modules/@fastify/error": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", - "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" }, "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", - "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "dependencies": { - "fast-json-stringify": "^5.7.0" + "dequal": "^2.0.3" } }, "node_modules/@fastify/mongodb": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@fastify/mongodb/-/mongodb-8.0.0.tgz", - "integrity": "sha512-IDw/wWpdc53+Y5sPpMg+ek71HOIVuz8NoD2GlfIOcvGE/lYdrZvnFQxqJcaZtlwPZ7YflDDkIu5aNkCPWdZQ0Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@fastify/mongodb/-/mongodb-9.0.2.tgz", + "integrity": "sha512-h04HpQ7nVeB2eR4YPJiFWaeFot+E6K6DHP5ymby3WEhExnVMaxd6FUVszDoU+bM3MmK9wtIFgJLUfOKcYU+nKQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mongodb": "^6.5.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "dependencies": { - "fastify-plugin": "^4.0.0", - "mongodb": "^6.0.0" + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" } }, "node_modules/@fastify/sensible": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-5.5.0.tgz", - "integrity": "sha512-D0zpl+nocsRXLceSbc4gasQaO3ZNQR4dy9Uu8Ym0mh8VUdrjpZ4g8Ca9O3pGXbBVOnPIGHUJNTV7Yf9dg/OYdg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.3.tgz", + "integrity": "sha512-Iyn8698hp/e5+v8SNBBruTa7UfrMEP52R16dc9jMpqSyEcPsvWFQo+R6WwHCUnJiLIsuci2ZoEZ7ilrSSCPIVg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "dependencies": { - "@lukeed/ms": "^2.0.1", - "fast-deep-equal": "^3.1.1", - "fastify-plugin": "^4.0.0", + "@lukeed/ms": "^2.0.2", + "dequal": "^2.0.3", + "fastify-plugin": "^5.0.0", "forwarded": "^0.2.0", "http-errors": "^2.0.0", "type-is": "^1.6.18", @@ -124,41 +417,46 @@ } }, "node_modules/@fastify/type-provider-typebox": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-4.0.0.tgz", - "integrity": "sha512-kTlN0saC/+xhcQPyBjb3YONQAMjiD/EHlCRjQjsr5E3NFjS5K8ZX5LGzXYDRjSa+sV4y8gTL5Q7FlObePv4iTA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-6.1.0.tgz", + "integrity": "sha512-k29cOitDRcZhMXVjtRq0+caKxdWoArz7su+dQWGzGWnFG+fSKhevgiZ7nexHWuXOEEQzgJlh6cptIMu69beaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "peerDependencies": { - "@sinclair/typebox": ">=0.26 <=0.32" + "typebox": "^1.0.13" } }, "node_modules/@immobiliarelabs/fastify-sentry": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@immobiliarelabs/fastify-sentry/-/fastify-sentry-8.0.2.tgz", - "integrity": "sha512-GxCIVYJIO3gtskVK9WGSmKjaCmzsv0RNaNegWebXf97d/MIq3a5FsFmyXaVANLwPfO/c2FuScECx0TbSdKeBfw==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@immobiliarelabs/fastify-sentry/-/fastify-sentry-9.0.1.tgz", + "integrity": "sha512-KY0h8s5Bs7IUYK1nVR1VY1ADACmrnMOo9N7WStk1U+A1unNrBb4HzWFjmMxFBZfISk6SZjsKXqShF23tJYkV4w==", + "deprecated": "The package is no longer maintained because from version 8 the Sentry SDK has a Fastify integration that covers all use cases", "license": "MIT", "dependencies": { "@sentry/node": "^7.105.0", "@sentry/tracing": "^7.105.0", "@sentry/utils": "^7.105.0", "cookie": "^0.7.0", - "fastify-plugin": "^4.3.0" + "fastify-plugin": "^5.0.1" }, "engines": { "node": ">=18" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -166,7 +464,8 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", @@ -187,13 +486,20 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.4.tgz", - "integrity": "sha512-8zJ8N1x51xo9hwPh6AWnKdLGEC5N3lDa6kms1YHmFBoRhTpJR6HG8wWk0td1MVCu9cD4YBrvjZEtd5Obw0Fbnw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", + "integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==", + "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@sentry-internal/tracing": { "version": "7.109.0", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.109.0.tgz", @@ -264,9 +570,10 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.32.9", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.9.tgz", - "integrity": "sha512-6oeJJPTIb0y3cs713HmXmXSx3WRWgid74KICYL9blOhNFuAcAB18dDWfATgcgzynfpF5xDzHGxEVbDYYr6nvgg==" + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "license": "MIT" }, "node_modules/@tsconfig/node10": { "version": "1.0.9", @@ -292,10 +599,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" + "node_modules/@types/google-libphonenumber": { + "version": "7.4.30", + "resolved": "https://registry.npmjs.org/@types/google-libphonenumber/-/google-libphonenumber-7.4.30.tgz", + "integrity": "sha512-Td1X1ayRxePEm6/jPHUBs2tT6TzW1lrVB6ZX7ViPGellyzO/0xMNi+wx5nH6jEitjznq276VGIqjK5qAju0XVw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/jsdom": { "version": "21.1.6", @@ -308,13 +617,22 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "20.11.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.4.tgz", - "integrity": "sha512-6I0fMH8Aoy2lOejL3s4LhyIYX34DPwY8bl5xlNjBvUEk8OHrcuzsFt+Ied4LvJihbtXPM+8zUqdydfIti86v9g==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/nodemailer": { @@ -326,15 +644,6 @@ "@types/node": "*" } }, - "node_modules/@types/tap": { - "version": "15.0.11", - "resolved": "https://registry.npmjs.org/@types/tap/-/tap-15.0.11.tgz", - "integrity": "sha512-QzbxIsrK6yX3iWC2PXGX/Ljz5cGISDEuOGISMcckeSUKIJXzbsfJLF4LddoncZ+ELVZpO0X87KfRem4h+yBFXQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -344,27 +653,18 @@ "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" }, "node_modules/@types/whatwg-url": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.4.tgz", - "integrity": "sha512-lXCmTWSHJvf0TRSO58nm978b8HJ/EdsSsEKLd3ODHFjo+3VGAyyTp4v50nWvwtzBxSMQrVOK7tcuN0zGPLICMw==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", "dependencies": { "@types/webidl-conversions": "*" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -403,14 +703,15 @@ } }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -418,9 +719,10 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", "dependencies": { "ajv": "^8.0.0" }, @@ -437,6 +739,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -455,18 +758,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -487,196 +778,46 @@ } }, "node_modules/avvio": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", - "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", + "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", "license": "MIT", "dependencies": { - "@fastify/error": "^3.3.0", + "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/bson": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", - "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", "engines": { "node": ">=16.20.1" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/c8": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", - "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@istanbuljs/schema": "^0.1.3", - "find-up": "^5.0.0", - "foreground-child": "^3.1.1", - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.1.6", - "test-exclude": "^6.0.0", - "v8-to-istanbul": "^9.0.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1" - }, - "bin": { - "c8": "bin/c8.js" - }, - "engines": { - "node": ">=14.14.0" - } - }, - "node_modules/c8/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/c8/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/c8/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/c8/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/c8/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "engines": { - "node": ">=8" + "node": ">= 0.4" } }, "node_modules/chalk": { @@ -706,35 +847,25 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -745,9 +876,10 @@ } }, "node_modules/close-with-grace": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/close-with-grace/-/close-with-grace-1.2.0.tgz", - "integrity": "sha512-Xga0jyAb4fX98u5pZAgqlbqHP8cHuy5M3Wto0k0L/36aP2C25Cjp51XfPw3Hz7dNC2L2/hF/PK/KJhO275L+VA==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/close-with-grace/-/close-with-grace-2.3.0.tgz", + "integrity": "sha512-38BS9BuqAml6XFIlSWQcj3eivE05yFV6cJDuYoNGiHrE+h9ud1JtMJIVKXdLWa2Uo2Xt7q/GYczOesEchvBEsw==", + "license": "MIT" }, "node_modules/color-convert": { "version": "2.0.1", @@ -768,7 +900,8 @@ "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -786,11 +919,6 @@ "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, "node_modules/concurrently": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", @@ -818,11 +946,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -838,20 +961,6 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/cssstyle": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", @@ -875,29 +984,6 @@ "node": ">=18" } }, - "node_modules/data-urls/node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", - "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", - "dependencies": { - "tr46": "^5.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -918,6 +1004,7 @@ "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", "engines": { "node": "*" } @@ -959,11 +1046,21 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -979,10 +1076,25 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/end-of-stream": { "version": "1.4.4", @@ -1003,39 +1115,65 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, "engines": { - "node": ">=0.8.x" + "node": ">= 0.4" } }, - "node_modules/fast-content-type-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } }, "node_modules/fast-copy": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", - "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", @@ -1049,16 +1187,26 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-json-stringify": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.10.0.tgz", - "integrity": "sha512-fu1BhzPzgOdvK+sVhSPFzm06DQl0Dwbo+NQxWm21k03ili2wsJExXbGZ9qsD4Lsn7zFGltF8h9I1fuhk4JPnrQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.1.1.tgz", + "integrity": "sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "dependencies": { - "@fastify/deepmerge": "^1.0.0", - "ajv": "^8.10.0", - "ajv-formats": "^2.1.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.1.0", - "json-schema-ref-resolver": "^1.0.1", + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, @@ -1071,29 +1219,32 @@ "fast-decode-uri-component": "^1.0.1" } }, - "node_modules/fast-redact": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", - "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" }, "node_modules/fast-uri": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.3.0.tgz", - "integrity": "sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.0.tgz", - "integrity": "sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==", + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", "funding": [ { "type": "github", @@ -1106,51 +1257,59 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.0", + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.3.0", - "fast-content-type-parse": "^1.1.0", - "fast-json-stringify": "^5.8.0", - "find-my-way": "^8.0.0", - "light-my-request": "^5.11.0", - "pino": "^9.0.0", - "process-warning": "^3.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", - "secure-json-parse": "^2.7.0", - "semver": "^7.5.4", - "toad-cache": "^3.3.0" + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, "node_modules/fastify-cli": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/fastify-cli/-/fastify-cli-6.0.1.tgz", - "integrity": "sha512-iGN4ULaftZr1qR7OTOQT4tbsduneQWXeF85EUnMeYGmxo5PPfrJ/o9r+X7hMgdnUGCDu2STCYDlMKNYtYYGdgA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/fastify-cli/-/fastify-cli-7.4.1.tgz", + "integrity": "sha512-7Jsfj2uLuGWvnxjrGDrHWpSm65+OcVx0ZbTD2wwkz6Wt6KjGm6+ZYwwpdXdwAlzbJYq+LCEMNvDJc4485AQ1vQ==", + "license": "MIT", "dependencies": { - "@fastify/deepmerge": "^1.2.0", + "@fastify/deepmerge": "^3.0.0", "chalk": "^4.1.2", - "chokidar": "^3.5.2", - "close-with-grace": "^1.1.0", + "chokidar": "^4.0.0", + "close-with-grace": "^2.1.0", "commist": "^3.0.0", "dotenv": "^16.0.0", - "fastify": "^4.0.0", - "fastify-plugin": "^4.0.0", + "fastify": "^5.0.0", + "fastify-plugin": "^5.0.0", "generify": "^4.0.0", - "help-me": "^4.0.1", + "help-me": "^5.0.0", "is-docker": "^2.0.0", - "make-promises-safe": "^5.1.0", - "pino-pretty": "^10.1.0", + "pino-pretty": "^13.0.0", "pkg-up": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.3.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "bin": { "fastify": "cli.js" } }, + "node_modules/fastify-cli/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/fastify-mailer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/fastify-mailer/-/fastify-mailer-2.3.1.tgz", @@ -1171,17 +1330,39 @@ "integrity": "sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==" }, "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==" + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" }, "node_modules/fastify-tsconfig": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fastify-tsconfig/-/fastify-tsconfig-2.0.0.tgz", - "integrity": "sha512-pvYwdtbZUJr/aTD7ZE0rGlvtYpx7IThHKVLBoqCKmT3FJpwm23XA2+PDmq8ZzfqqG4ajpyrHd5bkIixcIFjPhQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fastify-tsconfig/-/fastify-tsconfig-3.0.0.tgz", + "integrity": "sha512-TxFM9+MUUM2Ub6chZbP5sPNUFaPWA86kHU0VRd4o9OP6PBP92cj9c4/IEsnLoVHcLgrgXf2GUXWUzkJAO9iKFQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/fastq": { @@ -1193,30 +1374,18 @@ "reusify": "^1.0.4" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-my-way": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", - "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.3.0.tgz", + "integrity": "sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" + "safe-regex2": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/find-up": { @@ -1231,9 +1400,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -1250,28 +1419,16 @@ } } }, - "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -1286,22 +1443,13 @@ "node": ">= 0.6" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/generify": { @@ -1322,10 +1470,48 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-value": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/get-value/-/get-value-3.0.1.tgz", @@ -1337,33 +1523,25 @@ "node": ">=6.0" } }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, + "node_modules/google-libphonenumber": { + "version": "3.2.44", + "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.44.tgz", + "integrity": "sha512-9p2TghluF2LTChFMLWsDRD5N78SZDsILdUk4gyqYxBXluCyxoPiOq+Fqt7DKM+LUd33+OgRkdrc+cPR93AypCQ==", + "license": "(MIT AND Apache-2.0)", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=0.10" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-flag": { @@ -1374,15 +1552,51 @@ "node": ">=8" } }, - "node_modules/help-me": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", - "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { - "glob": "^8.0.0", - "readable-stream": "^3.6.0" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -1394,11 +1608,6 @@ "node": ">=18" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1449,56 +1658,18 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "license": "MIT", "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" + "node": ">= 10" } }, "node_modules/is-docker": { @@ -1515,42 +1686,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1567,11 +1711,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -1580,54 +1719,11 @@ "node": ">=0.10.0" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", "engines": { "node": ">=10" } @@ -1671,53 +1767,77 @@ } } }, - "node_modules/jsdom/node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", - "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", - "dependencies": { - "tr46": "^5.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/json-schema-ref-resolver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", - "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "dequal": "^2.0.3" } }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/light-my-request": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", - "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause", "dependencies": { - "cookie": "^0.7.0", - "process-warning": "^3.0.0", - "set-cookie-parser": "^2.4.1" + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" } }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -1731,35 +1851,11 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" }, "node_modules/make-error": { "version": "1.3.6", @@ -1767,11 +1863,6 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, - "node_modules/make-promises-safe": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/make-promises-safe/-/make-promises-safe-5.1.0.tgz", - "integrity": "sha512-AfdZ49rtyhQR/6cqVKGoH7y4ql7XkS5HJI1lZm0/5N6CQosy1eYbBJ/qbhkKHzo17UH7M918Bysf6XB9f3kS1g==" - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -1780,6 +1871,15 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1791,7 +1891,8 @@ "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" }, "node_modules/mime-db": { "version": "1.52.0", @@ -1812,44 +1913,35 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/mongodb": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.3.0.tgz", - "integrity": "sha512-tt0KuGjGtLUhLoU263+xvQmPHEGTw5LbcNC73EoFRYgSHwZt5tsoJC110hDyO1kjQzpgNrpdcSza9PknWN4LrA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", "dependencies": { - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^6.2.0", - "mongodb-connection-string-url": "^3.0.0" + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" }, "engines": { "node": ">=16.20.1" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", + "snappy": "^7.3.2", "socks": "^2.7.1" }, "peerDependenciesMeta": { @@ -1877,12 +1969,13 @@ } }, "node_modules/mongodb-connection-string-url": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.0.tgz", - "integrity": "sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", "dependencies": { "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^13.0.0" + "whatwg-url": "^14.1.0 || ^13.0.0" } }, "node_modules/ms": { @@ -1891,21 +1984,15 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nodemailer": { - "version": "6.9.9", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz", - "integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", + "peer": true, "engines": { "node": ">=6.0.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nwsapi": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", @@ -1979,45 +2066,18 @@ "node": ">=4" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pino": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", - "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", + "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", "license": "MIT", "dependencies": { + "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", - "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", - "process-warning": "^4.0.0", + "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", @@ -2029,100 +2089,68 @@ } }, "node_modules/pino-abstract-transport": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.1.0.tgz", - "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", "dependencies": { - "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, - "node_modules/pino-abstract-transport/node_modules/readable-stream": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", - "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/pino-abstract-transport/node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", "engines": { "node": ">= 10.x" } }, "node_modules/pino-pretty": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", - "integrity": "sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.2.tgz", + "integrity": "sha512-3cN0tCakkT4f3zo9RXDIhy6GTvtYD6bK4CRBLN9j3E/ePqN1tugAXD5rGVfoChW6s0hiek+eyYlLNqc/BG7vBQ==", + "license": "MIT", "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", - "fast-copy": "^3.0.0", + "fast-copy": "^3.0.2", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^1.0.0", + "pino-abstract-transport": "^2.0.0", "pump": "^3.0.0", - "readable-stream": "^4.0.0", - "secure-json-parse": "^2.4.0", - "sonic-boom": "^3.0.0", - "strip-json-comments": "^3.1.1" + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, - "node_modules/pino-pretty/node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" - }, - "node_modules/pino-pretty/node_modules/readable-stream": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", - "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/pino-std-serializers": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", "license": "MIT" }, - "node_modules/pino/node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", "dependencies": { - "split2": "^4.0.0" + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/pino/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", "funding": [ { "type": "github", @@ -2135,61 +2163,6 @@ ], "license": "MIT" }, - "node_modules/pino/node_modules/sonic-boom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", - "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/pino/node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "engines": { - "node": ">= 0.6.0" - } - }, - "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/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -2242,14 +2215,16 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/real-require": { @@ -2271,6 +2246,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2279,6 +2255,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2297,9 +2274,9 @@ } }, "node_modules/ret": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", - "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "license": "MIT", "engines": { "node": ">=10" @@ -2316,9 +2293,10 @@ } }, "node_modules/rfdc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" }, "node_modules/rrweb-cssom": { "version": "0.6.0", @@ -2354,12 +2332,22 @@ ] }, "node_modules/safe-regex2": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", - "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ret": "~0.4.0" + "ret": "~0.5.0" } }, "node_modules/safe-stable-stringify": { @@ -2388,17 +2376,26 @@ } }, "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2407,34 +2404,16 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, "node_modules/shell-quote": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", @@ -2444,21 +2423,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sonic-boom": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.0.tgz", - "integrity": "sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" } @@ -2467,6 +2436,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", "dependencies": { "memory-pager": "^1.0.2" } @@ -2516,6 +2486,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2529,6 +2500,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2537,11 +2509,12 @@ } }, "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2567,58 +2540,6 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -2633,18 +2554,6 @@ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", @@ -2676,14 +2585,15 @@ } }, "node_modules/tr46": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", - "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", "dependencies": { - "punycode": "^2.3.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/tree-kill": { @@ -2756,11 +2666,20 @@ "node": ">= 0.6" } }, + "node_modules/typebox": { + "version": "1.0.53", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.0.53.tgz", + "integrity": "sha512-fCi3wnKP4owdhs+LRIUfkPzR4p5RLa3tM4O6gqO7TeAo6SsamvqA59yPb1GyKDcCgB/ClmifVJ9BqQ128a4uvQ==", + "license": "MIT", + "peer": true + }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2770,10 +2689,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/universalify": { "version": "0.2.0", @@ -2783,14 +2703,6 @@ "node": ">= 4.0.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -2811,28 +2723,6 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, - "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2888,35 +2778,23 @@ } }, "node_modules/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", "dependencies": { - "tr46": "^4.1.1", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=16" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" + "node": ">=18" } }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -2972,19 +2850,16 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "engines": { "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -3002,6 +2877,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "engines": { "node": ">=12" } @@ -3014,17 +2890,6 @@ "engines": { "node": ">=6" } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index 8362587..be2210c 100644 --- a/package.json +++ b/package.json @@ -7,46 +7,55 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && tsc -p test/tsconfig.json && c8 node --test -r ts-node/register test/**/*.ts", - "start": "npm run build:ts && npm run copy:assets && npm run hav:init-mongodb && fastify start -l info dist/app.js", + "test": "npm run build:ts && node --test --test-reporter=tap --experimental-test-coverage --test-force-exit -r ts-node/register test/**/*.test.ts", + "test:ci": "mkdir -p coverage && node --test --test-reporter=lcov --experimental-test-coverage --test-reporter-destination coverage/lcov.info --test-force-exit -r ts-node/register test/**/*.test.ts", + "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", - "watch:ts": "npm run copy:assets; tsc -w", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", - "dev": "npm run copy:assets; npm run build:ts && npm run hav:init-mongodb && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"", - "dev:start": "npm run copy:assets; fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", + "dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"tsc -w\" \"npm:dev:start\"", + "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", "info": "fastify print-routes ./routes/root.ts", + "lint": "biome check --write src/", + "lint:check": "biome check src/", "hav:init-mongodb": "node dist/bin/hav-init-mongodb.js", - "hav:populate-email-queue": "node dist/bin/hav-populate-email-queue.js", - "hav:send-emails-in-queue": "node dist/bin/hav-send-emails-in-queue.js" + "hav:update-subscription-length": "node dist/bin/hav-update-subscription-length.js", + "hav:populate-queue": "node dist/bin/hav-populate-queue.js", + "hav:send-queue": "node dist/bin/hav-send-queue.js", + "hav:test-sms-sending": "node dist/bin/hav-test-sms-sending.js", + "hav:test-email-templates": "node dist/bin/hav-test-email-templates.js", + "hav:run-dialogi-test-server": "node dist/bin/hav-dialogi-test-server.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { - "@fastify/autoload": "^5.0.0", - "@fastify/mongodb": "^8.0.0", - "@fastify/sensible": "^5.0.0", - "@fastify/type-provider-typebox": "^4.0.0", - "@immobiliarelabs/fastify-sentry": "^8.0.1", - "@sinclair/typebox": "^0.32.9", + "@fastify/autoload": "^6.3.1", + "@fastify/mongodb": "^9.0.2", + "@fastify/sensible": "^6.0.3", + "@fastify/type-provider-typebox": "^6.1.0", + "@immobiliarelabs/fastify-sentry": "^9.0.1", + "@sinclair/typebox": "^0.34.41", "axios": "^1.6.7", - "c8": "^9.1.0", "dotenv": "^16.3.1", - "fastify": "^4.0.0", - "fastify-cli": "^6.0.1", + "fastify": "^5.6.2", + "fastify-cli": "^7.4.1", "fastify-mailer": "^2.3.1", - "fastify-plugin": "^4.0.0", + "fastify-plugin": "^5.1.0", + "google-libphonenumber": "^3.2.44", "jsdom": "^24.0.0", - "nodemailer": "^6.9.9", + "minimist": "^1.2.8", + "nodemailer": "^7.0.10", "sprightly": "^2.0.1" }, "devDependencies": { + "@biomejs/biome": "^2.2.4", + "@types/google-libphonenumber": "^7.4.30", "@types/jsdom": "^21.1.6", - "@types/node": "^20.4.4", + "@types/minimist": "^1.2.5", + "@types/node": "^22.19.1", "@types/nodemailer": "^6.4.14", - "@types/tap": "^15.0.5", "concurrently": "^8.2.2", - "fastify-tsconfig": "^2.0.0", + "fastify-tsconfig": "^3.0.0", "ts-node": "^10.4.0", "typescript": "^5.2.2" } diff --git a/pipelines/hakuvahti-staging.yml b/pipelines/hakuvahti-staging.yml deleted file mode 100644 index 31cbc08..0000000 --- a/pipelines/hakuvahti-staging.yml +++ /dev/null @@ -1,47 +0,0 @@ -# Continuous integration (CI) triggers cause a pipeline to run whenever you push -# an update to the specified branches or you push specified tags. -trigger: - batch: true - branches: - include: - - main - paths: - exclude: - - README.md - -# Pull request (PR) triggers cause a pipeline to run whenever a pull request is -# opened with one of the specified target branches, or when updates are made to -# such a pull request. -# -# GitHub creates a new ref when a pull request is created. The ref points to a -# merge commit, which is the merged code between the source and target branches -# of the pull request. -# -# Opt out of pull request validation -pr: none - -# By default, use self-hosted agents -pool: Default - -# Image tag name for Fuse projects -#parameters: -#- name: imagetag -# displayName: Image tag to be built and/or deployed -# type: string -# default: latest - -resources: - repositories: - # Azure DevOps repository - - repository: helfi-rekry-pipelines - type: git - # Azure DevOps project/repository - name: helfi-rekry/helfi-rekry-pipelines - -extends: - # Filename in Azure DevOps Repository (note possible -ui or -api) - # Django example: azure-pipelines-PROJECTNAME-api-release.yml - # Drupal example: azure-pipelines-drupal-release.yml - template: components/hakuvahti/pipelines/hakuvahti-staging.yml@helfi-rekry-pipelines - #parameters: - #imagetag: ${{ parameters.imagetag }} diff --git a/pipelines/hakuvahti-test.yml b/pipelines/hakuvahti-test.yml deleted file mode 100644 index 57885c8..0000000 --- a/pipelines/hakuvahti-test.yml +++ /dev/null @@ -1,47 +0,0 @@ -# Continuous integration (CI) triggers cause a pipeline to run whenever you push -# an update to the specified branches or you push specified tags. -trigger: - batch: true - branches: - include: - - dev - paths: - exclude: - - README.md - -# Pull request (PR) triggers cause a pipeline to run whenever a pull request is -# opened with one of the specified target branches, or when updates are made to -# such a pull request. -# -# GitHub creates a new ref when a pull request is created. The ref points to a -# merge commit, which is the merged code between the source and target branches -# of the pull request. -# -# Opt out of pull request validation -pr: none - -# By default, use self-hosted agents -pool: Default - -# Image tag name for Fuse projects -#parameters: -#- name: imagetag -# displayName: Image tag to be built and/or deployed -# type: string -# default: latest - -resources: - repositories: - # Azure DevOps repository - - repository: helfi-rekry-pipelines - type: git - # Azure DevOps project/repository - name: helfi-rekry/helfi-rekry-pipelines - -extends: - # Filename in Azure DevOps Repository (note possible -ui or -api) - # Django example: azure-pipelines-PROJECTNAME-api-release.yml - # Drupal example: azure-pipelines-drupal-release.yml - template: components/hakuvahti/pipelines/hakuvahti-test.yml@helfi-rekry-pipelines - #parameters: - #imagetag: ${{ parameters.imagetag }} diff --git a/pipelines/hakuvahti-production.yml b/pipelines/helfi-hakuvahti-dev.yml similarity index 67% rename from pipelines/hakuvahti-production.yml rename to pipelines/helfi-hakuvahti-dev.yml index da67476..f2489e5 100644 --- a/pipelines/hakuvahti-production.yml +++ b/pipelines/helfi-hakuvahti-dev.yml @@ -1,6 +1,18 @@ # Continuous integration (CI) triggers cause a pipeline to run whenever you push # an update to the specified branches or you push specified tags. -trigger: none +trigger: + batch: true + branches: + include: + - dev + paths: + exclude: + - '*release-please*' + - '*.md' + - '.github/' + - 'pipelines/helfi-hakuvahti-release.yml' + - 'pipelines/helfi-hakuvahti-review.yml' + - '*compose*' # Pull request (PR) triggers cause a pipeline to run whenever a pull request is # opened with one of the specified target branches, or when updates are made to @@ -19,18 +31,18 @@ pool: Default resources: repositories: # Azure DevOps repository - - repository: helfi-rekry-pipelines + - repository: helfi-hakuvahti-pipelines type: git # Azure DevOps project/repository - name: helfi-rekry/helfi-rekry-pipelines + name: helfi-hakuvahti/helfi-hakuvahti-pipelines extends: # Filename in Azure DevOps Repository - template: components/hakuvahti/pipelines/hakuvahti-production.yml@helfi-rekry-pipelines + template: components/helfi-hakuvahti/pipelines/helfi-hakuvahti-dev.yml@helfi-hakuvahti-pipelines # parameters: # Application build arguments and config map values as key value pairs. - # Does not contain all buildArguments or configMap values, the rest located in helfi-rekry-pipelines - # The values here will override the values defined in the helfi-rekry-pipelines repository + # Does not contain all buildArguments or configMap values, the rest located in helfi-hakuvahti-pipelines + # The values here will override the values defined in the helfi-hakuvahti-pipelines repository # buildArgs: # DEBUG: 1 # configMap: # pod environment variables diff --git a/pipelines/helfi-hakuvahti-release.yml b/pipelines/helfi-hakuvahti-release.yml new file mode 100644 index 0000000..19988b7 --- /dev/null +++ b/pipelines/helfi-hakuvahti-release.yml @@ -0,0 +1,46 @@ +# Continuous integration (CI) triggers cause a pipeline to run whenever you push +# an update to the specified branches or you push specified tags. +trigger: + batch: true + tags: + include: + - '*' + +# Pull request (PR) triggers cause a pipeline to run whenever a pull request is +# opened with one of the specified target branches, or when updates are made to +# such a pull request. +# +# GitHub creates a new ref when a pull request is created. The ref points to a +# merge commit, which is the merged code between the source and target branches +# of the pull request. +# +# Opt out of pull request validation +pr: none + +# By default, use self-hosted agents +pool: Default + +resources: + repositories: + # Azure DevOps repository + - repository: helfi-hakuvahti-pipelines + type: git + # Azure DevOps project/repository + name: helfi-hakuvahti/helfi-hakuvahti-pipelines + +extends: + # Filename in Azure DevOps Repository + template: components/helfi-hakuvahti/pipelines/helfi-hakuvahti-release.yml@helfi-hakuvahti-pipelines + # parameters: + # Application build arguments and config map values as key value pairs. + # Does not contain all buildArguments or configMap values, the rest located in helfi-hakuvahti-pipelines + # The values here will override the values defined in the helfi-hakuvahti-pipelines repository + ## Staging definitions + # buildArgsStage: + # DEBUG: 1 + # configMapStage: # pod environment variables + # DEBUG: 1 + ## Production definitions + ## Production is using staging image + # configMap: # pod environment variables + # DEBUG: 1 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..8dd7264 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,7 @@ +sonar.projectKey=City-of-Helsinki_helfi-hakuvahti +sonar.organization=city-of-helsinki +sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.inclusions=**/*.ts,openshift/Dockerfile +sonar.exclusions=test/**/* +sonar.coverage.exclusions=src/bin/*test*,src/bin/hav-update-subscription-length.ts +sonar.test.inclusions=test/**/*.ts diff --git a/src/app.ts b/src/app.ts index 1fcb083..c3c6945 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,50 +1,67 @@ -import { join } from 'path'; -import AutoLoad, { AutoloadPluginOptions } from '@fastify/autoload'; -import { FastifyPluginAsync, FastifyServerOptions } from 'fastify'; +import { join } from 'node:path'; +import AutoLoad, { type AutoloadPluginOptions } from '@fastify/autoload'; +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import type { FastifyPluginAsync, FastifyPluginOptions } from 'fastify'; import { Environment } from './types/environment'; -export interface AppOptions extends FastifyServerOptions, Partial { +export interface AppOptions extends FastifyPluginOptions, Partial {} -} // Pass --options via CLI arguments in command to enable these options. -const options: AppOptions = { -} - -const app: FastifyPluginAsync = async ( - fastify, - opts -): Promise => { - if (process.env.ENVIRONMENT === undefined) { - throw new Error('ENVIRONMENT environment variable is not set') +export const options: AppOptions = {}; + +const requiredEnvironmentVariables = ['ENVIRONMENT', 'HAKUVAHTI_API_KEY']; + +const app: FastifyPluginAsync = async (fastify, opts) => { + // Skip override option breaks fastify encapsulation. + // This is used by tests to get access to plugins + // registered by application. + delete opts.skipOverride; + + for (const envVar of requiredEnvironmentVariables) { + if (process.env[envVar] === undefined) { + throw new Error(`${envVar} environment variable is not set`); + } } - const env = process.env.ENVIRONMENT as Environment + const env = process.env.ENVIRONMENT as Environment; if (!Object.values(Environment).includes(env)) { - throw new Error('ENVIRONMENT environment variable is not valid') + throw new Error('ENVIRONMENT environment variable is not valid'); } - const release = process.env.SENTRY_RELEASE ?? ''; - fastify.register(require('@immobiliarelabs/fastify-sentry'), { + fastify.register(fastifySentry, { dsn: process.env.SENTRY_DSN, + beforeSend: (event) => { + if (!event?.request?.data) { + return event; + } + + const data = JSON.parse(event.request.data); + + if (!data.email) { + return event; + } + + delete data.email; + event.request.data = JSON.stringify(data); + + return event; + }, environment: env, - release: release, - setErrorHandler: true - }) - - await Promise.all([ - fastify.register(AutoLoad, { - dir: join(__dirname, 'plugins'), - options: opts, - ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/ - }), - fastify.register(AutoLoad, { - dir: join(__dirname, 'routes'), - options: opts, - ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/ - }) - ]) -} + release: process.env.SENTRY_RELEASE ?? '', + setErrorHandler: true, + }); + + fastify.register(AutoLoad, { + dir: join(__dirname, 'plugins'), + options: opts, + ignorePattern: /(^|\/|\\)(index|\.d).*\.ts$/, + }); + fastify.register(AutoLoad, { + dir: join(__dirname, 'routes'), + options: opts, + ignorePattern: /(^|\/|\\)(index|\.d).*\.ts$/, + }); +}; export default app; -export { app, options } diff --git a/src/bin/hav-dialogi-test-server.ts b/src/bin/hav-dialogi-test-server.ts new file mode 100644 index 0000000..df08e20 --- /dev/null +++ b/src/bin/hav-dialogi-test-server.ts @@ -0,0 +1,88 @@ +import dotenv from 'dotenv'; +import fastify from 'fastify'; + +dotenv.config(); + +/** + * Mock Dialogi SMS API server for local testing + * + * This is a minimal HTTP server that mimics the Elisa Dialogi API responses. + * Use this for local development when you don't have access to the real Dialogi API. + * + * Usage: + * 1. Run: npm run hav:run-dialogi-test-server + * 2. Set in .env: DIALOGI_API_URL=http://localhost:3001/sms + * 3. Test your SMS pipeline locally + */ + +const PORT = 3001; + +const server = fastify({ + logger: true, +}); + +// Mock Dialogi SMS endpoint +server.post('/sms', async (request, reply) => { + const body = request.body as { + sender?: string; + destination?: string; + text?: string; + }; + + const { sender, destination, text } = body; + + // Log the "sent" SMS + server.log.info('MOCK SMS SENT'); + server.log.info(`From: ${sender || 'unknown'}`); + server.log.info(`To: ${destination || 'unknown'}`); + server.log.info(`Message: ${text || 'empty'}`); + + // Return a mock Dialogi API response (based on their API structure) + const mockMessageId = `mock-${Date.now()}-${Math.random().toString(36).substring(7)}`; + + return reply.code(200).send({ + messages: [ + { + [destination || 'unknown']: { + converted: destination, + status: 'OK', + reason: null, + messageid: mockMessageId, + }, + }, + ], + warnings: [], + errors: [], + }); +}); + +// Health check endpoint +server.get('/health', async (_request, reply) => { + return reply.code(200).send({ + status: 'ok', + service: 'Mock Dialogi API', + timestamp: new Date().toISOString(), + }); +}); + +// Start server +const start = async () => { + try { + await server.listen({ port: PORT, host: '0.0.0.0' }); + console.log(''); + console.log('Mock Dialogi SMS API Server Running'); + console.log(''); + console.log(`Server listening on: http://localhost:${PORT}`); + console.log(`SMS endpoint: http://localhost:${PORT}/sms`); + console.log(`Health check: http://localhost:${PORT}/health`); + console.log(''); + console.log('To use in your .env file:'); + console.log(`DIALOGI_API_URL=http://localhost:${PORT}/sms`); + console.log(''); + } catch (err) { + server.log.error(err); + process.exit(1); + } +}; + +start(); diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 91e8184..371d458 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -1,103 +1,121 @@ -import fastify from 'fastify' +/** + * MongoDB Database Initialization Script + * + * Creates required collections with validation schemas for the Hakuvahti application: + * - queue: Queue for outbound notifications + * - subscription: Search subscriptions with user preferences + * + * Must be run before starting the application to ensure proper database structure. + */ + +import command from '../lib/command'; import mongodb from '../plugins/mongodb'; -import dotenv from 'dotenv' -dotenv.config() +command( + async (server) => { + const db = server.mongo.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } -const server = fastify({}) + // Check if collections exist + const collections = await db.listCollections().toArray(); + const existingCollections = collections.map((c) => c.name); -// Register only needed plugins -void server.register(mongodb) + let queueResult = null; + let subscriptionResult = null; -const app = async (): Promise<{}> => { - const createQueue = await server.mongo.db?.createCollection("queue", { - validator: { + // Queue collection: stores pending notifications + const queueValidator = { $jsonSchema: { - bsonType: "object", - title: "Hakuvahti email queue", - required: ["email", "content"], + bsonType: 'object', + title: 'Hakuvahti notification queue', + required: ['type', 'atv_id', 'content'], properties: { _id: { - "bsonType": "objectId" - }, - email: { - bsonType: "string", - }, - content: { - bsonType: "string", - } - } - } - } - }) - - const createSubscription = await server.mongo.db?.createCollection("subscription", { - validator: { - $jsonSchema: { - bsonType: "object", - title: "Hakuvahti entries", - required: ["email", "elastic_query", "query"], - properties: { - _id: { - "bsonType": "objectId" - }, - email: { - bsonType: "string", + bsonType: 'objectId', }, - elastic_query: { - bsonType: "string", + type: { + bsonType: 'string', + enum: ['email', 'sms'], }, - query: { - bsonType: "string", + atv_id: { + bsonType: 'string', }, - hash: { - bsonType: "string", - }, - expiry_notification_sent: { - bsonType: "int", - minimum: 0, - maximum: 1, - }, - status: { - bsonType: "int", - minimum: 0, - maximum: 2, - }, - last_checked: { - bsonType: "int" - }, - modified: { - bsonType: "date" + content: { + bsonType: 'string', }, - created: { - bsonType: "date" - } - } - } - } - }) + }, + }, + }; - server.log.debug(createQueue) - server.log.debug(createSubscription) - - return {} -} - -server.get('/', async function (request, reply) { - return await app() -}) + if (!existingCollections.includes('queue')) { + queueResult = await db.createCollection('queue', { validator: queueValidator }); + console.info('Queue collection created:', queueResult?.collectionName); + } else { + await db.command({ collMod: 'queue', validator: queueValidator }); + console.info('Queue collection validator updated'); + } -server.ready((err) => { - console.log('fastify server ready') - server.inject({ - method: 'GET', - url: '/' - }, (err, response) => { - if (response) { - console.log(JSON.parse(response.payload)) + // Drop legacy smsqueue collection if it exists + if (existingCollections.includes('smsqueue')) { + await db.collection('smsqueue').drop(); + console.info('Dropped legacy smsqueue collection'); } - server.close() - }) + // Subscription collection: stores user search criteria and metadata + if (!existingCollections.includes('subscription')) { + subscriptionResult = await db.createCollection('subscription', { + validator: { + $jsonSchema: { + bsonType: 'object', + title: 'Hakuvahti entries', + required: ['email', 'elastic_query', 'query', 'site_id'], + properties: { + _id: { + bsonType: 'objectId', + }, + email: { + bsonType: 'string', + }, + elastic_query: { + bsonType: 'string', + }, + query: { + bsonType: 'string', + }, + site_id: { + bsonType: 'string', + }, + hash: { + bsonType: 'string', + }, + expiry_notification_sent: { + bsonType: 'int', + minimum: 0, + maximum: 1, + }, + status: { + bsonType: 'int', + minimum: 0, // 0: unconfirmed, 1: active, 2: expired + maximum: 2, + }, + last_checked: { + bsonType: 'int', + }, + modified: { + bsonType: 'date', + }, + created: { + bsonType: 'date', + }, + }, + }, + }, + }); -}) + console.info('Subscription collection created:', subscriptionResult?.collectionName); + } + }, + [mongodb], +); diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts deleted file mode 100644 index 596ce2a..0000000 --- a/src/bin/hav-populate-email-queue.ts +++ /dev/null @@ -1,235 +0,0 @@ -import fastify from 'fastify' -import mongodb from '../plugins/mongodb' -import elasticproxy from '../plugins/elasticproxy' -import dotenv from 'dotenv' -import { SubscriptionCollectionLanguageType, SubscriptionCollectionType, SubscriptionStatus } from '../types/subscription' -import decode from '../plugins/base64' -import encode from '../plugins/base64' -import '../plugins/sentry' -import { - ElasticProxyJsonResponseType, - PartialDrupalNodeType -} from '../types/elasticproxy' -import { expiryEmail, newHitsEmail } from '../lib/email' -import { QueueInsertDocumentType } from '../types/mailer' - -dotenv.config() - -const server = fastify({}) -const release = process.env.SENTRY_RELEASE ?? ''; - -server.register(require('@immobiliarelabs/fastify-sentry'), { - dsn: process.env.SENTRY_DSN, - environment: process.env.ENVIRONMENT, - release: release, - setErrorHandler: true -}) - -// Register only needed plugins -void server.register(mongodb) -void server.register(elasticproxy) -void server.register(encode) -void server.register(decode) - -export const localizedEnvVar = (envVarBase: string, langCode: SubscriptionCollectionLanguageType): string | undefined => { - return process.env[`${envVarBase}_${langCode.toUpperCase()}`] -} - -// Command line/cron application -// to query for new results for subscriptions from -// ElasticProxy and add them to email queue - -/** - * Deletes subscriptions older than a specified number of days with a certain status. - * - * @param {SubscriptionStatus} modifyStatus - the status to modify subscriptions - * @param {number} olderThanDays - the number of days to consider for deletion - * @return {Promise} Promise that resolves when the subscriptions are deleted - */ -const massDeleteSubscriptions = async (modifyStatus: SubscriptionStatus, olderThanDays: number): Promise => { - const collection = server.mongo.db?.collection('subscription') - if (collection) { - const dateLimit: Date = new Date(Date.now() - (olderThanDays * 24 * 60 * 60 * 1000)) - try { - await collection.deleteMany({ status: modifyStatus, created: { $lt: dateLimit } }) - } catch (error) { - console.error(error) - - throw new Error('Could not delete subscriptions. See logs for errors.') - } - } -} - -/** - * Checks if an expiry notification should be sent for a given subscription. - * - * @param {Partial} subscription - The subscription to check. - * @return {boolean} Returns true if an expiry notification should be sent, false otherwise. - */ -const checkShouldSendExpiryNotification = (subscription: Partial): boolean => { - // Technically this is never missing but using Partial<> causes typing errors with created date otherwise... - if (!subscription.created) { - return false - } - - // Notification already sent - if (subscription.expiry_notification_sent === 1) { - return false - } - - const daysBeforeExpiry = process.env.SUBSCRIPTION_EXPIRY_NOTIFICATION_DAYS ? parseInt(process.env.SUBSCRIPTION_EXPIRY_NOTIFICATION_DAYS) : 3 - const subscriptionValidForDays = process.env.SUBSCRIPTION_MAX_AGE ? parseInt(process.env.SUBSCRIPTION_MAX_AGE) : 0 - const subscriptionExpiresAt = new Date(subscription.created).getTime() + (subscriptionValidForDays * 24 * 60 * 60 * 1000) - const subscriptionExpiryNotificationSentAt = new Date(subscriptionExpiresAt - (daysBeforeExpiry * 24 * 60 * 60 * 1000)) - - return Date.now() >= subscriptionExpiryNotificationSentAt.getTime() -} - -const getNewHitsFromElasticsearch = async (subscription: any): Promise => { - const elasticQuery: string = server.b64decode(subscription.elastic_query) - const lastChecked: number = subscription.last_checked ? subscription.last_checked : Math.floor(new Date().getTime() / 1000) - - try { - // Query for new results from ElasticProxy - const elasticResponse: ElasticProxyJsonResponseType = await server.queryElasticProxy(elasticQuery) - - // Filter out new hits: - return (elasticResponse?.hits?.hits ?? []) - .filter((hit: { _source: { field_publication_starts: number[]; }; }) => hit._source.field_publication_starts[0] >= lastChecked) - .map((hit: { _source: PartialDrupalNodeType; }) => hit._source) - - } catch (err) { - console.error(`Query ${elasticQuery} for ${subscription._id} failed`) - server.Sentry?.captureException(err) - } - - return [] -} - -/** - * Performs checking for new results for subscriptions and sends out emails based on the query results. - * - * @return {Promise<{}>} A Promise that resolves to an empty object. - */ -const app = async (): Promise<{}> => { - try { - // Subscriptions - const collection = server.mongo.db!.collection('subscription') - - // Email queue - const queueCollection = server.mongo.db!.collection('queue') - - // List of all enabled subscriptions - const result = await collection.find({ status: SubscriptionStatus.ACTIVE }).toArray() - - for (const subscription of result) { - const localizedBaseUrl = localizedEnvVar('BASE_URL', subscription.lang) - - // If subscription should expire soon, send an expiration email - if (checkShouldSendExpiryNotification(subscription as Partial)) { - await collection.updateOne( - { _id: subscription._id }, - { $set: { expiry_notification_sent: 1 } } - ) - - const subscriptionValidForDays = process.env.SUBSCRIPTION_MAX_AGE ? parseInt(process.env.SUBSCRIPTION_MAX_AGE) : 0 - const subscriptionExpiresAt = new Date(subscription.created).getTime() + (subscriptionValidForDays * 24 * 60 * 60 * 1000) - const subscriptionExpiresAtDate = new Date(subscriptionExpiresAt) - const day = String(subscriptionExpiresAtDate.getDate()).padStart(2, '0') - const month = String(subscriptionExpiresAtDate.getMonth() + 1).padStart(2, '0') // Months are 0-based - const year = subscriptionExpiresAtDate.getFullYear() - const formattedExpiryDate = `${day}.${month}.${year}` - - const expiryEmailContent = await expiryEmail(subscription.lang, { - search_description: subscription.search_description, - link: process.env.BASE_URL + subscription.query, - removal_date: formattedExpiryDate, - remove_link: localizedBaseUrl + '/hakuvahti/unsubscribe?subscription=' + subscription._id + '&hash=' + subscription.hash, - }) - - const expiryEmailToQueue:QueueInsertDocumentType = { - email: subscription.email, - content: expiryEmailContent - } - - // Add email to queue - await queueCollection.insertOne(expiryEmailToQueue) - } - - const newHits = await getNewHitsFromElasticsearch(subscription) - - // No new hits - if (newHits.length === 0) { - continue - } - - // Email content object - - // Format Mongo DateTime to EU format for email. - const createdDate: string = new Date(subscription.created).toISOString().substring(0, 10) - const date = new Date(createdDate); - const pad = (n: number) => n.toString().padStart(2, '0'); - const formattedCreatedDate = `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; - - const emailContent = await newHitsEmail(subscription.lang, { - created_date: formattedCreatedDate, - search_description: subscription.search_description, - search_link: subscription.query, - remove_link: localizedBaseUrl + '/hakuvahti/unsubscribe?subscription=' + subscription._id + '&hash=' + subscription.hash, - hits: newHits - }) - - const email:QueueInsertDocumentType = { - email: subscription.email, - content: emailContent - } - - // Add email to queue - await queueCollection.insertOne(email) - - // Set last checked timestamp to this moment - const dateUnixtime: number = Math.floor(new Date().getTime() / 1000) - - await collection.updateOne( - { _id: subscription._id }, - { $set: { last_checked: dateUnixtime } } - ) - } - } catch (error) { - console.error(error) - server.Sentry?.captureException(error) - } - - server.Sentry.captureCheckIn({monitorSlug: 'hav-populate-email-queue', status: 'ok'}) - return {} -}; - -server.get('/', async function (request, reply) { - // Maximum subscription age from configuration - const unconfirmedSubscriptionMaxAge: number = process.env.UNCONFIRMED_SUBSCRIPTION_MAX_AGE ? parseInt(process.env.UNCONFIRMED_SUBSCRIPTION_MAX_AGE) : 30 - const confirmedSubscriptionMaxAge: number = process.env.SUBSCRIPTION_MAX_AGE ? parseInt(process.env.SUBSCRIPTION_MAX_AGE) : 90 - - // Remove expired subscriptions that haven't been confirmed - await massDeleteSubscriptions(SubscriptionStatus.INACTIVE, unconfirmedSubscriptionMaxAge) - - // Remove expired subscriptions - await massDeleteSubscriptions(SubscriptionStatus.ACTIVE, confirmedSubscriptionMaxAge) - - // Loop through subscriptions and add new results to email queue - return await app() -}) - -server.ready((err) => { - console.log('fastify server ready') - server.inject({ - method: 'GET', - url: '/' - }, (err, response) => { - if (response) { - console.log(JSON.parse(response.payload)) - } - - server.close() - }) - -}) diff --git a/src/bin/hav-populate-queue.ts b/src/bin/hav-populate-queue.ts new file mode 100644 index 0000000..25c475d --- /dev/null +++ b/src/bin/hav-populate-queue.ts @@ -0,0 +1,548 @@ +import type { ObjectId } from '@fastify/mongodb'; +import command, { type Server } from '../lib/command'; +import { expiryEmail, newHitsEmail, newHitsSms, renewalSms } from '../lib/email'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import { generateUniqueSmsCode } from '../lib/smsCode'; +import atv from '../plugins/atv'; +import base64Plugin from '../plugins/base64'; +import elasticproxy from '../plugins/elasticproxy'; +import mongodb from '../plugins/mongodb'; +import '../plugins/sentry'; +import type { ElasticProxyJsonResponseType, PartialDrupalNodeType } from '../types/elasticproxy'; +import type { QueueInsertDocument } from '../types/queue'; +import type { SiteConfigurationType } from '../types/siteConfig'; +import { + type SubscriptionCollectionLanguageType, + type SubscriptionCollectionType, + SubscriptionStatus, +} from '../types/subscription'; + +// Statistics tracking +interface ProcessingStats { + sitesProcessed: number; + subscriptionsChecked: number; + expiryEmailsQueued: number; + newResultsEmailsQueued: number; + smsQueued: number; +} + +export const getLocalizedUrl = ( + siteConfig: SiteConfigurationType, + langCode: SubscriptionCollectionLanguageType, +): string => { + const langKey = langCode.toLowerCase() as keyof typeof siteConfig.urls; + if (langKey in siteConfig.urls) { + return siteConfig.urls[langKey]; + } + return siteConfig.urls.base; +}; + +// Command line/cron application +// to query for new results for subscriptions from +// ElasticProxy and add them to email queue + +/** + * Deletes subscriptions older than a specified number of days with a certain status for a specific site. + * + * @param server - fastify instance. + * @param modifyStatus - the status to modify subscriptions + * @param olderThanDays - the number of days to consider for deletion + * @param siteId - the site ID to filter subscriptions + * @return {Promise} Promise that resolves when the subscriptions are deleted + */ +const massDeleteSubscriptions = async ( + server: Server, + modifyStatus: SubscriptionStatus, + olderThanDays: number, + siteId: string, +): Promise => { + const collection = server.mongo.db?.collection('subscription'); + if (collection) { + const dateLimit: Date = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000); + try { + await collection.deleteMany({ + status: modifyStatus, + site_id: siteId, + created: { $lt: dateLimit }, + }); + } catch (error) { + console.error(error); + + throw new Error('Could not delete subscriptions. See logs for errors.'); + } + } +}; + +/** + * Checks if an expiry notification should be sent for a given subscription. + * + * @param {Partial} subscription - The subscription to check. + * @param {SiteConfiguration} siteConfig - The site configuration for the subscription. + * @return {boolean} Returns true if an expiry notification should be sent, false otherwise. + */ +const checkShouldSendExpiryNotification = ( + subscription: Partial, + siteConfig: SiteConfigurationType, +): boolean => { + // Technically this is never missing but using Partial<> causes typing errors with created date otherwise... + if (!subscription.created) { + return false; + } + + // Notification already sent + if (subscription.expiry_notification_sent === 1) { + return false; + } + + const daysBeforeExpiry = siteConfig.subscription.expiryNotificationDays; + const subscriptionValidForDays = siteConfig.subscription.maxAge; + const subscriptionExpiresAt = + new Date(subscription.created).getTime() + subscriptionValidForDays * 24 * 60 * 60 * 1000; + const subscriptionExpiryNotificationSentAt = new Date(subscriptionExpiresAt - daysBeforeExpiry * 24 * 60 * 60 * 1000); + + return Date.now() >= subscriptionExpiryNotificationSentAt.getTime(); +}; + +/** + * Calculates the expected delete_after date based on subscription created date and site config maxAge. + * + * @param createdDate - The subscription creation date + * @param maxAge - Number of days until deletion from site config + * @return The calculated delete_after date + */ +export const calculateExpectedDeleteAfter = (createdDate: Date, maxAge: number): Date => { + const deleteAfter = new Date(createdDate); + deleteAfter.setDate(deleteAfter.getDate() + maxAge); + return deleteAfter; +}; + +/** + * Checks if the subscription's delete_after needs to be synced with ATV. + * Compares stored delete_after with expected value based on current site config. + * + * @param storedDeleteAfter - The stored delete_after date from subscription (may be undefined) + * @param expectedDeleteAfter - The expected delete_after date based on current config + * @return True if sync is needed (missing or mismatched delete_after) + */ +export const needsDeleteAfterSync = (storedDeleteAfter: Date | undefined, expectedDeleteAfter: Date): boolean => { + if (!storedDeleteAfter) { + return true; + } + + const storedDate = new Date(storedDeleteAfter); + // Compare dates by their date string (YYYY-MM-DD) + return storedDate.toISOString().substring(0, 10) !== expectedDeleteAfter.toISOString().substring(0, 10); +}; + +const getNewHitsFromElasticsearch = async ( + subscription: SubscriptionCollectionType & { _id: ObjectId }, + siteConfig: SiteConfigurationType, + server: Server, +): Promise => { + let elasticQuery: string; + + if (subscription.elastic_query_atv) { + try { + const atvContent = await server.atvGetDocument(subscription.elastic_query as string); + const queryObj = typeof atvContent === 'string' ? JSON.parse(atvContent) : atvContent; + elasticQuery = server.b64decode(queryObj.elastic_query); + } catch (e) { + console.error(`Failed to load query from ATV for ${subscription._id}`, e); + return []; + } + } else if (subscription.elastic_query) { + elasticQuery = server.b64decode(subscription.elastic_query); + } else { + console.error(`Subscription ${subscription._id} has neither elastic_query nor elastic_query_atv`); + return []; + } + + const lastChecked: number = subscription.last_checked ? subscription.last_checked : Math.floor(Date.now() / 1000); + + try { + // Query for new results from ElasticProxy + const elasticResponse: ElasticProxyJsonResponseType = await server.queryElasticProxy( + siteConfig.elasticProxyUrl, + elasticQuery, + ); + + // Filter out new hits: + return (elasticResponse?.hits?.hits ?? []) + .filter((hit: { _source?: PartialDrupalNodeType }) => { + const publicationStarts = hit?._source?.field_publication_starts; + if (!Array.isArray(publicationStarts) || publicationStarts.length === 0) { + return false; + } + return publicationStarts[0] >= lastChecked; + }) + .map((hit: { _source: PartialDrupalNodeType }) => hit._source); + } catch (err) { + console.error(`Query ${elasticQuery} for ${subscription._id} failed`); + server.Sentry?.captureException(err); + } + + return []; +}; + +/** + * Processes subscriptions for a specific site configuration. + * + * @param server - Fastify server instance. + * @param siteConfig - The site configuration to process + * @param stats - Statistics object to track processing + * @param isDryRun - Do not write changes + * @return {Promise} A Promise that resolves when processing is complete + */ +const processSiteSubscriptions = async ( + server: Server, + siteConfig: SiteConfigurationType, + stats: ProcessingStats, + isDryRun: boolean, +): Promise => { + const collection = server.mongo.db?.collection('subscription'); + const queueCollection = server.mongo.db?.collection('queue'); + + if (!collection || !queueCollection) { + throw new Error('MongoDB collections not available'); + } + + // List of all enabled subscriptions for this site + const result = await collection + .find({ + status: SubscriptionStatus.ACTIVE, + site_id: siteConfig.id, + }) + .toArray(); + + stats.subscriptionsChecked += result.length; + + // Process subscriptions sequentially to avoid overwhelming the system + await result.reduce(async (previousPromise, subscription) => { + await previousPromise; + + const localizedBaseUrl = getLocalizedUrl(siteConfig, subscription.lang); + + // Calculate subscription expiry date + const subscriptionValidForDays = siteConfig.subscription.maxAge; + + // Sync ATV delete_after if needed (handles config changes and legacy subscriptions) + const expectedDeleteAfter = calculateExpectedDeleteAfter(new Date(subscription.created), subscriptionValidForDays); + if (needsDeleteAfterSync(subscription.delete_after, expectedDeleteAfter)) { + if (isDryRun) { + console.log( + `[DRY RUN] Would sync ATV delete_after for ${subscription._id} ` + + `(stored: ${subscription.delete_after?.toISOString().substring(0, 10) ?? 'none'}, ` + + `expected: ${expectedDeleteAfter.toISOString().substring(0, 10)})`, + ); + } else { + try { + await server.atvUpdateDocumentDeleteAfter( + subscription.email, + subscriptionValidForDays, + new Date(subscription.created), + ); + await collection.updateOne({ _id: subscription._id }, { $set: { delete_after: expectedDeleteAfter } }); + } catch (error) { + console.error(`Failed to sync ATV delete_after for subscription ${subscription._id}:`, error); + server.Sentry?.captureException(error); + } + } + } + const subscriptionExpiresAt = + new Date(subscription.created).getTime() + subscriptionValidForDays * 24 * 60 * 60 * 1000; + const subscriptionExpiresAtDate = new Date(subscriptionExpiresAt); + const day = String(subscriptionExpiresAtDate.getDate()).padStart(2, '0'); + const month = String(subscriptionExpiresAtDate.getMonth() + 1).padStart(2, '0'); // Months are 0-based + const year = subscriptionExpiresAtDate.getFullYear(); + const formattedExpiryDate = `${day}.${month}.${year}`; + + // If subscription should expire soon, send an expiration email + if (checkShouldSendExpiryNotification(subscription as Partial, siteConfig)) { + if (isDryRun) { + console.log(`[DRY RUN] Would send expiry email to ${subscription.email} (site: ${siteConfig.id})`); + } else { + await collection.updateOne({ _id: subscription._id }, { $set: { expiry_notification_sent: 1 } }); + } + + const expiryEmailContent = await expiryEmail( + subscription.lang, + { + search_description: subscription.search_description, + link: siteConfig.urls.base + subscription.query, + removal_date: formattedExpiryDate, + remove_link: `${localizedBaseUrl}/hakuvahti/unsubscribe?subscription=${subscription._id}&hash=${subscription.hash}`, + renewal_link: `${localizedBaseUrl}/hakuvahti/renew?subscription=${subscription._id}&hash=${subscription.hash}`, + search_link: subscription.query, + }, + siteConfig, + ); + + const expiryEmailToQueue: QueueInsertDocument = { + type: 'email', + atv_id: subscription.email, + content: expiryEmailContent, + }; + + // Add email to queue + if (!isDryRun) { + await queueCollection.insertOne(expiryEmailToQueue); + } + stats.expiryEmailsQueued++; + + // Queue renewal SMS if subscription has SMS and site supports it + if (subscription.has_sms && siteConfig.subscription.enableSms) { + try { + const smsCode = await generateUniqueSmsCode(collection); + const now = new Date(); + + if (!isDryRun) { + await collection.updateOne( + { _id: subscription._id }, + { $set: { sms_code: smsCode, sms_code_created: now } }, + ); + } + + const smsContent = await renewalSms( + subscription.lang, + { + expiry_date: formattedExpiryDate, + search_description: subscription.search_description, + sms_code: smsCode, + }, + siteConfig, + ); + + const smsToQueue: QueueInsertDocument = { + type: 'sms', + atv_id: subscription.email, + content: smsContent, + }; + + if (isDryRun) { + console.log(`[DRY RUN] Would queue renewal SMS for ${subscription._id} with code ${smsCode}`); + } else { + await queueCollection.insertOne(smsToQueue); + } + stats.smsQueued++; + } catch (error) { + console.error(`Error queueing renewal SMS for subscription ${subscription._id}:`, error); + } + } + } + + const newHits = await getNewHitsFromElasticsearch( + subscription as SubscriptionCollectionType & { _id: ObjectId }, + siteConfig, + server, + ); + + // No new hits + if (newHits.length === 0) { + return Promise.resolve(); + } + + // Limit hits in email (user can see all via search_link) + const maxHitsInEmail = siteConfig.mail.maxHitsInEmail ?? 10; + const hitsForEmail = newHits.slice(0, maxHitsInEmail); + + // Format Mongo DateTime to EU format for email. + const createdDate: string = new Date(subscription.created).toISOString().substring(0, 10); + const date = new Date(createdDate); + const pad = (n: number) => n.toString().padStart(2, '0'); + const formattedCreatedDate = `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; + + const emailContent = await newHitsEmail( + subscription.lang, + { + created_date: formattedCreatedDate, + expiry_date: formattedExpiryDate, + search_description: subscription.search_description, + search_link: subscription.query, + remove_link: `${localizedBaseUrl}/hakuvahti/unsubscribe?subscription=${subscription._id}&hash=${subscription.hash}`, + hits: hitsForEmail, + }, + siteConfig, + ); + + const email: QueueInsertDocument = { + type: 'email', + atv_id: subscription.email, + content: emailContent, + }; + + if (isDryRun) { + console.log( + `[DRY RUN] Would queue email for ${subscription.email}: ${newHits.length} new result(s) (site: ${siteConfig.id})`, + ); + } else { + // Add email to queue + await queueCollection.insertOne(email); + + // Set last checked timestamp to this moment + const dateUnixtime: number = Math.floor(Date.now() / 1000); + await collection.updateOne({ _id: subscription._id }, { $set: { last_checked: dateUnixtime } }); + } + stats.newResultsEmailsQueued++; + + // Queue SMS if subscription has SMS flag and SMS is enabled for site + if (subscription.has_sms && siteConfig.subscription.enableSms) { + try { + // Regenerate SMS code for this notification + const smsCode = await generateUniqueSmsCode(collection); + const now = new Date(); + + // Update subscription with new SMS code + if (!isDryRun) { + await collection.updateOne({ _id: subscription._id }, { $set: { sms_code: smsCode, sms_code_created: now } }); + } + + const smsContent = await newHitsSms( + subscription.lang, + { + search_description: subscription.search_description, + sms_code: smsCode, + }, + siteConfig, + ); + + const smsToQueue: QueueInsertDocument = { + type: 'sms', + atv_id: subscription.email, + content: smsContent, + }; + + if (isDryRun) { + console.log(`[DRY RUN] Would queue SMS for ${subscription._id} with code ${smsCode}`); + } else { + await queueCollection.insertOne(smsToQueue); + } + stats.smsQueued++; + } catch (error) { + // Log error but don't break email sending + console.error(`Error queueing SMS for subscription ${subscription._id}:`, error); + } + } + + return Promise.resolve(); + }, Promise.resolve()); +}; + +/** + * Main application function that processes all site configurations. + * + * @return A Promise that resolves when complete. + */ +const app = async (targetSite: string | undefined, isDryRun: boolean, server: Server): Promise => { + const checkInId = server.Sentry?.captureCheckIn({ + monitorSlug: 'hav-populate-queue', + status: 'in_progress', + }); + + // Initialize statistics + const stats: ProcessingStats = { + sitesProcessed: 0, + subscriptionsChecked: 0, + expiryEmailsQueued: 0, + newResultsEmailsQueued: 0, + smsQueued: 0, + }; + + try { + console.log('Environment:', process.env.ENVIRONMENT || 'dev'); + if (isDryRun) { + console.log('\n=== DRY RUN MODE - No changes will be made ===\n'); + } + console.log('Loading site configurations...'); + + // Load site configurations + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const allSiteConfigs = configLoader.getConfigurations(); + + // Filter by --site parameter if provided + let siteConfigsToProcess = Object.entries(allSiteConfigs); + if (targetSite) { + siteConfigsToProcess = siteConfigsToProcess.filter(([siteId]) => siteId === targetSite); + + if (siteConfigsToProcess.length === 0) { + console.error(`Error: Site '${targetSite}' not found in configurations`); + console.log(`Available sites: ${Object.keys(allSiteConfigs).join(', ')}`); + process.exit(1); + } + } + + const siteNames = siteConfigsToProcess.map(([siteId]) => siteId).join(', '); + console.log(`Processing ${siteConfigsToProcess.length} site(s): ${siteNames}\n`); + + // Process each site configuration + await siteConfigsToProcess.reduce(async (previousPromise, [siteId, siteConfig]) => { + await previousPromise; + console.log(`Processing subscriptions for site: ${siteId}`); + await processSiteSubscriptions(server, siteConfig, stats, isDryRun); + stats.sitesProcessed++; + return Promise.resolve(); + }, Promise.resolve()); + + // Print summary + console.log('\n=== Summary ==='); + console.log(`Sites processed: ${stats.sitesProcessed}`); + console.log(`Subscriptions checked: ${stats.subscriptionsChecked}`); + console.log(`Expiry emails queued: ${stats.expiryEmailsQueued}`); + console.log(`New results emails queued: ${stats.newResultsEmailsQueued}`); + console.log(`SMS queued: ${stats.smsQueued}`); + if (isDryRun) { + console.log('\n[DRY RUN] No changes were made to the database'); + } + } catch (error) { + console.error('Configuration loading error:', error); + if (!isDryRun) { + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-queue', status: 'error' }); + server.Sentry?.captureException(error); + } + return; + } + + if (!isDryRun) { + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-queue', status: 'ok' }); + } +}; + +command( + async function handle(server, argv) { + const targetSite: string | undefined = argv.site; + const isDryRun: boolean = argv['dry-run'] === true; + + // Load site configurations + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfigs = configLoader.getConfigurations(); + + // Clean up expired subscriptions for each site + await Object.entries(siteConfigs).reduce(async (previousPromise, [siteId, siteConfig]) => { + await previousPromise; + + // Remove expired subscriptions that haven't been confirmed + await massDeleteSubscriptions( + server, + SubscriptionStatus.INACTIVE, + siteConfig.subscription.unconfirmedMaxAge, + siteId, + ); + + // Remove expired subscriptions + await massDeleteSubscriptions(server, SubscriptionStatus.ACTIVE, siteConfig.subscription.maxAge, siteId); + + return Promise.resolve(); + }, Promise.resolve()); + + // Loop through subscriptions and add new results to email queue + await app(targetSite, isDryRun, server); + }, + [ + // Register only needed plugins + mongodb, + elasticproxy, + base64Plugin, + atv, + ], +); diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts deleted file mode 100644 index ff2d983..0000000 --- a/src/bin/hav-send-emails-in-queue.ts +++ /dev/null @@ -1,138 +0,0 @@ -import fastify from 'fastify' -import mongodb from '../plugins/mongodb'; -import atv from '../plugins/atv'; -import mailer from '../plugins/mailer'; -import '../plugins/sentry'; -import dotenv from 'dotenv' -import { AtvDocumentType } from '../types/atv'; -import { ObjectId } from '@fastify/mongodb'; - -dotenv.config() - -const server = fastify({}) -const release = process.env.SENTRY_RELEASE ?? ''; - -server.register(require('@immobiliarelabs/fastify-sentry'), { - dsn: process.env.SENTRY_DSN, - environment: process.env.ENVIRONMENT, - release: release, - setErrorHandler: true -}) - -// Register only needed plugins -void server.register(mailer) -void server.register(mongodb) -void server.register(atv) - -// Command line/cron application to send all emails from queue collection -const BATCH_SIZE = 100 - -const app = async (): Promise<{}> => { - if (typeof server.mongo?.db === 'undefined') { - console.error('MongoDB connection not working') - throw new Error('MongoDB connection not working') - } - - // Email queue - const queueCollection = server.mongo.db!.collection('queue') - const jsdom = require('jsdom') - const { JSDOM } = jsdom - - let hasMoreResults = true - - while (hasMoreResults) { - const result = await queueCollection.find({}).limit(BATCH_SIZE).toArray() - - if (result.length === 0) { - hasMoreResults = false - } else { - // Collect email ids as map - const emailIdsMap = new Map() - - for (const email of result) { - emailIdsMap.set(email.email, null) - } - - // Get batch of email documents from ATV - const emailIds = [...emailIdsMap.keys()] - const emailDocuments:Partial = await server.atvGetDocumentBatch(emailIds) - - // Update the email map with unencrypted email list - if (emailDocuments.length > 0) { - for (const emailDocument of emailDocuments) { - if (emailDocument?.id) { - emailIdsMap.set(emailDocument.id, emailDocument.content.email) - } - } - } - - // Send emails - for (const email of result) { - const atvId = email.email - const plaintextEmail = emailIdsMap.get(email.email) - const dom = new JSDOM(email.content) - const title = dom.window.document.querySelector('title')?.textContent || 'Untitled' - - // email.email is the ATV document id. - console.info('Sending email to', atvId) - - // Check that plaintextEmail was found. No sure how this can happen, - // maybe the ATV document was deleted before the email queue was empty? - // Anyway, if email document was not found, sending email will fail. - if (plaintextEmail) { - try { - await new Promise((resolve, reject) => server.mailer.sendMail({ - to: plaintextEmail, - subject: title, - html: email.content - }, (errors, info) => { - if (errors) { - return reject(new Error(`Sending email to ${atvId} failed.`, { cause: errors })) - } - - return resolve(info); - })) - } - // Continue even if sending email failed. - catch (error) { - server.Sentry?.captureException(error) - - console.error(error); - } - } - - // Remove document from queue. The document is removed - // event if the email sending does not succeed. - const deleteResult = await queueCollection.deleteOne({_id: new ObjectId(email._id) }) - if (deleteResult.deletedCount === 0) { - console.error(`Could not delete email document with id ${email._id} from queue`) - - throw Error('Deleting email from queue failed.') - } - } - } - } - - server.Sentry.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'ok'}) - return {} -} - -server.get('/', async function (request, reply) { - // Send all emails from queue - return await app() -}) - -server.ready((err) => { - console.log('fastify server ready') - server.inject({ - method: 'GET', - url: '/' - }, (err, response) => { - if (response) { - console.log(JSON.parse(response.payload)) - } - - server.close() - }) - -}) diff --git a/src/bin/hav-send-queue.ts b/src/bin/hav-send-queue.ts new file mode 100644 index 0000000..57dd9fc --- /dev/null +++ b/src/bin/hav-send-queue.ts @@ -0,0 +1,38 @@ +import command from '../lib/command'; +import { QueueService } from '../lib/queueService'; +import atv from '../plugins/atv'; +import dialogi from '../plugins/dialogi'; +import mailer from '../plugins/mailer'; +import mongodb from '../plugins/mongodb'; +import '../plugins/sentry'; + +// Command line/cron application to send all notifications from queue collection +command( + async (server) => { + const checkInId = server.Sentry?.captureCheckIn({ + monitorSlug: 'hav-send-queue', + status: 'in_progress', + }); + + if (typeof server.mongo?.db === 'undefined') { + throw new Error('MongoDB connection not working'); + } + + const queueService = new QueueService({ + db: server.mongo.db, + atvClient: server, + emailSender: server.mailer, + smsSender: server.dialogi, + sentry: server.Sentry, + }); + + await queueService.processQueue(); + + server.Sentry?.captureCheckIn({ + checkInId, + monitorSlug: 'hav-send-queue', + status: 'ok', + }); + }, + [mailer, mongodb, atv, dialogi], +); diff --git a/src/bin/hav-test-email-templates.ts b/src/bin/hav-test-email-templates.ts new file mode 100644 index 0000000..f6c6c56 --- /dev/null +++ b/src/bin/hav-test-email-templates.ts @@ -0,0 +1,138 @@ +import type { Collection } from 'mongodb'; +import command from '../lib/command'; +import { confirmationEmail, expiryEmail, newHitsEmail } from '../lib/email'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import mongodb from '../plugins/mongodb'; +import type { PartialDrupalNodeType } from '../types/elasticproxy'; +import type { QueueInsertDocument } from '../types/queue'; +import type { SiteConfigurationType } from '../types/siteConfig'; +import type { SubscriptionCollectionLanguageType } from '../types/subscription'; + +// npm run hav:test-email-templates -- --site=rekry + +// Dummy data +const DUMMY_DATA = { + confirmation: { + link: 'https://dummyconfirmation', + search_description: 'Testihaku', + }, + expiry: { + link: 'https://dummysearch', + search_description: 'IT-asiantuntija', + removal_date: '31.12.2025', + remove_link: 'https://dummyremove', + renewal_link: 'https://dummyrenew', + search_link: '/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja', + }, + newhits: { + hits: [ + { + _language: 'fi', + entity_type: ['node'], + url: ['/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja'], + langcode: ['fi'], + title: 'IT-asiantuntija, Kaupunkiympäristön toimiala', + field_publication_starts: [Date.now()], + } as unknown as PartialDrupalNodeType, + { + _language: 'fi', + entity_type: ['node'], + url: ['/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja'], + langcode: ['fi'], + title: 'Ohjelmistokehittäjä', + field_publication_starts: [Date.now()], + } as unknown as PartialDrupalNodeType, + { + _language: 'fi', + entity_type: ['node'], + url: ['/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja'], + langcode: ['fi'], + title: 'Tietoturva-asiantuntija, Keskushallinto', + field_publication_starts: [Date.now()], + } as unknown as PartialDrupalNodeType, + ], + search_description: 'IT-asiantuntija', + search_link: '/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja', + remove_link: 'https://dummy/remove/xyz789', + created_date: '15.11.2025', + expiry_date: '15.02.2026', + }, +}; + +const LANGUAGES: SubscriptionCollectionLanguageType[] = ['fi', 'en', 'sv']; + +export async function generateTestEmails( + queueCollection: Collection, + testEmail: string, + siteConfig: SiteConfigurationType, +): Promise { + for (const lang of LANGUAGES) { + const confirmationHtml = await confirmationEmail(lang, DUMMY_DATA.confirmation, siteConfig); + const confirmationEmailDoc: QueueInsertDocument = { + type: 'email', + atv_id: testEmail, + content: confirmationHtml, + }; + await queueCollection.insertOne(confirmationEmailDoc); + + const expiryHtml = await expiryEmail(lang, DUMMY_DATA.expiry, siteConfig); + const expiryEmailDoc: QueueInsertDocument = { + type: 'email', + atv_id: testEmail, + content: expiryHtml, + }; + await queueCollection.insertOne(expiryEmailDoc); + + const newhitsHtml = await newHitsEmail(lang, DUMMY_DATA.newhits, siteConfig); + const newhitsEmailDoc: QueueInsertDocument = { + type: 'email', + atv_id: testEmail, + content: newhitsHtml, + }; + await queueCollection.insertOne(newhitsEmailDoc); + } +} + +command( + async (server, argv) => { + if (!argv.site) { + throw new Error('--site parameter required'); + } + + if (server.mongo?.db === undefined) { + throw new Error('MongoDB unavailable'); + } + + const subscriptionCollection = server.mongo.db.collection('subscription'); + const latestSubscription = await subscriptionCollection.findOne( + {}, + { sort: { _id: -1 }, projection: { email: 1 } }, + ); + + if (!latestSubscription?.email) { + throw new Error('Create test subscription first.'); + } + + const siteId = argv.site; + const testEmail = latestSubscription.email; + + console.log(`Site: ${siteId}`); + + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfig = configLoader.getConfiguration(siteId); + + if (!siteConfig) { + throw new Error('Site configuration not found'); + } + + console.log(`Template path: ${siteConfig.mail.templatePath}`); + + const queueCollection = server.mongo.db.collection('queue'); + + await generateTestEmails(queueCollection, testEmail, siteConfig); + + console.log('Test emails generated. Run hav:send-queue and check mailpit.'); + }, + [mongodb], +); diff --git a/src/bin/hav-test-sms-sending.ts b/src/bin/hav-test-sms-sending.ts new file mode 100644 index 0000000..0effe8c --- /dev/null +++ b/src/bin/hav-test-sms-sending.ts @@ -0,0 +1,84 @@ +import command from '../lib/command'; +import { newHitsSms } from '../lib/email'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import dialogi from '../plugins/dialogi'; +import '../plugins/sentry'; + +// Test script to verify SMS sending via Elisa Dialogi API +command( + async (server) => { + const testPhoneNumber = process.env.TEST_SMS_NUMBER; + + if (!testPhoneNumber) { + console.error('ERROR: TEST_SMS_NUMBER environment variable not set'); + console.error('Please set TEST_SMS_NUMBER in your .env file (e.g., TEST_SMS_NUMBER=+358501234567)'); + process.exit(1); + } + + console.log('=== SMS Sending Test ==='); + console.log(`Target number: ${testPhoneNumber}`); + console.log(`Environment: ${process.env.ENVIRONMENT || 'dev'}\n`); + + try { + // Load site configurations + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + + // Use first available site configuration for testing (default to 'rekry') + const siteConfigs = configLoader.getConfigurations(); + const siteId = Object.keys(siteConfigs)[0]; + const siteConfig = siteConfigs[siteId]; + + if (!siteConfig) { + throw new Error('No site configuration found. Please configure at least one site in conf/ directory.'); + } + + console.log(`Using site configuration: ${siteId}\n`); + + // Test with each language + const languages = ['fi', 'sv', 'en'] as const; + + for (const lang of languages) { + console.log(`Testing ${lang.toUpperCase()} SMS...`); + + // Generate SMS content with dummy data + const smsContent = await newHitsSms( + lang, + { + search_description: 'Test search: Open positions in Helsinki', + }, + siteConfig, + ); + + console.log(`Content: ${smsContent}`); + + // Send SMS via Dialogi API + try { + const response = await server.dialogi.sendSms(testPhoneNumber, smsContent); + // Extract message ID from Dialogi response + const messageId = + response.messages?.[0]?.[testPhoneNumber]?.messageid || + Object.values(response.messages?.[0] || {})[0]?.messageid || + 'N/A'; + console.log(`SMS Message ID: ${messageId}`); + } catch (error) { + console.error(`✗ Failed to send ${lang} SMS:`, error); + throw error; + } + + console.log(''); + } + + console.log('=== All SMS tests completed successfully ==='); + } catch (error) { + console.error('\n=== SMS Test Failed ==='); + console.error(error); + server.Sentry?.captureException(error); + process.exit(1); + } + }, + [ + // Register only needed plugins + dialogi, + ], +); diff --git a/src/bin/hav-update-subscription-length.ts b/src/bin/hav-update-subscription-length.ts new file mode 100644 index 0000000..0656174 --- /dev/null +++ b/src/bin/hav-update-subscription-length.ts @@ -0,0 +1,190 @@ +// Migration Script: Update subscription length for a specific site. +// This needs to be run to update ATV documents whenever subscription +// length is modified! +// --dry-run to preview changes to delete_after +// --batch-size to control batch size if ATV updates take longer than expected/crash + +import command, { type Server } from '../lib/command'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import atv from '../plugins/atv'; +import mongodb from '../plugins/mongodb'; + +export interface MigrationOptions { + siteId: string; + batchSize: number; + dryRun: boolean; +} + +export interface MigrationStats { + total: number; + updated: number; + failed: number; + skipped: number; +} + +/** + * Formats a Date object to ISO date string (YYYY-MM-DD). + * + * @param date - The date to format + * @return ISO date string (YYYY-MM-DD) + */ +export const formatDateISO = (date: Date): string => { + return date.toISOString().substring(0, 10); +}; + +/** + * Formats a subscription update log message. + * + * @param index - The subscription index in the current batch + * @param subscriptionId - The MongoDB subscription ID + * @param createdDate - The subscription creation date + * @param deleteAfter - The calculated delete_after date + * @param isDryRun - Whether this is a dry run + * @return Formatted log message + */ +export const formatSubscriptionUpdateMessage = ( + index: number, + subscriptionId: string, + createdDate: Date, + deleteAfter: Date, + isDryRun: boolean, +): string => { + const action = isDryRun ? '[DRY RUN] Would update' : 'Updated'; + const created = formatDateISO(createdDate); + const deleteAfterStr = formatDateISO(deleteAfter); + return `${index}. ${action}: ${subscriptionId} | Created: ${created} | New delete_after: ${deleteAfterStr}`; +}; + +/** + * Formats an error message for a failed subscription update. + * + * @param index - The subscription index in the current batch + * @param subscriptionId - The MongoDB subscription ID + * @param error - The error that occurred + * @return Formatted error message + */ +export const formatErrorMessage = (index: number, subscriptionId: string, error: unknown): string => { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return `${index}. Failed: ${subscriptionId} | Error: ${errorMessage}`; +}; + +/** + * Calculates the delete_after date for an ATV document based on subscription created date and maxAge. + * + * @param createdDate - The subscription creation date + * @param maxAge - Number of days until deletion + * @return The calculated delete_after date + */ +export const calculateDeleteAfterDate = (createdDate: Date, maxAge: number): Date => { + const deleteAfter = new Date(createdDate); + // setDate handles day/month overflow automatically so we can just get + // current date and add X days to it. + deleteAfter.setDate(deleteAfter.getDate() + maxAge); + return deleteAfter; +}; + +/** + * Updates ATV document delete_after timestamps for all subscriptions of a given site. + * + * @param server - Fastify server instance + * @param options - Migration options + */ +export const updateSubscriptionLength = async (server: Server, options: MigrationOptions): Promise => { + const db = server.mongo.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } + + const stats: MigrationStats = { + total: 0, + updated: 0, + failed: 0, + skipped: 0, + }; + + const collection = db.collection('subscription'); + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfig = configLoader.getConfiguration(options.siteId); + + if (!siteConfig) { + throw new Error('Site configuration not found'); + } + + const maxAge = siteConfig.subscription.maxAge; + console.log(`Site configuration maxAge = ${maxAge} days`); + + // Get all subscriptions + const subscriptions = await collection.find({ site_id: options.siteId }).toArray(); + stats.total = subscriptions.length; + + console.log(`Found ${subscriptions.length} subscriptions for site: ${options.siteId}`); + + // Process subscriptions in batches + const { batchSize, dryRun } = options; + + for (let i = 0; i < subscriptions.length; i += batchSize) { + const batch = subscriptions.slice(i, i + batchSize); + + console.log(`\nbatch ${Math.floor(i / batchSize) + 1} (${batch.length} subscriptions):`); + + for (const [index, subscription] of batch.entries()) { + try { + // Calculate delete_after: subscription.created + maxAge days + const createdDate = new Date(subscription.created); + const deleteAfter = calculateDeleteAfterDate(createdDate, maxAge); + + const message = formatSubscriptionUpdateMessage( + i + index + 1, + subscription._id.toString(), + createdDate, + deleteAfter, + dryRun, + ); + + console.log(message); + + if (!dryRun) { + // Update ATV document with calculated delete_after + await server.atvUpdateDocumentDeleteAfter(subscription.email, maxAge, createdDate); + } + + stats.updated += 1; + } catch (error) { + const errorMessage = formatErrorMessage(i + index + 1, subscription._id.toString(), error); + console.error(errorMessage); + stats.failed += 1; + } + } + } + + console.log(`Total: ${stats.total}`); + console.log(`Updated: ${stats.updated}`); + console.log(`Failed: ${stats.failed}`); + console.log(`Skipped: ${stats.skipped}`); +}; + +command( + async (server, argv) => { + // Get site_id from --site parameter + const siteId = argv.site as string; + if (!siteId) { + throw new Error('--site parameter is required'); + } + + const batchSize = (argv['batch-size'] as number) || 100; + const dryRun = (argv['dry-run'] as boolean) || false; + + console.log(`Target site_id: ${siteId}`); + console.log(`Batch size: ${batchSize}`); + console.log(`Dry run: ${dryRun}`); + console.log(''); + + await updateSubscriptionLength(server, { + siteId, + batchSize, + dryRun, + }); + }, + [mongodb, atv], +); diff --git a/src/lib/command.ts b/src/lib/command.ts new file mode 100644 index 0000000..0bcdb2c --- /dev/null +++ b/src/lib/command.ts @@ -0,0 +1,60 @@ +// @fixme '@immobiliarelabs/fastify-sentry' is no longer maintained. +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import dotenv from 'dotenv'; +import fastify, { type FastifyInstance } from 'fastify'; +import minimist, { type ParsedArgs } from 'minimist'; + +dotenv.config(); + +export type Server = FastifyInstance; + +export type Command = (server: Server, argv: ParsedArgs) => Promise; + +/** + * Wrapper around fastify boilerplate for building console scripts. + * + * @param app - command handler + * @param plugins - list of fastify plugins to register + */ +export default function command(app: Command, plugins: Array<(...args: any[]) => unknown> = []) { + const server = fastify({}); + + // Parse CLI arguments + const argv = minimist(process.argv.slice(2)); + + // Register sentry for all commands. + server.register(fastifySentry, { + dsn: process.env.SENTRY_DSN, + environment: process.env.ENVIRONMENT, + release: process.env.SENTRY_RELEASE ?? '', + setErrorHandler: true, + }); + + plugins.forEach((plugin) => { + server.register(plugin); + }); + + server.ready(async (err) => { + if (err) { + console.error('Server failed to start:', err); + process.exit(1); + } + + let result = true; + + try { + await app(server, argv); + } catch (err) { + result = false; + + console.error('Command failed', err); + } + + await server.close(); + + // Exit with failure if command failed. + process.exit(result ? 0 : 1); + }); + + return server; +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 338744c..cac38c4 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,70 +1,187 @@ -import { sprightly } from "sprightly"; -import { SubscriptionCollectionLanguageType } from "../types/subscription" -import { PartialDrupalNodeType } from "../types/elasticproxy" -import dotenv from 'dotenv' +import { sprightly } from 'sprightly'; +import type { PartialDrupalNodeType } from '../types/elasticproxy'; +import type { SiteConfigurationType } from '../types/siteConfig'; +import type { SubscriptionCollectionLanguageType } from '../types/subscription'; -dotenv.config() +const TEMPLATE_BASE_PATH = 'dist/templates'; -// Base dir for email templates -const dir = process.env.MAIL_TEMPLATE_PATH || 'dist/templates' +export const translate = ( + key: string, + lang: SubscriptionCollectionLanguageType, + siteConfig: SiteConfigurationType, +): string => siteConfig.translations?.[key]?.[lang] ?? ''; -// Base url for the website (not HAV) -const baseUrl: string = process.env.BASE_URL || 'http://localhost:3000' +type SprightlyContext = Record; + +export const buildTranslationContext = ( + lang: SubscriptionCollectionLanguageType, + siteConfig: SiteConfigurationType, +): SprightlyContext => { + const context: SprightlyContext = {}; + const entries = siteConfig.translations ? Object.entries(siteConfig.translations) : []; + entries.forEach(([key, value]) => { + context[key] = value[lang] ?? ''; + }); + return context; +}; + +export const wrapWithLayout = ( + innerTemplatePath: string, + innerTemplateData: SprightlyContext, + lang: SubscriptionCollectionLanguageType, + title: string, + siteConfig: SiteConfigurationType, +) => { + const translations = buildTranslationContext(lang, siteConfig); + const templateData: SprightlyContext = { + ...translations, + ...innerTemplateData, + }; + const innerContent = sprightly(innerTemplatePath, templateData); + const now = new Date(); + const year = String(now.getFullYear()); + + const layoutData: SprightlyContext = { + ...translations, + lang, + title, + content: innerContent, + year, + }; + + return sprightly(`${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/index.html`, layoutData); +}; + +// Subscription confirmation SMS +export const confirmationSms = async ( + lang: SubscriptionCollectionLanguageType, + data: { sms_code: string }, + siteConfig: SiteConfigurationType, +) => + sprightly(`${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/sms/confirmation.txt`, { + ...buildTranslationContext(lang, siteConfig), + sms_code: data.sms_code ?? '', + }); // Subscription confirmation email -export const confirmationEmail = async (lang: SubscriptionCollectionLanguageType, data: { link: string; }) => { - try { - return sprightly('dist/templates/' + dir + '/confirmation_' + lang + '.html', { - lang: lang, +export const confirmationEmail = async ( + lang: SubscriptionCollectionLanguageType, + data: { link: string; search_description: string | undefined }, + siteConfig: SiteConfigurationType, +) => + wrapWithLayout( + `${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/confirmation.html`, + { + lang, link: data.link, - }); - } catch (error) { - throw error - } -} + search_description: data.search_description?.toLowerCase() ?? '', + }, + lang, + translate('email_subject_confirmation', lang, siteConfig), + siteConfig, + ); // Notification before subscription expires -export const expiryEmail = async (lang: SubscriptionCollectionLanguageType, data: { - link: string, - search_description: string, - removal_date: string, - remove_link: string }) => { - try { - return sprightly('dist/templates/' + dir + '/expiry_notification_' + lang + '.html', { - lang: lang, +export const expiryEmail = async ( + lang: SubscriptionCollectionLanguageType, + data: { + link: string; + search_description: string; + removal_date: string; + remove_link: string; + renewal_link: string; + search_link: string; + }, + siteConfig: SiteConfigurationType, +) => + wrapWithLayout( + `${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/expiry_notification.html`, + { + lang, link: data.link, search_description: data.search_description, remove_link: data.remove_link, - removal_date: data.removal_date - }); - } catch (error) { - throw error - } -} + removal_date: data.removal_date, + renewal_link: data.renewal_link, + search_link: siteConfig.urls.base + data.search_link, + }, + lang, + translate('email_subject_expiry', lang, siteConfig), + siteConfig, + ); // Email with list of new search monitor hits -export const newHitsEmail = async (lang: SubscriptionCollectionLanguageType, data: { - hits: PartialDrupalNodeType[], - search_description: string, - search_link: string, - remove_link: string, - created_date: string }) => { +export const newHitsEmail = async ( + lang: SubscriptionCollectionLanguageType, + data: { + hits: PartialDrupalNodeType[]; + search_description: string; + search_link: string; + remove_link: string; + created_date: string; + expiry_date: string; + }, + siteConfig: SiteConfigurationType, +) => { try { - const hitsContent = data.hits.map(item => sprightly('dist/templates/link_text.html', { - link: baseUrl + item.url, - content: item.title, - })).join('') + const hitsContent = data.hits + .map((item) => + sprightly('dist/templates/link_text.html', { + link: siteConfig.urls.base + item.url, + content: item.title, + }), + ) + .join(''); - return sprightly(`dist/templates/${dir}/newhits_${lang}.html`, { - lang: lang, - hits: hitsContent, - search_link: baseUrl + data.search_link, - remove_link: data.remove_link, - search_description: data.search_description, - created_date: data.created_date - }) + return wrapWithLayout( + `${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/newhits.html`, + { + lang, + hits: hitsContent, + search_link: siteConfig.urls.base + data.search_link, + remove_link: data.remove_link, + search_description: data.search_description, + created_date: data.created_date, + expiry_date: data.expiry_date, + }, + lang, + translate('email_subject_newhits', lang, siteConfig), + siteConfig, + ); } catch (error) { - console.error(error) - throw error + console.error(error); + throw error; } -} +}; + +// SMS notification for new search results +export const newHitsSms = async ( + lang: SubscriptionCollectionLanguageType, + data: { + search_description: string; + sms_code?: string; + }, + siteConfig: SiteConfigurationType, +) => + sprightly(`${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/sms/newhits.txt`, { + ...buildTranslationContext(lang, siteConfig), + search_description: data.search_description, + sms_code: data.sms_code ?? '', + }); + +// SMS notification for subscription renewal +export const renewalSms = async ( + lang: SubscriptionCollectionLanguageType, + data: { + expiry_date: string; + search_description: string; + sms_code: string; + }, + siteConfig: SiteConfigurationType, +) => + sprightly(`${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/sms/renew.txt`, { + ...buildTranslationContext(lang, siteConfig), + expiry_date: data.expiry_date, + search_description: data.search_description, + sms_code: data.sms_code, + }); diff --git a/src/lib/queueService.ts b/src/lib/queueService.ts new file mode 100644 index 0000000..bde8c9a --- /dev/null +++ b/src/lib/queueService.ts @@ -0,0 +1,149 @@ +import { ObjectId } from '@fastify/mongodb'; +import type * as Sentry from '@sentry/node'; +import type { FastifyInstance } from 'fastify'; +import { JSDOM } from 'jsdom'; +import type { Db } from 'mongodb'; +import type { DialogiClient } from '../plugins/dialogi'; +import type { AtvDocumentType } from '../types/atv'; +import type { FastifyMailer } from '../types/mailer'; +import type { QueueItem, QueueItemType } from '../types/queue'; + +export const BATCH_SIZE = 100; + +export interface QueueServiceDependencies { + db: Db; + atvClient: FastifyInstance; + emailSender: FastifyMailer; + smsSender: DialogiClient; + sentry?: typeof Sentry; + batchSize?: number; +} + +type NotificationHandlers = { [key in QueueItemType]: (item: QueueItem, atvDoc?: AtvDocumentType) => Promise }; + +export class QueueService { + private readonly queueCollection; + private readonly atvClient: FastifyInstance; + private readonly emailSender: FastifyMailer; + private readonly smsSender: DialogiClient; + private readonly sentry?: typeof Sentry; + private readonly batchSize: number; + + private handlers: NotificationHandlers; + + constructor(deps: QueueServiceDependencies) { + this.queueCollection = deps.db.collection('queue'); + this.atvClient = deps.atvClient; + this.emailSender = deps.emailSender; + this.smsSender = deps.smsSender; + this.sentry = deps.sentry; + this.batchSize = deps.batchSize ?? BATCH_SIZE; + this.handlers = { + sms: this.sendSms.bind(this), + email: this.sendEmail.bind(this), + }; + } + + async processQueue(): Promise { + let hasMoreResults = true; + + while (hasMoreResults) { + const result = (await this.queueCollection.find({}).limit(this.batchSize).toArray()) as QueueItem[]; + + if (result.length === 0) { + hasMoreResults = false; + } else { + await this.processBatch(result); + } + } + } + + private async processBatch(batch: QueueItem[]): Promise { + // Fetch all subscriber data from ATV in one call + const atvIds = [...new Set(batch.map((item) => item.atv_id))]; + const atvDocuments = await this.atvClient.atvGetDocumentBatch(atvIds); + const atvMap = new Map(); + + atvDocuments.forEach((doc) => { + if (doc?.id) atvMap.set(doc.id, doc); + }); + + // Process items sequentially + for (const item of batch) { + const atvDoc = atvMap.get(item.atv_id); + + if (!this.handlers[item.type]) { + console.error(`Missing queue handler for type ${item.type}`); + } + + // Send queued notification. + await this.handlers[item.type]?.(item, atvDoc); + + // Remove item from queue. + await this.removeFromQueue(item._id); + } + } + + private async sendEmail(item: QueueItem, atvDoc: AtvDocumentType | undefined): Promise { + const plaintextEmail = atvDoc?.content?.email as string | undefined; + const dom = new JSDOM(item.content); + const title = dom.window.document.querySelector('title')?.textContent || 'Untitled'; + + console.info('Sending email to', item.atv_id); + + if (!plaintextEmail) { + console.warn(`Email not found for ATV ID ${item.atv_id}`); + return; + } + + try { + await new Promise((resolve, reject) => { + this.emailSender.sendMail( + { + to: plaintextEmail, + subject: title, + html: item.content, + }, + (errors, info) => { + if (errors) { + return reject(new Error(`Sending email to ${item.atv_id} failed.`, { cause: errors })); + } + return resolve(info); + }, + ); + }); + } catch (error) { + // Continue even if sending email failed. + this.sentry?.captureException(error); + console.error(error); + } + } + + private async sendSms(item: QueueItem, atvDoc: AtvDocumentType | undefined): Promise { + const phoneNumber = atvDoc?.content?.sms as string | undefined; + + console.info('Sending SMS to', item.atv_id); + + if (!phoneNumber) { + console.warn(`Phone number not found for ATV ID ${item.atv_id}`); + return; + } + + try { + await this.smsSender.sendSms(phoneNumber, item.content); + } catch (error) { + // Continue even if sending SMS failed. + this.sentry?.captureException(error); + console.error(`Failed to send SMS for ATV ID ${item.atv_id}:`, error); + } + } + + private async removeFromQueue(id: ObjectId): Promise { + const deleteResult = await this.queueCollection.deleteOne({ _id: new ObjectId(id) }); + + if (deleteResult.deletedCount === 0) { + console.error(`Could not delete queue item with id ${id}`); + throw new Error('Deleting item from queue failed.'); + } + } +} diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts new file mode 100644 index 0000000..033d28c --- /dev/null +++ b/src/lib/siteConfigurationLoader.ts @@ -0,0 +1,160 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { + SiteConfigurationFileType, + SiteConfigurationMapType, + SiteConfigurationType, + SiteEnvironmentConfigType, +} from '../types/siteConfig'; + +export class SiteConfigurationLoader { + private static instance: SiteConfigurationLoader; + + private configurations: SiteConfigurationMapType = {}; + + private loaded = false; + + // eslint-disable-next-line no-empty-function + private constructor() {} + + public static getInstance(): SiteConfigurationLoader { + if (!SiteConfigurationLoader.instance) { + SiteConfigurationLoader.instance = new SiteConfigurationLoader(); + } + + return SiteConfigurationLoader.instance; + } + + /** + * This function needs to be called after getInstance + * to populate site data. + * + * @fixme call loadConfiguration automatically in getInstance. + */ + public async loadConfigurations(): Promise { + if (this.loaded) { + return; + } + + const environment = process.env.ENVIRONMENT || 'dev'; + const configDir = path.resolve(process.cwd(), 'conf'); + + if (!fs.existsSync(configDir)) { + throw new Error(`Configuration directory not found: ${configDir}`); + } + + const files = fs.readdirSync(configDir).filter((file) => file.endsWith('.json')); + + if (files.length === 0) { + throw new Error('No JSON configuration files found in conf/ directory'); + } + + // eslint-disable-next-line no-restricted-syntax + for (const file of files) { + const siteId = path.basename(file, '.json'); + const filePath = path.join(configDir, file); + + try { + const fileContent = fs.readFileSync(filePath, 'utf8'); + const rawConfig: SiteConfigurationFileType = JSON.parse(fileContent); + + if (!this.validateRawConfiguration(rawConfig)) { + throw new Error(`Invalid configuration structure in ${filePath}`); + } + + // Extract environment-specific config + const envConfig = (rawConfig as Record)[environment] as SiteEnvironmentConfigType; + if (!envConfig) { + throw new Error(`Environment '${environment}' not found in configuration ${filePath}`); + } + + if (!this.validateEnvironmentConfiguration(envConfig)) { + throw new Error(`Invalid environment configuration for '${environment}' in ${filePath}`); + } + + const translations = rawConfig.translations ?? undefined; + + // Flatten to runtime configuration + this.configurations[siteId] = { + id: siteId, + name: rawConfig.name, + urls: envConfig.urls, + subscription: envConfig.subscription, + mail: envConfig.mail, + elasticProxyUrl: envConfig.elasticProxyUrl, + translations, + }; + } catch (error) { + throw new Error(`Failed to load configuration from ${filePath}: ${error}`); + } + } + + this.loaded = true; + } + + /** + * Gets all loaded site configurations + * @return {SiteConfigurationMapType} The loaded site configurations + */ + public getConfigurations(): SiteConfigurationMapType { + if (!this.loaded) { + throw new Error('Configurations not loaded. Call loadConfigurations() first.'); + } + return this.configurations; + } + + /** + * Gets a specific site configuration by ID + * @param {string} siteId - The site ID to get configuration for + * @return {SiteConfigurationType | undefined} The site configuration or undefined if not found + */ + public getConfiguration(siteId: string): SiteConfigurationType | undefined { + if (!this.loaded) { + throw new Error('Configurations not loaded. Call loadConfigurations() first.'); + } + return this.configurations[siteId]; + } + + public getSiteIds(): string[] { + if (!this.loaded) { + throw new Error('Configurations not loaded. Call loadConfigurations() first.'); + } + return Object.keys(this.configurations); + } + + /** + * Validates that a raw configuration file has required properties + * @param {unknown} config - The configuration object to validate + * @return {boolean} True if configuration is valid + */ + // eslint-disable-next-line class-methods-use-this + public validateRawConfiguration(config: unknown): config is SiteConfigurationFileType { + if (typeof config !== 'object' || config === null) { + return false; + } + const configObj = config as Record; + + // Must have 'name' property + if (!('name' in configObj) || typeof configObj.name !== 'string') { + return false; + } + + // Must have at least one environment configuration (excluding 'name') + const envKeys = Object.keys(configObj).filter((key) => key !== 'name'); + return envKeys.length > 0; + } + + /** + * Validates that an environment-specific configuration has required properties + * @param {unknown} config - The configuration object to validate + * @return {boolean} True if environment configuration is valid + */ + // eslint-disable-next-line class-methods-use-this + public validateEnvironmentConfiguration(config: unknown): config is SiteEnvironmentConfigType { + if (typeof config !== 'object' || config === null) { + return false; + } + const required = ['urls', 'subscription', 'mail', 'elasticProxyUrl']; + return required.every((prop) => prop in config); + } +} diff --git a/src/lib/smsCode.ts b/src/lib/smsCode.ts new file mode 100644 index 0000000..06f2b11 --- /dev/null +++ b/src/lib/smsCode.ts @@ -0,0 +1,123 @@ +import { randomInt } from 'node:crypto'; +import type { Collection } from 'mongodb'; +import type { AtvDocumentType } from '../types/atv'; +import type { SmsVerificationResultType, VerificationSubscriptionType } from '../types/subscription'; + +// Type for ATV query function (matches Fastify decorator return type) +export type AtvQueryFn = (docId: string) => Promise>; + +/** + * Generates a unique 6-digit SMS verification code. + * Retries up to maxAttempts to avoid collisions with existing active codes. + */ +export async function generateUniqueSmsCode(collection: Collection | undefined): Promise { + const maxAttempts = 10; + + for (let i = 0; i < maxAttempts; i++) { + const code = String(randomInt(1000000)).padStart(6, '0'); + + // Check if code exists among active subscriptions + const existing = await collection?.findOne({ + sms_code: code, + sms_code_created: { $exists: true }, + }); + + if (!existing) { + return code; + } + } + + throw new Error('Failed to generate unique SMS code after maximum attempts'); +} + +/** + * Validates that the input matches the last 3 digits of the stored phone number. + */ +export function validatePhoneSuffix(storedPhone: string, inputSuffix: string): boolean { + if (!storedPhone || !inputSuffix) { + return false; + } + + // Extract digits only + const phoneDigits = storedPhone.replace(/\D/g, ''); + const inputDigits = inputSuffix.replace(/\D/g, ''); + + // Get last 3 digits of stored phone + const last3 = phoneDigits.slice(-3); + + return last3 === inputDigits; +} + +/** + * Checks if an SMS code has expired based on creation time and expiry minutes. + */ +export function isCodeExpired(codeCreated: Date, expireMinutes: number): boolean { + const expiresAt = new Date(codeCreated).getTime() + expireMinutes * 60 * 1000; + return Date.now() > expiresAt; +} + +/** + * Find a subscription by its SMS verification code. + */ +export async function findSubscriptionByCode( + collection: Collection, + smsCode: string, +): Promise { + return (await collection.findOne({ + sms_code: smsCode, + sms_code_created: { $exists: true }, + })) as VerificationSubscriptionType | null; +} + +/** + * Validates an SMS verification request. + * 1. Check code expiry + * 2. Fetch phone from ATV and validate suffix + * + * @param subscription - The subscription found by sms_code + * @param phoneSuffix - Last 3 digits of phone from user + * @param expireMinutes - Minutes until code expires + * @param atvQueryFn - Function to fetch ATV document content + * @returns Verification result with subscription or error + */ +export async function verifySmsRequest( + subscription: VerificationSubscriptionType, + phoneSuffix: string, + expireMinutes: number, + atvQueryFn: AtvQueryFn, +): Promise { + // Check code expiry + if (!subscription.sms_code_created || isCodeExpired(new Date(subscription.sms_code_created), expireMinutes)) { + return { + success: false, + error: { statusCode: 400, statusMessage: 'Verification code has expired.' }, + }; + } + + // Fetch phone number from ATV document content + let storedPhone: string | undefined; + try { + // atvGetDocument returns unwrapped content (response.data.content) + const atvContent = await atvQueryFn(subscription.email); + const content = atvContent as { sms?: string } | undefined; + storedPhone = content?.sms; + } catch (_error) { + return { + success: false, + error: { statusCode: 500, statusMessage: 'Failed to verify phone number.' }, + }; + } + + // Validate phone suffix + if (!storedPhone || !validatePhoneSuffix(storedPhone, phoneSuffix)) { + return { + success: false, + error: { statusCode: 401, statusMessage: 'Invalid verification.' }, + }; + } + + return { + success: true, + subscription, + }; +} diff --git a/src/lib/subscriptionActions.ts b/src/lib/subscriptionActions.ts new file mode 100644 index 0000000..ec46201 --- /dev/null +++ b/src/lib/subscriptionActions.ts @@ -0,0 +1,144 @@ +import type { Collection, ObjectId } from 'mongodb'; +import type { SiteConfigurationType } from '../types/siteConfig'; +import { type RenewalSubscriptionType, SubscriptionStatus } from '../types/subscription'; + +export interface ActionResult { + success: boolean; + statusCode: number; + statusMessage: string; + expiryDate?: string; +} + +// Type for ATV update function (injected from Fastify decorator) +export type AtvUpdateFn = (docId: string, maxAge: number, fromDate: Date) => Promise; + +/** + * Confirms a subscription by setting status from INACTIVE to ACTIVE. + */ +export async function confirmSubscription(collection: Collection, subscriptionId: ObjectId): Promise { + const result = await collection.updateOne( + { _id: subscriptionId, status: SubscriptionStatus.INACTIVE }, + { + $set: { status: SubscriptionStatus.ACTIVE }, + $unset: { sms_code: 1, sms_code_created: 1 }, + }, + ); + + if (result.modifiedCount === 0) { + return { + success: false, + statusCode: 404, + statusMessage: 'Subscription not found or already confirmed.', + }; + } + + return { + success: true, + statusCode: 200, + statusMessage: 'Subscription confirmed.', + }; +} + +/** + * Deletes a subscription. + */ +export async function deleteSubscription(collection: Collection, subscriptionId: ObjectId): Promise { + const result = await collection.deleteOne({ _id: subscriptionId }); + + if (result.deletedCount === 0) { + return { + success: false, + statusCode: 404, + statusMessage: 'Subscription not found.', + }; + } + + return { + success: true, + statusCode: 200, + statusMessage: 'Subscription deleted.', + }; +} + +/** + * Renews a subscription with full validation. + * - Must be ACTIVE status + * - Must be within renewal window (past expiry notification date) + * - Updates ATV document delete_after + * - Updates subscription timestamps + */ +export async function renewSubscription( + collection: Collection, + subscription: RenewalSubscriptionType, + siteConfig: SiteConfigurationType, + atvUpdateFn: AtvUpdateFn, +): Promise { + // Check ACTIVE status + if (subscription.status !== SubscriptionStatus.ACTIVE) { + return { + success: false, + statusCode: 400, + statusMessage: 'Only active subscriptions can be renewed.', + }; + } + + // Check renewal window + const { maxAge, expiryNotificationDays } = siteConfig.subscription; + const subscriptionExpiresAt = new Date(subscription.created).getTime() + maxAge * 24 * 60 * 60 * 1000; + const expiryNotificationDate = new Date(subscriptionExpiresAt - expiryNotificationDays * 24 * 60 * 60 * 1000); + + if (Date.now() < expiryNotificationDate.getTime()) { + return { + success: false, + statusCode: 400, + statusMessage: 'Subscription cannot be renewed yet.', + }; + } + + // Update ATV document delete_after + const now = new Date(); + try { + await atvUpdateFn(subscription.email, maxAge, now); + } catch (_error) { + return { + success: false, + statusCode: 500, + statusMessage: 'Failed to update subscription expiry in storage.', + }; + } + + // Calculate new delete_after + const newDeleteAfter = new Date(now); + newDeleteAfter.setDate(newDeleteAfter.getDate() + maxAge); + + // Build update fields + const updateFields: Record = { + created: now, + modified: now, + expiry_notification_sent: SubscriptionStatus.INACTIVE, + delete_after: newDeleteAfter, + }; + + // Preserve original created date on first renewal + if (!subscription.first_created) { + updateFields.first_created = subscription.created; + } + + await collection.updateOne( + { _id: subscription._id as ObjectId }, + { + $set: updateFields, + $unset: { sms_code: 1, sms_code_created: 1 }, + }, + ); + + // Calculate new expiry date + const newExpiryDate = new Date(Date.now() + maxAge * 24 * 60 * 60 * 1000); + + return { + success: true, + statusCode: 200, + statusMessage: 'Subscription renewed successfully.', + expiryDate: newExpiryDate.toISOString(), + }; +} diff --git a/src/plugins/api-key.ts b/src/plugins/api-key.ts new file mode 100644 index 0000000..585a114 --- /dev/null +++ b/src/plugins/api-key.ts @@ -0,0 +1,26 @@ +import { timingSafeEqual } from 'node:crypto'; +import fp from 'fastify-plugin'; + +/** + * Validate token in request headers + * + * Requests must have 'Authorization: api-key ' header in the request. + */ +export default fp(async (fastify, _opts) => { + fastify.addHook('preHandler', async (request, reply) => { + // Skip token check for health check routes + if (request.url === '/healthz' || request.url === '/readiness') { + return true; + } + + const { HAKUVAHTI_API_KEY } = process.env; + const expected = Buffer.from(`api-key ${HAKUVAHTI_API_KEY}`); + const received = Buffer.from(request.headers.authorization?.toString() ?? ''); + + if (!HAKUVAHTI_API_KEY || expected.length !== received.length || !timingSafeEqual(expected, received)) { + return reply.code(403).send(); + } + + return true; + }); +}); diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index f08e0ba..86095ae 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -1,66 +1,62 @@ -import fp from 'fastify-plugin' -import axios, { AxiosResponse } from 'axios' -import { - AtvDocumentBatchType, - AtvDocumentType, - AtvResponseType } from '../types/atv' -import { SubscriptionRequestType } from '../types/subscription' -import { FastifyRequest } from 'fastify/types/request' - -export interface AtvPluginOptions { -} +import axios, { type AxiosResponse } from 'axios'; +import fp from 'fastify-plugin'; +import type { AtvDocumentBatchType, AtvDocumentType } from '../types/atv'; + +export type AtvPluginOptions = Record; /** * Fetches content by document id from the ATV API. * - * @param {string} atvDocumentId - The id of the ATV document - * @return {Promise>} The content of the document + * @param atvDocumentId - The id of the ATV document + * @return The content of the document */ const atvFetchContentById = async (atvDocumentId: string): Promise> => { try { - const response: AxiosResponse> = await axios.get(`${process.env.ATV_API_URL}/v1/documents/${atvDocumentId}`, { - headers: { - 'x-api-key': process.env.ATV_API_KEY - } - }) - - if (response.data && response.data.content) { - return response.data.content - } else { - throw new Error('Empty content returned from API') + const response: AxiosResponse> = await axios.get( + `${process.env.ATV_API_URL}/v1/documents/${atvDocumentId}`, + { + headers: { + 'x-api-key': process.env.ATV_API_KEY, + }, + }, + ); + + if (response.data?.content) { + return response.data.content; } } catch (error: unknown) { - console.error(error); - - throw new Error('Error fetching Document by id') + throw new Error('Error fetching Document by id', { + cause: error, + }); } -} + + throw new Error('Empty content returned from API'); +}; /** - * Create a document with the given email and return a partial AtvDocumentType. + * Create a document with the given content, return a partial AtvDocumentType. * - * @param {string} email - the email to be included in the document - * @return {Promise>} the created document + * @param content - the content object to be included in the document + * @param tosFunctionId - the TOS function ID for the document + * @return the created document */ -const atvCreateDocumentWithEmail = async (email: string): Promise> => { +export const atvCreateDocument = async (content: object, tosFunctionId: string): Promise> => { try { - const timestamp = Math.floor(Date.now() / 1000).toString() + const timestamp = Math.floor(Date.now() / 1000).toString(); // ATV automatically deletes the document after deleteAfter date has passed - const deleteAfter = new Date() - const maxAge: number = +process.env.SUBSCRIPTION_MAX_AGE! - deleteAfter.setDate(deleteAfter.getDate() + maxAge) + const deleteAfter = new Date(); + const maxAge: number = Number(process.env.SUBSCRIPTION_MAX_AGE) || 90; // Default: 90 days + deleteAfter.setDate(deleteAfter.getDate() + maxAge); // Minimal document required by ATV const documentObject: Partial = { - 'draft': 'false', - 'tos_function_id': 'atvCreateDocumentWithEmail', - 'tos_record_id': timestamp, - 'delete_after': deleteAfter.toISOString().substring(0, 10), - 'content': JSON.stringify({ - 'email': email - }) - } + draft: 'false', + tos_function_id: tosFunctionId, + tos_record_id: timestamp, + delete_after: deleteAfter.toISOString().substring(0, 10), + content: JSON.stringify(content), + }; const response: AxiosResponse> = await axios.post( `${process.env.ATV_API_URL}/v1/documents/`, @@ -68,121 +64,150 @@ const atvCreateDocumentWithEmail = async (email: string): Promise>} A promise that resolves with a partial array of AtvDocumentType objects + * @param atvDocumentId - The id of the ATV document to update + * @param maxAge - The number of days until deletion (defaults to SUBSCRIPTION_MAX_AGE env var or 90) + * @param fromDate - The date to calculate deletion from (defaults to current date) + * @return The updated document */ -const atvGetDocumentBatch = async (emails: string[]): Promise> => { +const atvUpdateDocumentDeleteAfter = async ( + atvDocumentId: string, + maxAge?: number, + fromDate?: Date, +): Promise> => { try { - const documentObject: AtvDocumentBatchType = { - document_ids: emails - } - - const response: AxiosResponse> = await axios.post( - `${process.env.ATV_API_URL}/v1/documents/batch-list/`, - documentObject, + // First, fetch the existing document to preserve all content + const existingDocResponse: AxiosResponse> = await axios.get( + `${process.env.ATV_API_URL}/v1/documents/${atvDocumentId}`, + { + headers: { + 'x-api-key': process.env.ATV_API_KEY, + }, + }, + ); + + // Calculate new delete_after date + const deleteAfter = fromDate ? new Date(fromDate) : new Date(); + const daysUntilDeletion: number = maxAge || Number(process.env.SUBSCRIPTION_MAX_AGE) || 90; + deleteAfter.setDate(deleteAfter.getDate() + daysUntilDeletion); + + const existingDoc = existingDocResponse.data; + + const updateObject: Partial = { + tos_function_id: existingDoc.tos_function_id, + tos_record_id: existingDoc.tos_record_id, + content: existingDoc.content, + draft: existingDoc.draft, + delete_after: deleteAfter.toISOString().substring(0, 10), + }; + + const response: AxiosResponse> = await axios.patch( + `${process.env.ATV_API_URL}/v1/documents/${atvDocumentId}`, + updateObject, { headers: { 'Content-Type': 'application/json', - 'X-Api-Key': process.env.ATV_API_KEY - } - } - ) - - return response.data - } catch (error: any) { - console.error(error) + 'X-Api-Key': process.env.ATV_API_KEY, + }, + }, + ); - throw new Error('Failed to fetch document. See error log.') + return response.data; + } catch (error: unknown) { + throw new Error('Failed to update ATV document', { + cause: error, + }); } -} +}; /** - * Request email hook function. + * Retrieves a batch of documents for the given emails. * - * @param {FastifyRequest} request - the request object - * @return {void} no return value + * @param emails - The array of document ids for which to retrieve documents + * @return A promise that resolves with a partial array of AtvDocumentType objects */ -const requestEmailHook = async (request: FastifyRequest) => { +const atvGetDocumentBatch = async (emails: string[]): Promise> => { try { - // Hook only runs on POST requests - if (request.method !== 'POST') { - return - } - - // If the POST request has 'email' variable, automatically create ATV document - // and store email there. Only the ATV document Id gets saved in HAV database. - const body: Partial = request.body as Partial - const email: string = (body.email as string)?.trim() - - if (!isValidEmail(email)) { - throw new Error('Invalid email format') - } + const documentObject: AtvDocumentBatchType = { + document_ids: emails, + }; - const atvDocument: Partial = await atvCreateDocumentWithEmail(email) - const atvDocumentId: string | undefined = atvDocument.id + const response: AxiosResponse> = await axios.post( + `${process.env.ATV_API_URL}/v1/documents/batch-list/`, + documentObject, + { + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': process.env.ATV_API_KEY, + }, + }, + ); - if (atvDocumentId) { - request.atvResponse = { - atvDocumentId: atvDocumentId, - } - } - } catch (error) { - console.error('An error occurred:', error) - throw new Error('Could not create document to ATV. Cannot subscribe.') + return response.data; + } catch (error: unknown) { + throw new Error('Failed to fetch document', { + cause: error, + }); } -} - -const isValidEmail = (email: string): boolean => { - const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ - return re.test(String(email).toLowerCase()) -} - -export default fp(async (fastify, opts) => { - // Hook handler automatically creates ATV document for the email - // and sets the returned documentId to atvResponse.email variable - fastify.addHook('preHandler', requestEmailHook) - - // Expose atvQueryEmail function to global scope - fastify.decorate('atvQueryEmail', async function (atvDocumentId: string) { - return atvFetchContentById(atvDocumentId) - }) - - // Expose atvCreateDocumentWithEmail function to global scope - fastify.decorate('atvCreateDocumentWithEmail', async function (email: string) { - return atvCreateDocumentWithEmail(email) - }) +}; + +// @todo: Exposing separate functions that handle ATV +// communication is not the best approach. We should +// create ATV class in src/lib that abstract the API, +// and expose the class as a plugin. +export default fp(async (fastify, _opts) => { + // Expose atvGetDocument function to global scope + fastify.decorate('atvGetDocument', async function atvGetDocument(atvDocumentId: string) { + return atvFetchContentById(atvDocumentId); + }); + + // Expose atvCreateDocument function to global scope + fastify.decorate( + 'atvCreateDocument', + async function atvCreateDocumentHandler(content: object, tosFunctionId: string) { + return atvCreateDocument(content, tosFunctionId); + }, + ); // Expose atvGetDocumentBatch function to global scope - fastify.decorate('atvGetDocumentBatch', async function (emails: string[]) { - return atvGetDocumentBatch(emails) - }) - -}) + fastify.decorate('atvGetDocumentBatch', async function atvGetDocumentBatchHandler(emails: string[]) { + return atvGetDocumentBatch(emails); + }); + + // Expose atvUpdateDocumentDeleteAfter function to global scope + fastify.decorate( + 'atvUpdateDocumentDeleteAfter', + async function atvUpdateDocumentDeleteAfterHandler(atvDocumentId: string, maxAge?: number, fromDate?: Date) { + return atvUpdateDocumentDeleteAfter(atvDocumentId, maxAge, fromDate); + }, + ); +}); declare module 'fastify' { - export interface FastifyRequest { - atvResponse?: AtvResponseType; - } - export interface FastifyInstance { - atvQueryEmail(email: string): Promise>; - atvCreateDocumentWithEmail: (email: string) => Promise>; + atvGetDocument(email: string): Promise>; + atvCreateDocument: (content: object, tosFunctionId: string) => Promise>; atvGetDocumentBatch: (emails: string[]) => Promise>; + atvUpdateDocumentDeleteAfter: ( + atvDocumentId: string, + maxAge?: number, + fromDate?: Date, + ) => Promise>; } } diff --git a/src/plugins/base64.ts b/src/plugins/base64.ts index d695c44..10e8dbf 100644 --- a/src/plugins/base64.ts +++ b/src/plugins/base64.ts @@ -1,19 +1,18 @@ -import fp from 'fastify-plugin' -import { Buffer } from 'buffer' +import { Buffer } from 'node:buffer'; +import fp from 'fastify-plugin'; // Helper plugin to encode/decode base64. // Functions can be used through import or through Fastify instance. -export interface Base64PluginOptions { -} +export type Base64PluginOptions = Record; -export const decode = (str: string):string => Buffer.from(str, 'base64').toString('utf-8'); -export const encode = (str: string):string => Buffer.from(str, 'utf-8').toString('base64'); +export const decode = (str: string): string => Buffer.from(str, 'base64').toString('utf-8'); +export const encode = (str: string): string => Buffer.from(str, 'utf-8').toString('base64'); -export default fp(async (fastify, opts) => { - fastify.decorate('b64decode', decode) - fastify.decorate('b64encode', encode) -}) +export default fp(async (fastify, _opts) => { + fastify.decorate('b64decode', decode); + fastify.decorate('b64encode', encode); +}); declare module 'fastify' { export interface FastifyInstance { diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts new file mode 100644 index 0000000..cd9e7bd --- /dev/null +++ b/src/plugins/dialogi.ts @@ -0,0 +1,102 @@ +import axios, { type AxiosResponse } from 'axios'; +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; +import type { DialogiSmsRequestType, DialogiSmsResponseType } from '../types/dialogi'; + +/** + * Elisa Dialogi SMS Plugin + * + * Provides SMS sending functionality via Elisa Dialogi API + * https://docs.dialogi.elisa.fi/docs/dialogi/send-sms/operations/create-a + */ + +export interface DialogiClient { + /** + * Send an SMS message + * @param destination - Recipient phone number in E.164 format (e.g., "+358501234567") + * @param text - SMS message content + * @returns Promise with Dialogi API response + */ + sendSms(destination: string, text: string): Promise; +} + +export default fp(async function dialogiPlugin(fastify: FastifyInstance) { + // Validate required environment variables + if (!process.env.DIALOGI_API_URL) { + fastify.log.warn('DIALOGI_API_URL not configured - SMS sending will be disabled'); + } + + if (!process.env.DIALOGI_API_KEY) { + fastify.log.warn('DIALOGI_API_KEY not configured - SMS sending will be disabled'); + } + + if (!process.env.DIALOGI_SENDER) { + fastify.log.warn('DIALOGI_SENDER not configured - SMS sending will be disabled'); + } + + const dialogiClient: DialogiClient = { + async sendSms(destination: string, text: string): Promise { + // Check if Dialogi is configured + if (!process.env.DIALOGI_API_URL || !process.env.DIALOGI_API_KEY || !process.env.DIALOGI_SENDER) { + throw new Error( + 'Dialogi SMS service is not configured. Please set DIALOGI_API_URL, DIALOGI_API_KEY, and DIALOGI_SENDER', + ); + } + + try { + const requestBody: DialogiSmsRequestType = { + sender: process.env.DIALOGI_SENDER, + destination, + text, + }; + + const response: AxiosResponse = await axios.post( + process.env.DIALOGI_API_URL, + requestBody, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.DIALOGI_API_KEY}`, + }, + timeout: 10000, // 10 second timeout + }, + ); + + // Extract message ID from response + const messageId = + response.data.messages?.[0]?.[destination]?.messageid || + Object.values(response.data.messages?.[0] || {})[0]?.messageid || + 'unknown'; + + fastify.log.info({ messageId }, 'SMS sent to Dialogi'); + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || error.message; + fastify.log.error( + { + error: errorMessage, + status: error.response?.status, + statusText: error.response?.statusText, + }, + 'Failed to send SMS via Dialogi', + ); + throw new Error(`Dialogi SMS API error: ${errorMessage}`); + } + + fastify.log.error({ error }, 'Unexpected error sending SMS via Dialogi'); + throw error; + } + }, + }; + + // Decorate Fastify instance with Dialogi client + fastify.decorate('dialogi', dialogiClient); +}); + +declare module 'fastify' { + interface FastifyInstance { + dialogi: DialogiClient; + } +} diff --git a/src/plugins/elasticproxy.ts b/src/plugins/elasticproxy.ts index 2bf5fc6..ed3e44f 100644 --- a/src/plugins/elasticproxy.ts +++ b/src/plugins/elasticproxy.ts @@ -1,62 +1,66 @@ +import https from 'node:https'; import axios from 'axios'; -import fp from 'fastify-plugin' -import { ElasticProxyJsonResponseType } from '../types/elasticproxy'; -import https from 'https' +import fp from 'fastify-plugin'; +import type { ElasticProxyJsonResponseType } from '../types/elasticproxy'; // Query Elastic Proxy -export interface ElasticProxyPluginOptions { -} +export type ElasticProxyPluginOptions = Record; /** * Sends a query to the ElasticSearch proxy. - * @param elasticQueryJson - The JSON string representing the ElasticSearch query. - * @returns The response data from the ElasticSearch proxy. + * @param {string} elasticProxyBaseUrl - The base URL of the ElasticSearch proxy. + * @param {string} elasticQueryJson - The JSON string representing the ElasticSearch query. + * @return {Promise} The response data from the ElasticSearch proxy. */ -const queryElasticProxy = async (elasticQueryJson: string): Promise => { - if (!process.env.ELASTIC_PROXY_URL) { - throw new Error('ELASTIC_PROXY_URL is not set') +const queryElasticProxy = async ( + elasticProxyBaseUrl: string, + elasticQueryJson: string, +): Promise => { + if (!elasticProxyBaseUrl) { + throw new Error('elasticProxyBaseUrl is required'); } // Elastic proxy supports ndjson (multipart json requests) or single json searches - const elasticProxyUrl: string = process.env.ELASTIC_PROXY_URL + (elasticQueryJson.startsWith("{}\n") ? '/_msearch' : '/_search'); - const contentType: string = elasticQueryJson.startsWith("{}\n") ? 'application/x-ndjson' : 'application/json'; + const elasticProxyUrl: string = + elasticProxyBaseUrl + (elasticQueryJson.startsWith('{}\n') ? '/_msearch' : '/_search'); + const contentType: string = elasticQueryJson.startsWith('{}\n') ? 'application/x-ndjson' : 'application/json'; try { - let rejectUnauthorized = true - if (process.env.ENVIRONMENT === 'dev') { + let rejectUnauthorized = true; + if (process.env.ENVIRONMENT === 'local') { // On dev/local, ignore errors with docker certs - rejectUnauthorized = false + rejectUnauthorized = false; } const response = await axios.post( elasticProxyUrl, // ElasticProxy requests must terminate to newline or server returns Bad request - elasticQueryJson + (elasticQueryJson.endsWith("\n") ? '' : '\n'), + elasticQueryJson + (elasticQueryJson.endsWith('\n') ? '' : '\n'), { httpsAgent: new https.Agent({ - rejectUnauthorized: rejectUnauthorized + rejectUnauthorized, }), headers: { - 'Content-Type': contentType - } - } - ) + 'Content-Type': contentType, + }, + }, + ); return response.data; } catch (error) { - console.error(error) + console.error(error); - throw new Error('Error while sending request to ElasticSearch proxy') + throw new Error('Error while sending request to ElasticSearch proxy'); } -} +}; -export default fp(async (fastify, opts) => { - fastify.decorate('queryElasticProxy', queryElasticProxy) -}) +export default fp(async (fastify, _opts) => { + fastify.decorate('queryElasticProxy', queryElasticProxy); +}); declare module 'fastify' { export interface FastifyInstance { - queryElasticProxy(elasticQueryJson: string): Promise + queryElasticProxy(elasticProxyBaseUrl: string, elasticQueryJson: string): Promise; } } diff --git a/src/plugins/localizedenvvar.ts b/src/plugins/localizedenvvar.ts index 7c3ed67..e8ef5ee 100644 --- a/src/plugins/localizedenvvar.ts +++ b/src/plugins/localizedenvvar.ts @@ -1,16 +1,14 @@ import fp from 'fastify-plugin'; -import { SubscriptionCollectionLanguageType } from '../types/subscription'; +import type { SubscriptionCollectionLanguageType } from '../types/subscription'; -export interface localizedEnvVarPluginPluginOptions { -} +export type localizedEnvVarPluginPluginOptions = Record; -export const localizedEnvVar = (envVarBase: string, langCode: SubscriptionCollectionLanguageType): string | undefined => { - return process.env[`${envVarBase}_${langCode.toUpperCase()}`] -} +export const localizedEnvVar = (envVarBase: string, langCode: SubscriptionCollectionLanguageType): string | undefined => + process.env[`${envVarBase}_${langCode.toUpperCase()}`]; export default fp(async (fastify) => { - fastify.decorate('localizedEnvVar', localizedEnvVar) -}) + fastify.decorate('localizedEnvVar', localizedEnvVar); +}); declare module 'fastify' { export interface FastifyInstance { diff --git a/src/plugins/mailer.ts b/src/plugins/mailer.ts index 35a026d..68f6596 100644 --- a/src/plugins/mailer.ts +++ b/src/plugins/mailer.ts @@ -1,29 +1,30 @@ -import fp from 'fastify-plugin' -import { FastifyInstance } from 'fastify' -import { FastifyMailer } from '../types/mailer' +import fp from 'fastify-plugin'; +import type { FastifyMailer } from '../types/mailer'; // Initialize mailer as plugin -export default fp(async function (fastify: FastifyInstance) { +export default fp(async function mailerPlugin(fastify) { const opts = { - defaults: { - from: process.env.MAIL_FROM + defaults: { + from: process.env.MAIL_FROM, }, transport: { host: process.env.MAIL_HOST, port: process.env.MAIL_PORT, - secure: (process.env.MAIL_SECURE == "true " ? true : false), + secure: process.env.MAIL_SECURE === 'true', auth: { user: process.env.MAIL_AUTH_USER, - pass: process.env.MAIL_AUTH_PASS - } - } - } + pass: process.env.MAIL_AUTH_PASS, + }, + }, + }; - fastify.register(require('fastify-mailer'), opts) -}) + // eslint-disable-next-line global-require + fastify.register(require('fastify-mailer'), opts); +}); -declare module "fastify" { +declare module 'fastify' { + // eslint-disable-next-line no-shadow interface FastifyInstance { mailer: FastifyMailer; } diff --git a/src/plugins/mongodb.ts b/src/plugins/mongodb.ts index d566a4b..100c61b 100644 --- a/src/plugins/mongodb.ts +++ b/src/plugins/mongodb.ts @@ -1,12 +1,11 @@ -import fp from 'fastify-plugin' -import mongo from '@fastify/mongodb' -import { FastifyInstance } from 'fastify' +import mongo from '@fastify/mongodb'; +import fp from 'fastify-plugin'; // MongoDB connection -export default fp(async function (fastify: FastifyInstance) { - fastify.register(mongo, { - url: process.env.MONGODB, - forceClose: true - }) +export default fp(async function mongodbPlugin(fastify) { + fastify.register(mongo, { + url: process.env.MONGODB, + forceClose: true, + }); }); diff --git a/src/plugins/randhash.ts b/src/plugins/randhash.ts index 191c135..9f1218a 100644 --- a/src/plugins/randhash.ts +++ b/src/plugins/randhash.ts @@ -1,15 +1,14 @@ -import fp from 'fastify-plugin' +import fp from 'fastify-plugin'; // Helper plugin for random hash -export interface RandHashPluginOptions { -} +export type RandHashPluginOptions = Record; -export default fp(async (fastify, opts) => { - fastify.decorate('getRandHash', function () { - return (Math.random() + 1).toString(36).substring(2) - }) -}) +export default fp(async (fastify, _opts) => { + fastify.decorate('getRandHash', function getRandHash() { + return (Math.random() + 1).toString(36).substring(2); + }); +}); declare module 'fastify' { export interface FastifyInstance { diff --git a/src/plugins/sensible.ts b/src/plugins/sensible.ts index 0cf52d5..d42fe9f 100644 --- a/src/plugins/sensible.ts +++ b/src/plugins/sensible.ts @@ -1,8 +1,7 @@ -import fp from 'fastify-plugin' -import sensible, { SensibleOptions } from '@fastify/sensible' +import sensible, { type FastifySensibleOptions } from '@fastify/sensible'; +import fp from 'fastify-plugin'; -// This plugins adds some utilities to handle http errors - -export default fp(async (fastify) => { - fastify.register(sensible) -}) +// This plugin adds some utilities to handle http errors +export default fp(async (fastify) => { + fastify.register(sensible); +}); diff --git a/src/plugins/sentry.ts b/src/plugins/sentry.ts index 6d98605..2194f20 100644 --- a/src/plugins/sentry.ts +++ b/src/plugins/sentry.ts @@ -1,7 +1,8 @@ -import Sentry from '@sentry/core' +// eslint-disable-next-line import/no-extraneous-dependencies +import type * as Sentry from '@sentry/node'; declare module 'fastify' { - export interface FastifyInstance { - Sentry: typeof Sentry - } + export interface FastifyInstance { + Sentry: typeof Sentry; + } } diff --git a/src/plugins/token.ts b/src/plugins/token.ts deleted file mode 100644 index 3f8b901..0000000 --- a/src/plugins/token.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fp from 'fastify-plugin' - -// Validate token in request headers - -export default fp(async (fastify, opts) => { - fastify.addHook('preHandler', async (request, reply) => { - // Skip token check for health check routes - if (request.url === '/healthz' || request.url === '/readiness') { - return true - } - - if (!request.headers.token) { - reply - .code(403) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Authentication failed.'}) - } - - // TODO: Do something with the token - - return true - }) -}) - -declare module 'fastify' { - export interface FastifyRequest { - tokenAuthentication?: boolean - } -} diff --git a/src/plugins/validateElasticQuery.ts b/src/plugins/validateElasticQuery.ts new file mode 100644 index 0000000..c3c0705 --- /dev/null +++ b/src/plugins/validateElasticQuery.ts @@ -0,0 +1,75 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import fp from 'fastify-plugin'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import type { SubscriptionRequestType } from '../types/subscription'; + +export type ValidateElasticQueryPluginOptions = Record; + +/** + * Pre-handler hook to validate Elastic queries before saving subscriptions. + * This prevents broken queries from being saved in the database. + * + * @param request - the request object + * @param fastify - fastify instance + */ +const validateElasticQueryHook = async (request: FastifyRequest, fastify: FastifyInstance) => { + try { + // Only run on POST requests to /subscription endpoint + if (request.method !== 'POST' || request.url !== '/subscription') { + return; + } + + const body: Partial = request.body as Partial; + const siteId = body.site_id; + const elasticQuery = body.elastic_query; + + if (!siteId) { + throw new Error('site_id is required'); + } + + if (!elasticQuery) { + throw new Error('elastic_query is required'); + } + + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfig = configLoader.getConfiguration(siteId); + + if (!siteConfig) { + throw new Error(`Invalid site_id: ${siteId}`); + } + + // Decode elastic_query + const decodedQuery = fastify.b64decode(elasticQuery); + + // Validate the query by executing it against Elastic + await fastify.queryElasticProxy(siteConfig.elasticProxyUrl, decodedQuery); + + request.elasticQueryValidation = { + isValid: true, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error validating Elastic query'; + fastify.log.error({ error: errorMessage }, 'Elastic query validation failed'); + + request.elasticQueryValidation = { + isValid: false, + error: errorMessage, + }; + } +}; + +export default fp(async (fastify, _opts) => { + fastify.addHook('preHandler', async (request) => { + await validateElasticQueryHook(request, fastify); + }); +}); + +declare module 'fastify' { + export interface FastifyRequest { + elasticQueryValidation?: { + isValid: boolean; + error?: string; + }; + } +} diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index d872820..9383b36 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -1,101 +1,280 @@ -import { - FastifyPluginAsync, - FastifyRequest, - FastifyReply, - FastifyInstance -} from 'fastify' - -import { +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import libphonenumber from 'google-libphonenumber'; +import { confirmationEmail, confirmationSms } from '../lib/email'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import { generateUniqueSmsCode } from '../lib/smsCode'; +import { atvCreateDocument } from '../plugins/atv'; +import type { AtvDocumentType } from '../types/atv'; +import { Generic400Error, type Generic400ErrorType, Generic500Error, type Generic500ErrorType } from '../types/error'; +import type { QueueInsertDocument } from '../types/queue'; +import { + type SubscriptionCollectionType, + SubscriptionRequest, + type SubscriptionRequestType, SubscriptionResponse, - SubscriptionResponseType, - SubscriptionCollectionType, - SubscriptionRequest, - SubscriptionRequestType, - SubscriptionStatus -} from '../types/subscription' - -import { - Generic500Error, - Generic500ErrorType -} from '../types/error' - -import { confirmationEmail } from '../lib/email' -import { QueueInsertDocumentType } from '../types/mailer' - -// Add subscription to given query parameters - -const subscription: FastifyPluginAsync = async ( - fastify: FastifyInstance, - opts: object -): Promise => { + type SubscriptionResponseType, + SubscriptionStatus, +} from '../types/subscription'; + +// Validation helpers +const isValidEmail = (email: string): boolean => { + const re = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return re.test(String(email).toLowerCase()); +}; + +const phoneUtil = libphonenumber.PhoneNumberUtil.getInstance(); + +const parsePhoneNumber = (sms: string): string => { + const parsed = phoneUtil.parse(sms, 'FI'); + if (!phoneUtil.isValidNumber(parsed)) { + throw new Error('Invalid phone number.'); + } + return phoneUtil.format(parsed, libphonenumber.PhoneNumberFormat.E164); +}; + +/** + * Stores user data in ATV. + */ +async function storeUserData(body: SubscriptionRequestType) { + const email = body.email?.trim(); + const phone = body.sms?.trim(); + + let atvDocument: Partial; + + try { + atvDocument = await atvCreateDocument( + { + ...(email && { email: email }), + ...(phone && { sms: phone }), + }, + 'atvCreateDocumentWithEmail', + ); + } catch (error) { + throw new Error('Could not create document to ATV.', { + cause: error, + }); + } + + if (!atvDocument || !atvDocument.id) { + throw new Error('Could not create document to ATV.'); + } + + return atvDocument.id; +} + +const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { fastify.post<{ - Body: SubscriptionRequestType, - Reply: SubscriptionResponseType | Generic500ErrorType - }>('/subscription', { - schema: { - body: SubscriptionRequest, - response: { - 200: SubscriptionResponse, - 500: Generic500Error + Body: SubscriptionRequestType; + Reply: SubscriptionResponseType | Generic400ErrorType | Generic500ErrorType; + }>( + '/subscription', + { + schema: { + body: SubscriptionRequest, + response: { + 200: SubscriptionResponse, + 400: Generic400Error, + 500: Generic500Error, + }, + }, + preValidation: (request, reply, done): void => { + // Validate email and SMS BEFORE ATV document creation + // preValidation runs BEFORE preHandler (where ATV storage happens) + const email = request.body.email?.trim(); + const sms = request.body.sms?.trim(); + + if (!email && !sms) { + reply.code(400).send({ error: 'Either email or sms is required.', field: 'email' }); + done(); + return; + } + + if (email && !isValidEmail(email)) { + reply.code(400).send({ error: 'Invalid email format.', field: 'email' }); + done(); + return; + } + + if (sms) { + try { + // Normalize the phone number to E.164 format. + request.body.sms = parsePhoneNumber(sms); + } catch { + reply.code(400).send({ error: 'Invalid phone number format.', field: 'sms' }); + done(); + return; + } + } + + done(); + return; + }, + }, + async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { + const mongodb = fastify.mongo; + const collection = mongodb.db?.collection('subscription'); + const hash = fastify.getRandHash(); + + // Check if elastic query validation failed. + // These checks are run in a plugin that writes + // results to request globals. + if (request.elasticQueryValidation && !request.elasticQueryValidation.isValid) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ + error: `Invalid elastic_query: ${request.elasticQueryValidation.error || 'Query validation failed'}`, + }); } - } - }, async ( - request: FastifyRequest<{ Body: SubscriptionRequestType }>, - reply: FastifyReply - ) => { - const mongodb = fastify.mongo - const collection = mongodb.db?.collection('subscription') - const hash = fastify.getRandHash() - - // Replace email in request with ATV hashed email - if (!(request?.atvResponse?.atvDocumentId)) - return reply - .code(500) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Could not find hashed email. Subscription not added.' }) - request.body.email = request.atvResponse.atvDocumentId; - - // Subscription data that goes to collection - const subscription: Partial = { - ...request.body, - hash: hash, - created: new Date(), - modified: new Date(), - last_checked: Math.floor(Date.now() / 1000), - expiry_notification_sent: SubscriptionStatus.INACTIVE, - status: SubscriptionStatus.INACTIVE - }; - - const response = await collection?.insertOne(subscription) - if (!response) { - fastify.log.debug(response) - - throw new Error('Adding new subscription failed. See logs.') - } - - // Insert email in queue - - const subscribeLinkBase = fastify.localizedEnvVar('BASE_URL', request.body.lang) - const emailContent = await confirmationEmail(request.body.lang, { - link: subscribeLinkBase + `/hakuvahti/confirm?subscription=${response.insertedId}&hash=${hash}` - }) - - // Email data to queue - const email:QueueInsertDocumentType = { - email: request.body.email, - content: emailContent - } - - const q = mongodb.db?.collection('queue') - await q?.insertOne(email) - - fastify.log.debug(emailContent) - - return reply - .code(200) - .header('Content-Type', 'application/json; charset=utf-8') - .send(response); - }) -} -export default subscription + // Load site configuration + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfig = configLoader.getConfiguration(request.body.site_id); + + if (!siteConfig) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid site_id provided.' }); + } + + const hasSms = !!siteConfig.subscription?.enableSms && !!request.body.sms; + const hasEmail = !!request.body.email; + + // Store user data in ATV. + try { + const atvId = await storeUserData(request.body); + + // Remove user data from request body. + delete request.body.sms; + delete request.body.email; + + // @fixme: these are confusing field names for ATV id. + if (hasEmail) { + request.body.email = atvId; + } + + if (hasSms) { + if (!hasEmail) { + // Email is required field and saving the subscription fails if it is null. + request.body.email = ''; + } + + request.body.sms = atvId; + } + } catch { + return reply + .code(500) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Could not find hashed email. Subscription not added.' }); + } + + // Store user query to ATV on callee request. + // The query itself might contain user data that we must store in ATV. + const elasticQueryAtv = request.body.elastic_query_atv; + if (elasticQueryAtv) { + const atvDocument = await fastify.atvCreateDocument( + { + elastic_query: request.body.elastic_query, + }, + 'atvCreateDocumentWithQuery', + ); + if (atvDocument.id) { + request.body.elastic_query = atvDocument.id; + } else { + return reply + .code(500) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Could not create ATV document for query. Subscription not added.' }); + } + } + + // Subscription data that goes to collection. + const now = new Date(); + const deleteAfter = new Date(now); + deleteAfter.setDate(deleteAfter.getDate() + siteConfig.subscription.maxAge); + + const subscriptionData: Partial = { + ...request.body, + hash, + created: now, + modified: now, + last_checked: Math.floor(Date.now() / 1000), + expiry_notification_sent: SubscriptionStatus.INACTIVE, + status: SubscriptionStatus.INACTIVE, + has_sms: hasSms, + has_email: hasEmail, + delete_after: deleteAfter, + }; + + // Generate SMS code if SMS is enabled for this subscription and site + if (hasSms) { + subscriptionData.sms_code = await generateUniqueSmsCode(collection); + subscriptionData.sms_code_created = now; + } + + const response = await collection?.insertOne(subscriptionData); + if (!response) { + fastify.log.debug(response); + + throw new Error('Adding new subscription failed. See logs.'); + } + + const subscribeLinkBase = + request.body.lang in siteConfig.urls ? siteConfig.urls[request.body.lang] : siteConfig.urls.base; + + // @todo Should we do error handling for notifications? + // What to do if sending notifications fails? At that point, all user + // data is already stored in ATV, but the user has no way to enable + // the subscription. + await Promise.all([ + // Queue email confirmation: + hasEmail && + (async () => { + const document: QueueInsertDocument = { + type: 'email', + atv_id: request.body.email ?? '', + content: await confirmationEmail( + request.body.lang, + { + link: `${subscribeLinkBase}/hakuvahti/confirm?subscription=${response.insertedId}&hash=${hash}`, + search_description: request.body.search_description, + }, + siteConfig, + ), + }; + + console.info('Sending email confirmation message to', response.insertedId, document); + + return mongodb.db?.collection('queue')?.insertOne(document); + })(), + + // Queue sms confirmation: + hasSms && + (async () => { + const document: QueueInsertDocument = { + type: 'sms', + atv_id: request.body.sms ?? '', + content: await confirmationSms( + request.body.lang, + { + sms_code: subscriptionData.sms_code ?? '', + }, + siteConfig, + ), + }; + + console.info('Sending sms confirmation message to', response.insertedId, document); + + return mongodb.db?.collection('queue')?.insertOne(document); + })(), + ]); + + return reply.code(200).header('Content-Type', 'application/json; charset=utf-8').send(response); + }, + ); +}; + +export default subscription; diff --git a/src/routes/confirmSubscription.ts b/src/routes/confirmSubscription.ts index ed1938a..8a88b18 100644 --- a/src/routes/confirmSubscription.ts +++ b/src/routes/confirmSubscription.ts @@ -1,74 +1,59 @@ -import { - FastifyPluginAsync, - FastifyReply, - FastifyInstance, - FastifyRequest -} from 'fastify' +import { ObjectId } from '@fastify/mongodb'; +import type { FastifyPluginAsync } from 'fastify'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; -import { - Generic500Error, - Generic500ErrorType -} from '../types/error' +import { + SubscriptionGenericPostResponse, + type SubscriptionGenericPostResponseType, + SubscriptionStatus, +} from '../types/subscription'; -import { - SubscriptionGenericPostResponse, - SubscriptionGenericPostResponseType, - SubscriptionStatus -} from '../types/subscription' - -import { ObjectId } from '@fastify/mongodb' - // Confirms subscription - -const confirmSubscription: FastifyPluginAsync = async ( - fastify: FastifyInstance, - opts: object -): Promise => { +const confirmSubscription: FastifyPluginAsync = async (fastify, _opts) => { + // @fixme change request type to post. fastify.get<{ - Reply: SubscriptionGenericPostResponseType | Generic500ErrorType - }>('/subscription/confirm/:id/:hash', { - schema: { - response: { - 200: SubscriptionGenericPostResponse, - 500: Generic500Error - } - } - }, async ( - request: FastifyRequest, - reply: FastifyReply - ) => { - const mongodb = fastify.mongo - const collection = mongodb.db?.collection('subscription'); - const { id, hash } = <{ id: string, hash: string }>request.params + Reply: SubscriptionGenericPostResponseType | Generic500ErrorType; + }>( + '/subscription/confirm/:id/:hash', + { + schema: { + response: { + 200: SubscriptionGenericPostResponse, + 500: Generic500Error, + }, + }, + }, + async (request, reply) => { + const { id, hash } = request.params as { id: string; hash: string }; - const subscription = await collection?.findOne({ - _id: new ObjectId(id), - hash: hash, - status: SubscriptionStatus.INACTIVE - }); + // Set status to active if the client known object id and hash value. + const response = await fastify.mongo.db?.collection('subscription')?.updateOne( + { + _id: new ObjectId(id), + hash, + status: SubscriptionStatus.INACTIVE, + }, + { $set: { status: SubscriptionStatus.ACTIVE } }, + ); - if (!subscription) { - return reply - .code(404) - .send({ - statusCode: 404, - statusMessage: 'Subscription not found.' + if (response?.modifiedCount) { + fastify.log.info({ + level: 'info', + message: `Subscription ${id} confirmed`, }); - } - await collection!.updateOne( - { _id: new ObjectId(id) }, - { $set: { status: SubscriptionStatus.ACTIVE } }, - ) + return reply.code(200).header('Content-Type', 'application/json; charset=utf-8').send({ + statusCode: 200, + statusMessage: 'Subscription enabled.', + }); + } else { + return reply.code(404).header('Content-Type', 'application/json; charset=utf-8').send({ + statusCode: 404, + statusMessage: 'Subscription not found.', + }); + } + }, + ); +}; - return reply - .code(200) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ - statusCode: 200, - statusMessage: 'Subscription enabled.' - }) - }) -} - -export default confirmSubscription +export default confirmSubscription; diff --git a/src/routes/deleteSubscription.ts b/src/routes/deleteSubscription.ts index 4cea572..5c5327f 100644 --- a/src/routes/deleteSubscription.ts +++ b/src/routes/deleteSubscription.ts @@ -1,84 +1,48 @@ -import { - FastifyPluginAsync, - FastifyReply, - FastifyInstance, - FastifyRequest -} from 'fastify' +import { ObjectId } from '@fastify/mongodb'; +import type { FastifyPluginAsync } from 'fastify'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; -import { - Generic500Error, - Generic500ErrorType -} from '../types/error' - -import { - SubscriptionGenericPostResponse, - SubscriptionGenericPostResponseType -} from '../types/subscription' -import { ObjectId } from '@fastify/mongodb' +import { SubscriptionGenericPostResponse, type SubscriptionGenericPostResponseType } from '../types/subscription'; // Deletes subscription - -const deleteSubscription: FastifyPluginAsync = async ( - fastify: FastifyInstance, - opts: object -): Promise => { +const deleteSubscription: FastifyPluginAsync = async (fastify, _opts) => { fastify.delete<{ - Reply: SubscriptionGenericPostResponseType | Generic500ErrorType - }>('/subscription/delete/:id/:hash', { - schema: { - response: { - 200: SubscriptionGenericPostResponse, - 500: Generic500Error + Reply: SubscriptionGenericPostResponseType | Generic500ErrorType; + }>( + '/subscription/delete/:id/:hash', + { + schema: { + response: { + 200: SubscriptionGenericPostResponse, + 500: Generic500Error, + }, + }, + }, + async (request, reply) => { + const { id, hash } = request.params as { id: string; hash: string }; + + // Delete subscription if client knows object id and hash. + const result = await fastify.mongo.db?.collection('subscription')?.deleteOne({ _id: new ObjectId(id), hash }); + + if (result?.deletedCount === 0) { + return reply.code(404).send({ + statusCode: 404, + statusMessage: 'Subscription not found.', + }); + } else { + fastify.log.info({ + level: 'info', + message: `Subscription ${id} deleted`, + result, + }); + + return reply.code(200).send({ + statusCode: 200, + statusMessage: 'Subscription deleted', + }); } - } - }, async ( - request: FastifyRequest, - reply: FastifyReply - ) => { - const mongodb = fastify.mongo - const collection = mongodb.db?.collection('subscription'); - const { id, hash } = <{ id: string, hash: string }>request.params - - // Check that subscription exists and hash matches - const subscription = await collection?.findOne({ - _id: new ObjectId(id), - hash: hash - }); - - if (!subscription) { - return reply - .code(404) - .send({ - statusCode: 404, - statusMessage: 'Subscription not found.' - }) - } - - // Delete subscription - const result = await collection?.deleteOne({ _id: new ObjectId(id) }) - - fastify.log.info({ - level: 'info', - message: 'Subscription deleted', - result: result - }) - - if (result?.deletedCount === 0) { - return reply - .code(404) - .send({ - statusCode: 404, - statusMessage: 'Subscription not found.' - }) - } - - return reply - .code(200) - .send({ - statusCode: 200, - message: 'Subscription deleted' - }) - }) -} + }, + ); +}; -export default deleteSubscription +export default deleteSubscription; diff --git a/src/routes/healthzAndReadiness.ts b/src/routes/healthzAndReadiness.ts index 4df8412..4c1f1aa 100644 --- a/src/routes/healthzAndReadiness.ts +++ b/src/routes/healthzAndReadiness.ts @@ -1,85 +1,74 @@ -import { - FastifyPluginAsync, - FastifyReply, - FastifyInstance, - FastifyRequest -} from 'fastify'; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; -const healthzAndReadiness: FastifyPluginAsync = async ( - fastify: FastifyInstance, - opts: object -): Promise => { - fastify.get('/healthz', { - schema: { - response: { - 200: { - type: 'object', - properties: { - statusCode: { type: 'number' }, - message: { type: 'string' } +const healthzAndReadiness: FastifyPluginAsync = async (fastify, _opts) => { + fastify.get( + '/healthz', + { + logLevel: 'silent', + schema: { + response: { + 200: { + type: 'object', + properties: { + statusCode: { type: 'number' }, + message: { type: 'string' }, + }, + required: ['statusCode', 'message'], }, - required: ['statusCode', 'message'] - } - } - } - }, async ( - request: FastifyRequest, - reply: FastifyReply - ) => { - return reply - .code(200) - .send({ + }, + }, + }, + async (_request: FastifyRequest, reply: FastifyReply) => + reply.code(200).send({ statusCode: 200, - message: 'OK' - }) - }) + message: 'OK', + }), + ); - fastify.get('/readiness', { - schema: { - response: { - 200: { - type: 'object', - properties: { - statusCode: { type: 'number' }, - message: { type: 'string' } + fastify.get( + '/readiness', + { + logLevel: 'silent', + schema: { + response: { + 200: { + type: 'object', + properties: { + statusCode: { type: 'number' }, + message: { type: 'string' }, + }, + required: ['statusCode', 'message'], }, - required: ['statusCode', 'message'] - }, - 500: { - type: 'object', - properties: { - statusCode: { type: 'number' }, - message: { type: 'string' } + 500: { + type: 'object', + properties: { + statusCode: { type: 'number' }, + message: { type: 'string' }, + }, + required: ['statusCode', 'message'], }, - required: ['statusCode', 'message'] - } - } - } - }, async ( - request: FastifyRequest, - reply: FastifyReply - ) => { - const mongodb = fastify.mongo; + }, + }, + }, + async (_request: FastifyRequest, reply: FastifyReply) => { + const mongodb = fastify.mongo; - try { - // Check MongoDB connection - await mongodb.db?.command({ ping: 1 }); + try { + // Check MongoDB connection + await mongodb.db?.command({ ping: 1 }); - return reply - .code(200) - .send({ + return reply.code(200).send({ statusCode: 200, - message: 'OK' - }) - } catch (error) { - return reply - .code(500) - .send({ + message: 'OK', + }); + } catch { + return reply.code(500).send({ statusCode: 500, - message: 'MongoDB connection failed' - }) - } - }) -} + message: 'MongoDB connection failed', + }); + } + }, + ); +}; export default healthzAndReadiness; diff --git a/src/routes/renewSubscription.ts b/src/routes/renewSubscription.ts new file mode 100644 index 0000000..7c1a69d --- /dev/null +++ b/src/routes/renewSubscription.ts @@ -0,0 +1,131 @@ +import { ObjectId } from '@fastify/mongodb'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; + +import { + SubscriptionRenewResponse, + type SubscriptionRenewResponseType, + SubscriptionStatus, +} from '../types/subscription'; + +// Renews subscription by resetting the created timestamp + +const renewSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { + fastify.get<{ + Reply: SubscriptionRenewResponseType | Generic500ErrorType; + }>( + '/subscription/renew/:id/:hash', + { + schema: { + response: { + 200: SubscriptionRenewResponse, + 500: Generic500Error, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const mongodb = fastify.mongo; + const collection = mongodb.db?.collection('subscription'); + const { id, hash } = request.params as { id: string; hash: string }; + + // Find subscription with matching id and hash + const subscription = await collection?.findOne({ + _id: new ObjectId(id), + hash, + }); + + if (!subscription) { + return reply.code(404).send({ + statusCode: 404, + statusMessage: 'Subscription not found.', + }); + } + + // Only allow renewal for ACTIVE subscriptions + if (subscription.status !== SubscriptionStatus.ACTIVE) { + return reply.code(400).send({ + statusCode: 400, + statusMessage: 'Only active subscriptions can be renewed.', + }); + } + + // Load site configuration to get maxAge and expiryNotificationDays + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfig = configLoader.getConfiguration(subscription.site_id); + + if (!siteConfig) { + return reply.code(500).send({ + statusCode: 500, + statusMessage: 'Site configuration not found.', + }); + } + + // Calculate when the expiry notification would be sent + const daysBeforeExpiry = siteConfig.subscription.expiryNotificationDays; + const subscriptionValidForDays = siteConfig.subscription.maxAge; + const subscriptionExpiresAt = + new Date(subscription.created).getTime() + subscriptionValidForDays * 24 * 60 * 60 * 1000; + const subscriptionExpiryNotificationDate = new Date( + subscriptionExpiresAt - daysBeforeExpiry * 24 * 60 * 60 * 1000, + ); + + // Only allow renewal if current time is past the expiry notification date + if (Date.now() < subscriptionExpiryNotificationDate.getTime()) { + return reply.code(400).send({ + statusCode: 400, + statusMessage: 'Subscription cannot be renewed yet.', + }); + } + + // Archive the original created date if not already archived + const now = new Date(); + const newDeleteAfter = new Date(now); + newDeleteAfter.setDate(newDeleteAfter.getDate() + subscriptionValidForDays); + + const updateFields: Record = { + created: now, + modified: now, + expiry_notification_sent: SubscriptionStatus.INACTIVE, + delete_after: newDeleteAfter, + }; + + // Only set first_created if it doesn't exist yet (for multiple renewals) + if (!subscription.first_created) { + updateFields.first_created = subscription.created; + } + + // Update ATV document's delete_after timestamp to match the new subscription expiry + try { + await fastify.atvUpdateDocumentDeleteAfter(subscription.email, subscriptionValidForDays, now); + } catch (error) { + fastify.log.error({ + level: 'error', + message: 'Failed to update ATV document delete_after timestamp', + error, + subscriptionId: id, + atvDocumentId: subscription.email, + }); + return reply.code(500).send({ + statusCode: 500, + statusMessage: 'Failed to update subscription expiry in storage.', + }); + } + + // Update subscription with new created timestamp + await collection?.updateOne({ _id: new ObjectId(id) }, { $set: updateFields }); + + // Calculate new expiry date + const newExpiryDate = new Date(Date.now() + subscriptionValidForDays * 24 * 60 * 60 * 1000); + + return reply.code(200).header('Content-Type', 'application/json; charset=utf-8').send({ + statusCode: 200, + statusMessage: 'Subscription renewed successfully.', + expiryDate: newExpiryDate.toISOString(), + }); + }, + ); +}; + +export default renewSubscription; diff --git a/src/routes/root.ts b/src/routes/root.ts index 27918c7..173027c 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,9 +1,9 @@ -import { FastifyPluginAsync } from 'fastify' +import type { FastifyPluginAsync } from 'fastify'; -const root: FastifyPluginAsync = async (fastify, opts): Promise => { - fastify.get('/', async function (request, reply) { - return { root: true } - }) -} +const root: FastifyPluginAsync = async (fastify, _opts): Promise => { + fastify.get('/', async function rootHandler(_request, _reply) { + return { root: true }; + }); +}; -export default root +export default root; diff --git a/src/routes/smsSubscription.ts b/src/routes/smsSubscription.ts new file mode 100644 index 0000000..d4879c5 --- /dev/null +++ b/src/routes/smsSubscription.ts @@ -0,0 +1,161 @@ +import type { ObjectId } from '@fastify/mongodb'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import { findSubscriptionByCode, verifySmsRequest } from '../lib/smsCode'; +import { confirmSubscription, deleteSubscription, renewSubscription } from '../lib/subscriptionActions'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; +import type { SiteConfigurationType } from '../types/siteConfig'; +import { + SmsVerificationRequest, + type SmsVerificationRequestType, + SmsVerificationResponse, + type SmsVerificationResponseType, + type SubscriptionStatus, + type VerificationSubscriptionType, +} from '../types/subscription'; + +type SmsAction = 'confirm' | 'delete' | 'renew'; + +/** + * Shared schema for all SMS verification endpoints. + */ +const smsSchema = { + body: SmsVerificationRequest, + response: { + 200: SmsVerificationResponse, + 400: SmsVerificationResponse, + 401: SmsVerificationResponse, + 403: SmsVerificationResponse, + 404: SmsVerificationResponse, + 429: SmsVerificationResponse, + 500: Generic500Error, + }, +}; + +/** + * Get expiry minutes based on action type. + */ +const getExpireMinutes = (action: SmsAction, siteConfig: SiteConfigurationType): number => { + if (action === 'confirm') { + return siteConfig.subscription.smsCodeExpireConfirmMinutes ?? 60; + } + return siteConfig.subscription.smsCodeExpireActionMinutes ?? 720; +}; + +/** + * Execute the subscription action based on type. + */ +const executeAction = async ( + action: SmsAction, + collection: ReturnType['collection']>, + subscription: VerificationSubscriptionType, + siteConfig: SiteConfigurationType, + fastify: FastifyInstance, +) => { + const subscriptionId = subscription._id as ObjectId; + + switch (action) { + case 'confirm': + return confirmSubscription(collection, subscriptionId); + + case 'delete': + return deleteSubscription(collection, subscriptionId); + + case 'renew': { + const subscriptionDoc = { + _id: subscriptionId, + email: subscription.email, + site_id: subscription.site_id, + status: subscription.status as SubscriptionStatus, + created: new Date(subscription.created as Date), + first_created: subscription.first_created ? new Date(subscription.first_created as Date) : undefined, + }; + return renewSubscription(collection, subscriptionDoc, siteConfig, fastify.atvUpdateDocumentDeleteAfter); + } + } +}; + +/** + * Create SMS verification handler for a specific action. + */ +const createSmsHandler = + (action: SmsAction, fastify: FastifyInstance) => + async (request: FastifyRequest<{ Body: SmsVerificationRequestType }>, reply: FastifyReply) => { + const { sms_code, number } = request.body; + const collection = fastify.mongo.db?.collection('subscription'); + + if (!collection) { + return reply.code(500).send({ error: 'Database not available' }); + } + + // Find subscription by SMS code + const subscription = await findSubscriptionByCode(collection, sms_code); + if (!subscription) { + return reply.code(404).send({ + statusCode: 404, + statusMessage: 'Invalid verification code.', + }); + } + + // Load site configuration and check enableSms + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + + const siteConfig = configLoader.getConfiguration(subscription.site_id); + if (!siteConfig?.subscription.enableSms) { + return reply.code(403).send({ + statusCode: 403, + statusMessage: 'SMS verification is not enabled for this site.', + }); + } + + // Verify with correct expiry from config (check expiry + validate phone) + const expireMinutes = getExpireMinutes(action, siteConfig); + const verification = await verifySmsRequest(subscription, number, expireMinutes, fastify.atvGetDocument); + + if (!verification.success) { + const error = verification.error || { statusCode: 500, statusMessage: 'Verification failed' }; + return reply.code(error.statusCode).send(error); + } + + // Execute action + const result = await executeAction(action, collection, subscription, siteConfig, fastify); + + if (result.success) { + fastify.log.info({ + level: 'info', + message: `Subscription ${subscription._id} ${action}ed via SMS`, + }); + } + + return reply.code(result.statusCode).send({ + statusCode: result.statusCode, + statusMessage: result.statusMessage, + expiryDate: result.expiryDate, + }); + }; + +/** + * SMS-based subscription actions. + * - POST /subscription/confirm/sms + * - POST /subscription/delete/sms + * - POST /subscription/renew/sms + */ +const smsSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { + fastify.post<{ + Body: SmsVerificationRequestType; + Reply: SmsVerificationResponseType | Generic500ErrorType; + }>('/subscription/confirm/sms', { schema: smsSchema }, createSmsHandler('confirm', fastify)); + + fastify.post<{ + Body: SmsVerificationRequestType; + Reply: SmsVerificationResponseType | Generic500ErrorType; + }>('/subscription/delete/sms', { schema: smsSchema }, createSmsHandler('delete', fastify)); + + fastify.post<{ + Body: SmsVerificationRequestType; + Reply: SmsVerificationResponseType | Generic500ErrorType; + }>('/subscription/renew/sms', { schema: smsSchema }, createSmsHandler('renew', fastify)); +}; + +export default smsSubscription; diff --git a/src/routes/subscriptionStatus.ts b/src/routes/subscriptionStatus.ts new file mode 100644 index 0000000..f235314 --- /dev/null +++ b/src/routes/subscriptionStatus.ts @@ -0,0 +1,63 @@ +import { ObjectId } from '@fastify/mongodb'; +import type { FastifyPluginAsync } from 'fastify'; +import { Generic500Error, type Generic500ErrorType, GenericResponse, type GenericResponseType } from '../types/error'; + +import { + SubscriptionStatus, + SubscriptionStatusResponse, + type SubscriptionStatusResponseType, +} from '../types/subscription'; + +// Checks subscription status +const subscriptionStatus: FastifyPluginAsync = async (fastify, _opts) => { + fastify.get<{ + Reply: SubscriptionStatusResponseType | GenericResponseType | Generic500ErrorType; + }>( + '/subscription/status/:id/:hash', + { + schema: { + response: { + 200: SubscriptionStatusResponse, + 404: GenericResponse, + 500: Generic500Error, + }, + }, + }, + async (request, reply) => { + const { id, hash } = request.params as { id: string; hash: string }; + + const subscription = await fastify.mongo.db?.collection('subscription')?.findOne({ + _id: new ObjectId(id), + hash, + }); + + if (!subscription) { + return reply.code(404).send({ + statusMessage: 'Subscription not found.', + }); + } + + // Map numeric status to text value + let statusText: 'active' | 'inactive' | 'disabled'; + switch (subscription.status) { + case SubscriptionStatus.ACTIVE: + statusText = 'active'; + break; + case SubscriptionStatus.INACTIVE: + statusText = 'inactive'; + break; + case SubscriptionStatus.DISABLED: + statusText = 'disabled'; + break; + default: + statusText = 'inactive'; + } + + return reply.code(200).header('Content-Type', 'application/json; charset=utf-8').send({ + subscriptionStatus: statusText, + }); + }, + ); +}; + +export default subscriptionStatus; diff --git a/src/templates/index.html b/src/templates/index.html deleted file mode 100644 index 7b845bc..0000000 --- a/src/templates/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - {{ title }} - - - {{ content }} - - \ No newline at end of file diff --git a/src/templates/kymp/confirmation.html b/src/templates/kymp/confirmation.html new file mode 100644 index 0000000..adf9876 --- /dev/null +++ b/src/templates/kymp/confirmation.html @@ -0,0 +1,9 @@ +

{{ email_confirmation_title }}

+

{{ email_confirmation_intro }}

+

{{ email_confirmation_search_description }}: {{ search_description }}

+ + {{ email_confirmation_button }} + +

{{ email_confirmation_ignore }}

+

{{ email_generic_automatically_sent }}

diff --git a/src/templates/kymp/expiry_notification.html b/src/templates/kymp/expiry_notification.html new file mode 100644 index 0000000..3e2c8ec --- /dev/null +++ b/src/templates/kymp/expiry_notification.html @@ -0,0 +1,15 @@ +

{{ email_expiry_title_header }}

+

{{ email_expiry_prefix }} {{ removal_date }}{{ email_expiry_suffix }}

+

{{ email_generic_your_search_terms }}: {{ search_description }}

+ + {{ email_expiry_renewal_button }} + +

+ {{ email_expiry_new_link }} +

+

{{ email_newhits_expiry_instructions }}

+

+ {{ email_generic_remove_link }} +

+

{{ email_generic_automatically_sent }}

diff --git a/src/templates/kymp/index.html b/src/templates/kymp/index.html new file mode 100644 index 0000000..15fbbd3 --- /dev/null +++ b/src/templates/kymp/index.html @@ -0,0 +1,69 @@ + + + + + + + {{ title }} + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ {{ content }} +
+ Helsinki logo +

© {{ copyright_holder }} {{ year }}

+

+ {{ instructions_text }} +

+
+
+ + diff --git a/src/templates/kymp/newhits.html b/src/templates/kymp/newhits.html new file mode 100644 index 0000000..ca5129b --- /dev/null +++ b/src/templates/kymp/newhits.html @@ -0,0 +1,13 @@ +

{{ email_newhits_header }}

+{{ hits }} +

{{ email_generic_your_search_terms }}: {{ search_description }}

+

{{ email_newhits_intro_prefix }}

+

+ {{ email_newhits_link_text }} +

+

{{ email_newhits_expiry_prefix }} {{ expiry_date }}{{ email_newhits_expiry_suffix }}

+

{{ email_newhits_expiry_instructions }}

+

+ {{ email_generic_remove_link }} +

+

{{ email_generic_automatically_sent }}

diff --git a/src/templates/kymp/sms/confirmation.txt b/src/templates/kymp/sms/confirmation.txt new file mode 100644 index 0000000..70a4ba9 --- /dev/null +++ b/src/templates/kymp/sms/confirmation.txt @@ -0,0 +1 @@ +{{ sms_confirmation_intro }} {{ sms_confirmation_code_label }} {{ sms_code }} {{ sms_confirmation_code_validity }} {{ sms_confirmation_link }} diff --git a/src/templates/kymp/sms/newhits.txt b/src/templates/kymp/sms/newhits.txt new file mode 100644 index 0000000..88ba9cc --- /dev/null +++ b/src/templates/kymp/sms/newhits.txt @@ -0,0 +1 @@ +{{ sms_newhits_intro }} {{ email_generic_your_search_terms }}: {{ search_description }} {{ sms_newhits_remove_text }} {{ sms_newhits_remove_link }} {{ sms_newhits_code_label }} {{ sms_code }} {{ sms_newhits_code_validity }} diff --git a/src/templates/kymp/sms/renew.txt b/src/templates/kymp/sms/renew.txt new file mode 100644 index 0000000..f33f43c --- /dev/null +++ b/src/templates/kymp/sms/renew.txt @@ -0,0 +1 @@ +{{ sms_renewal_intro }} {{ expiry_date }}. {{ sms_renewal_search_label }} {{ search_description }} {{ sms_renewal_text }} {{ sms_renewal_link }} {{ sms_renewal_code_label }} {{ sms_code }} {{ sms_renewal_code_validity }} diff --git a/src/templates/link_text.html b/src/templates/link_text.html index 3194427..75944de 100644 --- a/src/templates/link_text.html +++ b/src/templates/link_text.html @@ -1 +1 @@ -

{{ content }}

+

{{ content }}

diff --git a/src/templates/rekry/confirmation.html b/src/templates/rekry/confirmation.html new file mode 100644 index 0000000..adf9876 --- /dev/null +++ b/src/templates/rekry/confirmation.html @@ -0,0 +1,9 @@ +

{{ email_confirmation_title }}

+

{{ email_confirmation_intro }}

+

{{ email_confirmation_search_description }}: {{ search_description }}

+ + {{ email_confirmation_button }} + +

{{ email_confirmation_ignore }}

+

{{ email_generic_automatically_sent }}

diff --git a/src/templates/rekry/confirmation_en.html b/src/templates/rekry/confirmation_en.html deleted file mode 100644 index a7501b4..0000000 --- a/src/templates/rekry/confirmation_en.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - Confirm a saved search on hel.fi - - - -

Hi!

-

This email address was used to save a search on the City of Helsinki website. Please confirm the saved search to - receive notifications. Click on the link below:

- Confirm saved search -

If you did not save a search, you can ignore this message.

-

-

Kind regards,

-

City of Helsinki

- - - \ No newline at end of file diff --git a/src/templates/rekry/confirmation_fi.html b/src/templates/rekry/confirmation_fi.html deleted file mode 100644 index 06a1c82..0000000 --- a/src/templates/rekry/confirmation_fi.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - Vahvista hakuvahdin tilaus sivustolta hel.fi - - - -

Hei!

-

Tälle sähköpostille luotiin hakuvahti Helsingin kaupungin verkkosivustolla. Vahvista hakuvahdin tilaus - klikkaamalla alla olevaa linkkiä:

- Vahvista hakuvahti -

Jos et tilannut hakuvahtia, voit jättää tämän sähköpostin huomioimatta.

-

-

Ystävällisin terveisin,

-

Helsingin kaupunki

- - - \ No newline at end of file diff --git a/src/templates/rekry/confirmation_sv.html b/src/templates/rekry/confirmation_sv.html deleted file mode 100644 index 982c9e1..0000000 --- a/src/templates/rekry/confirmation_sv.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - Bekräfta beställningen av en sökvakt på webbplatsen till hel.fi - - - -

Hej!

-

En sökvakt på Helsingfors stads webbplats har skapats för den här epostadressen. Bekräfta beställningen av - sökvakten genom att klicka på länken nedan:

- Bekräfta sökvakten -

Om du inte har beställt sökvakten, kan strunta i det här epostmeddelandet.

-

-

Med vänlig hälsning,

-

Helsingfors stad

- - - \ No newline at end of file diff --git a/src/templates/rekry/expiry_notification.html b/src/templates/rekry/expiry_notification.html new file mode 100644 index 0000000..3e2c8ec --- /dev/null +++ b/src/templates/rekry/expiry_notification.html @@ -0,0 +1,15 @@ +

{{ email_expiry_title_header }}

+

{{ email_expiry_prefix }} {{ removal_date }}{{ email_expiry_suffix }}

+

{{ email_generic_your_search_terms }}: {{ search_description }}

+ + {{ email_expiry_renewal_button }} + +

+ {{ email_expiry_new_link }} +

+

{{ email_newhits_expiry_instructions }}

+

+ {{ email_generic_remove_link }} +

+

{{ email_generic_automatically_sent }}

diff --git a/src/templates/rekry/expiry_notification_en.html b/src/templates/rekry/expiry_notification_en.html deleted file mode 100644 index d982599..0000000 --- a/src/templates/rekry/expiry_notification_en.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - Your saved search is about to expire - - - -

Your saved search on the City of Helsinki website is about to expire on {{ removal_date }}, but - you can renew it, if you wish. Search criteria: {{ search_description }}.

-

- Save a new search -

-

Kind regards,

-

City of Helsinki

-

Delete saved search

- - - \ No newline at end of file diff --git a/src/templates/rekry/expiry_notification_fi.html b/src/templates/rekry/expiry_notification_fi.html deleted file mode 100644 index 45f94c7..0000000 --- a/src/templates/rekry/expiry_notification_fi.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - Hakuvahtisi on vanhentumassa - - - -

Hakuvahtisi Helsingin kaupungin sivustolla on päättymässä {{ removal_date }}, - mutta voit tehdä sen uudelleen. Hakuehdot: {{ search_description }}.

-

- Tee uusi hakuvahti -

-

Ystävällisin terveisin,

-

Helsingin kaupunki

-

Poista hakuvahti

- - - \ No newline at end of file diff --git a/src/templates/rekry/expiry_notification_sv.html b/src/templates/rekry/expiry_notification_sv.html deleted file mode 100644 index ce2e1a6..0000000 --- a/src/templates/rekry/expiry_notification_sv.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - Tiden för din sökvakt håller på att gå ut - - - -

Din sökvakt på Helsingfors stads webbplats kommer att gå ut den {{ removal_date }}, men du kan - skapa den på nytt. Sökvillkor: {{ search_description }}.

-

- Skapa en ny sökvakt -

-

Med vänlig hälsning,

-

Helsingfors stad

-

Radera sökvakten

- - - \ No newline at end of file diff --git a/src/templates/rekry/index.html b/src/templates/rekry/index.html new file mode 100644 index 0000000..15fbbd3 --- /dev/null +++ b/src/templates/rekry/index.html @@ -0,0 +1,69 @@ + + + + + + + {{ title }} + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ {{ content }} +
+ Helsinki logo +

© {{ copyright_holder }} {{ year }}

+

+ {{ instructions_text }} +

+
+
+ + diff --git a/src/templates/rekry/newhits.html b/src/templates/rekry/newhits.html new file mode 100644 index 0000000..ca5129b --- /dev/null +++ b/src/templates/rekry/newhits.html @@ -0,0 +1,13 @@ +

{{ email_newhits_header }}

+{{ hits }} +

{{ email_generic_your_search_terms }}: {{ search_description }}

+

{{ email_newhits_intro_prefix }}

+

+ {{ email_newhits_link_text }} +

+

{{ email_newhits_expiry_prefix }} {{ expiry_date }}{{ email_newhits_expiry_suffix }}

+

{{ email_newhits_expiry_instructions }}

+

+ {{ email_generic_remove_link }} +

+

{{ email_generic_automatically_sent }}

diff --git a/src/templates/rekry/newhits_en.html b/src/templates/rekry/newhits_en.html deleted file mode 100644 index d1efeaa..0000000 --- a/src/templates/rekry/newhits_en.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - New search matches - - - -

We found new matches for your saved search. Search criteria: {{ search_description }}, {{ created_date }}

-

- {{ hits }} -

-

See all results

-

-

Kind regard,

-

City of Helsinki

-

Please do not reply to this message.

-

---

-

Delete saved search

- - - \ No newline at end of file diff --git a/src/templates/rekry/newhits_fi.html b/src/templates/rekry/newhits_fi.html deleted file mode 100644 index 7d8ef6b..0000000 --- a/src/templates/rekry/newhits_fi.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - Uusia hakuvahtiosumia - - - -

Löysimme uusia osumia hakuvahdillasi. Hakuehdot: {{ search_description }}, {{ created_date }}

-

- {{ hits }} -

-

Katso kaikki hakuvahtitulokset

-

-

Ystävällisin terveisin,

-

Helsingin kaupunki

-

Tähän viestiin ei voi vastata.

-

---

-

Poista hakuvahti

- - - \ No newline at end of file diff --git a/src/templates/rekry/newhits_sv.html b/src/templates/rekry/newhits_sv.html deleted file mode 100644 index 2dc94de..0000000 --- a/src/templates/rekry/newhits_sv.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - Nya träffar med sökvakten - - - -

Vi hittade nya träffar med din sökvakt. Sökvillkor: {{ search_description }}, {{ created_date }}

-

- {{ hits }} -

-

Se alla sökvaktens resultat

-

-

Med vänlig hälsning,

-

Helsingfors stad

-

Detta meddelande kan inte besvaras.

-

---

-

Radera sökvakten

- - - \ No newline at end of file diff --git a/src/templates/rekry/sms/confirmation.txt b/src/templates/rekry/sms/confirmation.txt new file mode 100644 index 0000000..89b9508 --- /dev/null +++ b/src/templates/rekry/sms/confirmation.txt @@ -0,0 +1 @@ +{{ sms_newhits_intro }} "{{ search_description }}". {{ sms_newhits_cta }}: {{ search_link }} diff --git a/src/templates/rekry/sms/sms.html b/src/templates/rekry/sms/sms.html new file mode 100644 index 0000000..89b9508 --- /dev/null +++ b/src/templates/rekry/sms/sms.html @@ -0,0 +1 @@ +{{ sms_newhits_intro }} "{{ search_description }}". {{ sms_newhits_cta }}: {{ search_link }} diff --git a/src/templates/text.html b/src/templates/text.html deleted file mode 100644 index 52ce0a2..0000000 --- a/src/templates/text.html +++ /dev/null @@ -1 +0,0 @@ -

{{ content }}

\ No newline at end of file diff --git a/src/types/atv.ts b/src/types/atv.ts index 73ac05e..9051e53 100644 --- a/src/types/atv.ts +++ b/src/types/atv.ts @@ -1,10 +1,11 @@ -import { Static, Type } from '@sinclair/typebox' +import { type Static, Type } from '@sinclair/typebox'; export const AtvResponse = Type.Object({ - atvDocumentId: Type.String() -}) + atvDocumentId: Type.String(), + hasSms: Type.Optional(Type.Boolean()), +}); -export type AtvResponseType = Static +export type AtvResponseType = Static; export const AtvDocument = Type.Object({ id: Type.Optional(Type.String()), @@ -63,13 +64,13 @@ export const AtvDocument = Type.Object({ content_schema_url: Type.Optional(Type.String()), // Attachments - attachments: Type.Optional(Type.Array(Type.Any())) -}) + attachments: Type.Optional(Type.Array(Type.Any())), +}); -export type AtvDocumentType = Static +export type AtvDocumentType = Static; export const AtvDocumentBatch = Type.Object({ - document_ids: Type.Array(Type.String()) -}) + document_ids: Type.Array(Type.String()), +}); -export type AtvDocumentBatchType = Static +export type AtvDocumentBatchType = Static; diff --git a/src/types/dialogi.ts b/src/types/dialogi.ts new file mode 100644 index 0000000..3f7f061 --- /dev/null +++ b/src/types/dialogi.ts @@ -0,0 +1,31 @@ +import { type Static, Type } from '@sinclair/typebox'; + +// Request types for Elisa Dialogi SMS API +export const DialogiSmsRequest = Type.Object({ + sender: Type.String(), // Message sender (phone number, shortcode, or alphanumeric max 11 chars) + destination: Type.String(), // Phone number in international format (E.164) + text: Type.String(), // SMS message content +}); + +export type DialogiSmsRequestType = Static; + +// Response types for Elisa Dialogi SMS API +export const DialogiSmsResponse = Type.Object({ + messages: Type.Optional( + Type.Array( + Type.Record( + Type.String(), + Type.Object({ + converted: Type.Optional(Type.String()), + status: Type.Optional(Type.String()), + reason: Type.Optional(Type.Union([Type.String(), Type.Null()])), + messageid: Type.Optional(Type.String()), + }), + ), + ), + ), + warnings: Type.Optional(Type.Array(Type.Object({ message: Type.String() }))), + errors: Type.Optional(Type.Array(Type.Object({ message: Type.String() }))), +}); + +export type DialogiSmsResponseType = Static; diff --git a/src/types/elasticproxy.ts b/src/types/elasticproxy.ts index c6d67cd..8d8654d 100644 --- a/src/types/elasticproxy.ts +++ b/src/types/elasticproxy.ts @@ -1,35 +1,35 @@ -import { Static, Type } from '@sinclair/typebox' +import { type Static, Type } from '@sinclair/typebox'; export const ElasticProxyResponseItem = Type.Object({ - took: Type.Number(), - timed_out: Type.Boolean(), - _shards: Type.Object(Type.Unknown()), - hits: Type.Object(Type.Unknown()), - aggregations: Type.Object(Type.Unknown()), - status: Type.Number() -}) -export type ElasticProxyResponseItemType = Static + took: Type.Number(), + timed_out: Type.Boolean(), + _shards: Type.Object(Type.Unknown()), + hits: Type.Object(Type.Unknown()), + aggregations: Type.Object(Type.Unknown()), + status: Type.Number(), +}); +export type ElasticProxyResponseItemType = Static; export const ElasticProxyResponseHits = Type.Object({ - total: Type.Unknown(), - max_score: Type.Unknown(), - hits: Type.Array(ElasticProxyResponseItem), -}) -export type ElasticProxyResponseHitsType = Static + total: Type.Unknown(), + max_score: Type.Unknown(), + hits: Type.Array(ElasticProxyResponseItem), +}); +export type ElasticProxyResponseHitsType = Static; export const ElasticProxyJsonResponse = Type.Object({ - took: Type.Number(), - hits: Type.Object(Type.Unknown()), - responses: Type.Array(ElasticProxyResponseItem), -}) -export type ElasticProxyJsonResponseType = Static + took: Type.Number(), + hits: Type.Object(Type.Unknown()), + responses: Type.Array(ElasticProxyResponseItem), +}); +export type ElasticProxyJsonResponseType = Static; export const PartialDrupalNode = Type.Object({ - _language: Type.String(), - entity_type: Type.Array(Type.String()), - url: Type.Array(Type.String()), - langcode: Type.Array(Type.String()), - title: Type.String(), - field_publication_starts: Type.Array(Type.Number()) -}) -export type PartialDrupalNodeType = Static + _language: Type.String(), + entity_type: Type.Array(Type.String()), + url: Type.Array(Type.String()), + langcode: Type.Array(Type.String()), + title: Type.String(), + field_publication_starts: Type.Array(Type.Number()), +}); +export type PartialDrupalNodeType = Static; diff --git a/src/types/environment.ts b/src/types/environment.ts index 1c676ff..ffb15d4 100644 --- a/src/types/environment.ts +++ b/src/types/environment.ts @@ -1,9 +1,10 @@ -import { Type } from '@sinclair/typebox' +import { Type } from '@sinclair/typebox'; export enum Environment { PRODUCTION = 'production', STAGING = 'staging', - DEV = 'dev' + DEV = 'dev', + LOCAL = 'local', } -export const EnvironmentType = Type.Enum(Environment) +export const EnvironmentType = Type.Enum(Environment); diff --git a/src/types/error.ts b/src/types/error.ts index 94dc51f..1737c10 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -1,8 +1,21 @@ -import { Static, Type } from '@sinclair/typebox' +import { type Static, Type } from '@sinclair/typebox'; + +export const GenericResponse = Type.Object({ + statusMessage: Type.String(), +}); + +export type GenericResponseType = Static; + +export const Generic400Error = Type.Object({ + error: Type.String(), + field: Type.Optional(Type.String()), +}); + +export type Generic400ErrorType = Static; export const Generic500Error = Type.Object({ - email: Type.Optional(Type.String()), - error: Type.Optional(Type.String()), -}) + email: Type.Optional(Type.String()), + error: Type.Optional(Type.String()), +}); -export type Generic500ErrorType = Static +export type Generic500ErrorType = Static; diff --git a/src/types/mailer.ts b/src/types/mailer.ts index 5889a8b..bf1e255 100644 --- a/src/types/mailer.ts +++ b/src/types/mailer.ts @@ -1,23 +1,7 @@ -import { Transporter } from 'nodemailer' -import { Static, Type } from '@sinclair/typebox' +import type { Transporter } from 'nodemailer'; export interface FastifyMailerNamedInstance { [namespace: string]: Transporter; } export type FastifyMailer = FastifyMailerNamedInstance & Transporter; - -export const QueueDocument = Type.Object({ - _id: Type.Optional(Type.String()), - email: Type.String(), - content: Type.String(), -}) - -export type QueueDocumentType = Static - -export const QueueInsertDocument = Type.Object({ - email: Type.String(), - content: Type.String(), -}) - -export type QueueInsertDocumentType = Static diff --git a/src/types/queue.ts b/src/types/queue.ts new file mode 100644 index 0000000..d6ab7e1 --- /dev/null +++ b/src/types/queue.ts @@ -0,0 +1,13 @@ +import type { ObjectId } from '@fastify/mongodb'; + +export type QueueItemType = 'email' | 'sms'; + +export interface QueueInsertDocument { + type: QueueItemType; + atv_id: string; + content: string; +} + +export interface QueueItem extends QueueInsertDocument { + _id: ObjectId; +} diff --git a/src/types/siteConfig.ts b/src/types/siteConfig.ts new file mode 100644 index 0000000..6a85a77 --- /dev/null +++ b/src/types/siteConfig.ts @@ -0,0 +1,65 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const SiteLanguageUrls = Type.Object({ + base: Type.String(), + en: Type.String(), + fi: Type.String(), + sv: Type.String(), +}); +export type SiteLanguageUrlsType = Static; + +const TranslationValue = Type.Object({ + fi: Type.String(), + en: Type.String(), + sv: Type.String(), +}); +export type TranslationValueType = Static; + +export const TranslationMap = Type.Record(Type.String(), TranslationValue); +export type TranslationMapType = Static; + +export const SiteSubscriptionSettings = Type.Object({ + maxAge: Type.Number(), + unconfirmedMaxAge: Type.Number(), + expiryNotificationDays: Type.Number(), + enableSms: Type.Optional(Type.Boolean()), + smsCodeExpireConfirmMinutes: Type.Optional(Type.Number()), + smsCodeExpireActionMinutes: Type.Optional(Type.Number()), +}); +export type SiteSubscriptionSettingsType = Static; + +export const SiteMailSettings = Type.Object({ + templatePath: Type.String(), + maxHitsInEmail: Type.Optional(Type.Number()), +}); +export type SiteMailSettingsType = Static; + +export const SiteEnvironmentConfig = Type.Object({ + urls: SiteLanguageUrls, + subscription: SiteSubscriptionSettings, + mail: SiteMailSettings, + elasticProxyUrl: Type.String(), +}); +export type SiteEnvironmentConfigType = Static; + +export const SiteConfigurationFile = Type.Object( + { + name: Type.String(), + translations: Type.Optional(TranslationMap), + }, + { additionalProperties: SiteEnvironmentConfig }, +); +export type SiteConfigurationFileType = Static; + +export const SiteConfiguration = Type.Object({ + id: Type.String(), + name: Type.String(), + urls: SiteLanguageUrls, + subscription: SiteSubscriptionSettings, + mail: SiteMailSettings, + elasticProxyUrl: Type.String(), + translations: Type.Optional(TranslationMap), +}); +export type SiteConfigurationType = Static; +export const SiteConfigurationMap = Type.Record(Type.String(), SiteConfiguration); +export type SiteConfigurationMapType = Static; diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 4417d39..63ecc6a 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -1,33 +1,56 @@ -import { Static, Type } from '@sinclair/typebox' +import { type Static, Type } from '@sinclair/typebox'; export enum SubscriptionStatus { DISABLED = 2, ACTIVE = 1, - INACTIVE = 0 + INACTIVE = 0, } -export const SubscriptionStatusType = Type.Enum(SubscriptionStatus) +export const SubscriptionStatusType = Type.Enum(SubscriptionStatus); -export const SubscriptionCollectionLanguage = Type.Union([ - Type.Literal('en'), - Type.Literal('fi'), - Type.Literal('sv'), -]) -export type SubscriptionCollectionLanguageType = Static +export const SubscriptionStatusResponse = Type.Object({ + subscriptionStatus: Type.Union([Type.Literal('active'), Type.Literal('inactive'), Type.Literal('disabled')]), +}); +export type SubscriptionStatusResponseType = Static; + +export const SubscriptionRenewResponse = Type.Object({ + statusCode: Type.Number(), + statusMessage: Type.String(), + expiryDate: Type.String(), // ISO date string +}); +export type SubscriptionRenewResponseType = Static; + +export const SubscriptionCollectionLanguage = Type.Union([Type.Literal('en'), Type.Literal('fi'), Type.Literal('sv')]); +export type SubscriptionCollectionLanguageType = Static; export const SubscriptionCollection = Type.Object({ email: Type.String(), elastic_query: Type.String(), + elastic_query_atv: Type.Optional(Type.Number()), search_description: Type.Optional(Type.String()), hash: Type.Optional(Type.String()), query: Type.String(), + site_id: Type.String(), created: Type.Date(), modified: Type.Date(), lang: SubscriptionCollectionLanguage, last_checked: Type.Optional(Type.Number()), expiry_notification_sent: Type.Enum(SubscriptionStatus), - status: Type.Enum(SubscriptionStatus) -}) -export type SubscriptionCollectionType = Static + status: Type.Enum(SubscriptionStatus), + has_sms: Type.Optional(Type.Boolean()), + has_email: Type.Optional(Type.Boolean()), + delete_after: Type.Optional(Type.Date()), + first_created: Type.Optional(Type.Date()), + sms_code: Type.Optional(Type.String()), + sms_code_created: Type.Optional(Type.Date()), +}); +export type SubscriptionCollectionType = Static; + +// Subscription renewal +export const RenewalSubscription = Type.Intersect([ + Type.Pick(SubscriptionCollection, ['email', 'site_id', 'status', 'created', 'first_created']), + Type.Object({ _id: Type.Unknown() }), +]); +export type RenewalSubscriptionType = Static; // MongoDB response when inserting: export const SubscriptionResponse = Type.Object({ @@ -35,29 +58,90 @@ export const SubscriptionResponse = Type.Object({ // This is actually MongoDB's ObjectId object: insertedId: Type.Optional(Type.Unknown()), -}) -export type SubscriptionResponseType = Static +}); +export type SubscriptionResponseType = Static; -// Request to add new subscription: -export const SubscriptionRequest = Type.Object({ - email: Type.String(), +// Request to add new subscription (either email or sms is required, both allowed): +const SubscriptionRequestBase = Type.Object({ elastic_query: Type.String(), + elastic_query_atv: Type.Optional(Type.Number()), query: Type.String(), search_description: Type.Optional(Type.String()), - lang: SubscriptionCollectionLanguage -}) -export type SubscriptionRequestType = Static + site_id: Type.String(), + lang: SubscriptionCollectionLanguage, +}); + +export const SubscriptionRequest = Type.Union([ + Type.Intersect([ + SubscriptionRequestBase, + Type.Object({ + email: Type.String(), + sms: Type.Optional(Type.String()), + }), + ]), + Type.Intersect([ + SubscriptionRequestBase, + Type.Object({ + email: Type.Optional(Type.String()), + sms: Type.String(), + }), + ]), +]); +export type SubscriptionRequestType = Static; // Generic request with SubscriptionId export const SubscriptionGenericPostRequest = Type.Object({ - id: Type.String() -}) -export type SubscriptionGenericPostRequestType = Static + id: Type.String(), +}); +export type SubscriptionGenericPostRequestType = Static; // Generic response with id and status code export const SubscriptionGenericPostResponse = Type.Object({ id: Type.Optional(Type.String()), statusCode: Type.Number(), - statusMessage: Type.Optional(Type.String()) -}) -export type SubscriptionGenericPostResponseType = Static + statusMessage: Type.Optional(Type.String()), +}); +export type SubscriptionGenericPostResponseType = Static; + +// SMS verification request +export const SmsVerificationRequest = Type.Object({ + sms_code: Type.String(), + number: Type.String(), +}); +export type SmsVerificationRequestType = Static; + +// SMS verification response +export const SmsVerificationResponse = Type.Object({ + statusCode: Type.Number(), + statusMessage: Type.String(), + expiryDate: Type.Optional(Type.String()), +}); +export type SmsVerificationResponseType = Static; + +// Subscription document for SMS verification +export const VerificationSubscription = Type.Intersect([ + Type.Pick(SubscriptionCollection, [ + 'email', + 'site_id', + 'status', + 'created', + 'first_created', + 'sms_code', + 'sms_code_created', + ]), + Type.Object({ _id: Type.Unknown() }), +]); +export type VerificationSubscriptionType = Static; + +// SMS verification result +export const SmsVerificationResult = Type.Object({ + success: Type.Boolean(), + subscription: Type.Optional(VerificationSubscription), + error: Type.Optional( + Type.Object({ + statusCode: Type.Number(), + statusMessage: Type.String(), + }), + ), +}); +export type SmsVerificationResultType = Static; diff --git a/test/bin/hav-populate-queue-sync.test.ts b/test/bin/hav-populate-queue-sync.test.ts new file mode 100644 index 0000000..e541628 --- /dev/null +++ b/test/bin/hav-populate-queue-sync.test.ts @@ -0,0 +1,30 @@ +import * as assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { + calculateExpectedDeleteAfter, + needsDeleteAfterSync, +} from '../../src/bin/hav-populate-queue'; + +describe('ATV delete_after sync helpers', () => { + test('calculateExpectedDeleteAfter adds maxAge days to created date', () => { + const createdDate = new Date('2025-01-15'); + const result = calculateExpectedDeleteAfter(createdDate, 90); + assert.strictEqual(result.toISOString().substring(0, 10), '2025-04-15'); + }); + + test('needsDeleteAfterSync returns true when stored is undefined', () => { + assert.strictEqual(needsDeleteAfterSync(undefined, new Date('2025-04-15')), true); + }); + + test('needsDeleteAfterSync returns false when dates match', () => { + const date = new Date('2025-04-15'); + assert.strictEqual(needsDeleteAfterSync(date, date), false); + }); + + test('needsDeleteAfterSync returns true when dates differ', () => { + assert.strictEqual( + needsDeleteAfterSync(new Date('2025-04-15'), new Date('2025-04-16')), + true, + ); + }); +}); diff --git a/test/bin/hav-test-email-templates.test.ts b/test/bin/hav-test-email-templates.test.ts new file mode 100644 index 0000000..100bbab --- /dev/null +++ b/test/bin/hav-test-email-templates.test.ts @@ -0,0 +1,47 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import type { Collection } from 'mongodb'; +import { generateTestEmails } from '../../src/bin/hav-test-email-templates'; +import type { QueueInsertDocument } from '../../src/types/queue'; + +test('generateTestEmails queues all email types for all languages', async () => { + const queuedEmails: Array = []; + const mockQueueCollection = { + insertOne: async (doc: QueueInsertDocument) => { + queuedEmails.push(doc); + return { insertedId: 'mock-id' }; + }, + } as unknown as Collection; + + const mockSiteConfig = { + id: 'rekry', + name: 'Rekry', + urls: { + base: 'https://test.hel.fi', + fi: 'https://test.hel.fi/fi', + en: 'https://test.hel.fi/en', + sv: 'https://test.hel.fi/sv', + }, + subscription: { + maxAge: 90, + unconfirmedMaxAge: 5, + expiryNotificationDays: 5, + }, + mail: { + templatePath: 'rekry', + }, + elasticProxyUrl: 'https://elastic.test', + translations: { + site_name: { fi: 'Avoimet työpaikat', en: 'Open positions', sv: 'Lediga jobb' }, + }, + } as const; + + const testEmail = 'test@mailpit'; + + await generateTestEmails(mockQueueCollection, testEmail, mockSiteConfig); + + assert.equal(queuedEmails.length, 9, 'Should queue 9 emails'); + assert.ok(queuedEmails.every((email) => email.atv_id === testEmail), 'All emails should use test email address'); + assert.ok(queuedEmails.every((email) => email.content.length > 0), 'All emails should have content'); + assert.ok(queuedEmails.every((email) => email.type === 'email'), 'All items should have type email'); +}); diff --git a/test/bin/hav-update-subscription-length.test.ts b/test/bin/hav-update-subscription-length.test.ts new file mode 100644 index 0000000..26047e8 --- /dev/null +++ b/test/bin/hav-update-subscription-length.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { + calculateDeleteAfterDate, + formatDateISO, + formatErrorMessage, + formatSubscriptionUpdateMessage, +} from '../../src/bin/hav-update-subscription-length'; + +describe('hav-update-subscription-length', () => { + describe('calculateDeleteAfterDate', () => { + it('should calculate delete_after date by adding maxAge days to created date', () => { + const createdDate = new Date('2025-11-01T12:00:00.000Z'); + const maxAge = 90; + const result = calculateDeleteAfterDate(createdDate, maxAge); + + // Expected: 2025-11-01 + 90 days = 2026-01-30 + assert.strictEqual(result.toISOString().substring(0, 10), '2026-01-30'); + }); + + it('should handle different maxAge values', () => { + const createdDate = new Date('2025-01-01T00:00:00.000Z'); + const maxAge = 180; + const result = calculateDeleteAfterDate(createdDate, maxAge); + + // Expected: 2025-01-01 + 180 days = 2025-06-30 + assert.strictEqual(result.toISOString().substring(0, 10), '2025-06-30'); + }); + + it('should not change the original date', () => { + const createdDate = new Date('2025-11-01T12:00:00.000Z'); + const originalTime = createdDate.getTime(); + + calculateDeleteAfterDate(createdDate, 90); + assert.strictEqual(createdDate.getTime(), originalTime); + }); + }); + + describe('formatDateISO', () => { + it('should format date to YYYY-MM-DD string', () => { + const date = new Date('2025-12-01T15:30:45.123Z'); + const result = formatDateISO(date); + + assert.strictEqual(result, '2025-12-01'); + }); + }); + + describe('formatSubscriptionUpdateMessage', () => { + it('should format dry-run message correctly', () => { + const createdDate = new Date('2025-12-01T12:00:00.000Z'); + const deleteAfter = new Date('2026-03-01T12:00:00.000Z'); + const result = formatSubscriptionUpdateMessage(1, 'abc123', createdDate, deleteAfter, true); + + assert.strictEqual( + result, + '1. [DRY RUN] Would update: abc123 | Created: 2025-12-01 | New delete_after: 2026-03-01', + ); + }); + + it('should format actual update message correctly', () => { + const createdDate = new Date('2025-12-01T12:00:00.000Z'); + const deleteAfter = new Date('2026-03-01T12:00:00.000Z'); + const result = formatSubscriptionUpdateMessage(5, 'xyz789', createdDate, deleteAfter, false); + + assert.strictEqual(result, '5. Updated: xyz789 | Created: 2025-12-01 | New delete_after: 2026-03-01'); + }); + }); + + describe('formatErrorMessage', () => { + it('should format error message with Error object', () => { + const error = new Error('Connection timeout'); + const result = formatErrorMessage(1, 'abc123', error); + + assert.strictEqual(result, '1. Failed: abc123 | Error: Connection timeout'); + }); + + it('should handle unknown error type', () => { + const error = 'String error'; + const result = formatErrorMessage(2, 'xyz789', error); + + assert.strictEqual(result, '2. Failed: xyz789 | Error: Unknown error'); + }); + }); + +}); diff --git a/test/helper.ts b/test/helper.ts index 7045177..5e976e4 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,37 +1,85 @@ // This file contains code that we reuse between our tests. -const helper = require('fastify-cli/helper.js') -import * as path from 'path' -import * as test from 'node:test' + +import assert from 'node:assert'; +import crypto from 'node:crypto'; +import * as path from 'node:path'; +import type * as test from 'node:test'; +import type { ObjectId } from '@fastify/mongodb'; +import type { FastifyInstance } from 'fastify'; +import helper from 'fastify-cli/helper.js'; +import type { Collection } from 'mongodb'; +import { SubscriptionStatus } from '../src/types/subscription'; export type TestContext = { - after: typeof test.after + after: typeof test.after; }; -const AppPath = path.join(__dirname, '..', 'src', 'app.ts') +process.env.HAKUVAHTI_API_KEY = 'test'; + +const AppPath = path.join(__dirname, '..', 'src', 'app.ts'); // Fill in this config with all the configurations // needed for testing the application -async function config () { - return {} +function config() { + return { + // Fastify only exposes plugins to child context. + // Fastify cli helper overrides this when skipOverride + // option is set. + // https://fastify.dev/docs/latest/Reference/Encapsulation/ + skipOverride: true, // Register our application with fastify-plugin + }; +} + +/** + * Helper for creating subscription in the database. + * + * @param collection - MongoDB collection to insert into + * @param subscriptionData - Optional partial subscription data to override defaults + * @returns The ObjectId of the created subscription + */ +export async function createSubscription( + collection: Collection | undefined, + subscriptionData: Partial<{ + hash: string; + status: SubscriptionStatus; + site_id: string; + email: string; + elastic_query: string; + query: string; + [key: string]: unknown; + }> = {}, +): Promise { + const insertResult = await collection?.insertOne({ + hash: crypto.randomUUID(), + status: SubscriptionStatus.INACTIVE, + site_id: 'test', + email: 'test-atv-doc-id', + elastic_query: 'test-query', + query: '/search?q=test', + ...subscriptionData, // Override defaults with provided data + }); + + assert.ok(insertResult); + + return insertResult.insertedId; } // Automatically build and tear down our instance -async function build (t: TestContext) { +async function build(t: TestContext): Promise { // you can set all the options supported by the fastify CLI command - const argv = [AppPath] + const argv = [AppPath]; // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await helper.build(argv, await config()) + const app = await helper.build(argv, config()); // Tear down our app after we are done - t.after(() => void app.close()) + t.after(() => void app.close()); - return app -} + await app.ready(); -export { - config, - build + return app; } + +export { config, build }; diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts new file mode 100644 index 0000000..5fbd238 --- /dev/null +++ b/test/lib/command.test.ts @@ -0,0 +1,71 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, type Mock, mock, test } from 'node:test'; +import command, { type Command } from '../../src/lib/command'; + +/** + * Helper for running command methods. + */ +async function runCommand(app: Command): Promise { + return new Promise((resolve) => { + command(app).addHook('onClose', async (_instance) => { + // Wait for process.exit to be called (happens after onClose hook) + setImmediate(resolve); + }); + }); +} + +describe('command helper', () => { + let processExitMock: Mock<(code: number) => void>; + + beforeEach(() => { + // Mock process.exit to prevent actual process termination + processExitMock = mock.method(process, 'exit', () => { + // Do nothing - prevent actual exit + }); + }); + + afterEach(() => { + processExitMock.mock.restore(); + }); + + test('executes command successfully and exits with 0', async () => { + // Set up process.argv + process.argv = ['node', 'script.js', '--test', 'value', '--dry-run']; + + // Create a mock command + const mockCommand = mock.fn(async (server, argv) => { + // Command executes successfully + assert.ok(server, 'Server should be provided'); + assert.ok(argv, 'Argv should be provided'); + + // Arguments are parsed correctly + assert.equal(argv.test, 'value'); + assert.ok(argv['dry-run']); + }); + + await runCommand(mockCommand); + + // Verify command was called + assert.strictEqual(mockCommand.mock.calls.length, 1); + + // Verify process.exit was called with 0 + assert.strictEqual(processExitMock.mock.calls.length, 1); + assert.strictEqual(processExitMock.mock.calls[0].arguments[0], 0); + }); + + test('when command fails exits with 1', async () => { + // Create a mock command that throws an error + const mockCommand = mock.fn(async (_server, _argv) => { + throw new Error('Test failure'); + }); + + await runCommand(mockCommand); + + // Verify command was called + assert.strictEqual(mockCommand.mock.calls.length, 1); + + // Verify process.exit was called with 1 + assert.strictEqual(processExitMock.mock.calls.length, 1); + assert.strictEqual(processExitMock.mock.calls[0].arguments[0], 1); + }); +}); diff --git a/test/lib/email.test.ts b/test/lib/email.test.ts new file mode 100644 index 0000000..333d7e8 --- /dev/null +++ b/test/lib/email.test.ts @@ -0,0 +1,123 @@ +import { strict as assert } from 'node:assert'; +import { after, before, test } from 'node:test'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { buildTranslationContext, translate, wrapWithLayout } from '../../src/lib/email'; +import type { SiteConfigurationType } from '../../src/types/siteConfig'; +import type { SubscriptionCollectionLanguageType } from '../../src/types/subscription'; + +const TEMPLATE_ROOT = path.join('dist', 'templates', 'test'); +const INNER_TEMPLATE = path.join(TEMPLATE_ROOT, 'inner_fi.html'); +const LAYOUT_TEMPLATE = path.join(TEMPLATE_ROOT, 'index.html'); + +const baseConfig: SiteConfigurationType = { + id: 'test', + name: 'test', + urls: { + base: 'https://test.test', + en: 'https://test.test/en', + fi: 'https://test.test/fi', + sv: 'https://test.test/sv', + }, + subscription: { + maxAge: 90, + unconfirmedMaxAge: 5, + expiryNotificationDays: 3, + }, + mail: { + templatePath: 'test', + maxHitsInEmail: 10, + }, + elasticProxyUrl: 'https://elastic.test', + translations: { + foo: { + fi: 'Hei', + en: 'Hello', + sv: 'Hej', + }, + empty_value: { + fi: '', + en: 'fallback', + sv: 'placeholder', + }, + }, +}; + +const createTestTemplates = async () => { + await fs.mkdir(TEMPLATE_ROOT, { recursive: true }); + await fs.writeFile( + INNER_TEMPLATE, + '
{{ foo }}{{ custom_value }}
', + 'utf-8', + ); + await fs.writeFile( + LAYOUT_TEMPLATE, + '
{{ content }}
{{ foo }} - {{ title }}
', + 'utf-8', + ); +}; + +before(async () => { + await createTestTemplates(); +}); + +after(async () => { + await fs.rm(TEMPLATE_ROOT, { recursive: true, force: true }); +}); + +test('buildTranslationContext returns language specific map', () => { + const ctx = buildTranslationContext('fi', baseConfig); + assert.equal(ctx.foo, 'Hei'); + assert.equal(ctx.empty_value, ''); + assert.equal(ctx.nonexistent as string | undefined, undefined); +}); + +test('translate falls back to empty string when key or language missing', () => { + const missingKey = translate('does_not_exist', 'fi', baseConfig); + assert.equal(missingKey, ''); + const missingLang = translate('foo', 'sv', { + ...baseConfig, + translations: { + foo: { fi: 'Hei', en: 'Hello', sv: '' }, + }, + }); + assert.equal(missingLang, ''); +}); + +const executeWrap = ( + lang: SubscriptionCollectionLanguageType, + customValue: string, +) => + wrapWithLayout( + path.join('dist', 'templates', baseConfig.mail.templatePath, 'inner_fi.html'), + { custom_value: customValue }, + lang, + `Subject for ${lang}`, + baseConfig, + ); + +test('wrapWithLayout injects translations into inner template and layout', () => { + const html = executeWrap('fi', 'custom'); + assert.match(html, /
Heicustom<\/span><\/div>/); + assert.match(html, /