From 98a062b66c0a61228025f0b1ae896647bfd124f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Risto=20Nyk=C3=A4nen?= Date: Fri, 9 May 2025 07:26:39 +0300 Subject: [PATCH 001/228] added in_progress trigger for better cron monitoring experience --- src/bin/hav-populate-email-queue.ts | 2 ++ src/bin/hav-send-emails-in-queue.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 596ce2a..7d5f0d5 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -112,6 +112,8 @@ const getNewHitsFromElasticsearch = async (subscription: any): Promise} A Promise that resolves to an empty object. */ const app = async (): Promise<{}> => { + server.Sentry.captureCheckIn({monitorSlug: 'hav-populate-email-queue', status: 'in_progress'}) + try { // Subscriptions const collection = server.mongo.db!.collection('subscription') diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index ff2d983..982da29 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -28,6 +28,8 @@ void server.register(atv) const BATCH_SIZE = 100 const app = async (): Promise<{}> => { + server.Sentry.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress'}) + if (typeof server.mongo?.db === 'undefined') { console.error('MongoDB connection not working') throw new Error('MongoDB connection not working') From cc73b7d359b67371c241bd7450e8343d4114ef28 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 6 Jun 2025 17:30:28 +0300 Subject: [PATCH 002/228] Add sonarcloud properties --- sonar-project.properties | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..ea0bc49 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,5 @@ +sonar.projectKey=City-of-Helsinki_helfi-hakuvahti +sonar.organization=city-of-helsinki +sonar.inclusions=**/*.ts,Dockerfile,openshift/Dockerfile +sonar.exclusions=test/**/* +sonar.test.inclusions=test/**/*.ts From 5b9c6b087505bab262a3012c8dc35afb32f188fa Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 6 Jun 2025 17:37:00 +0300 Subject: [PATCH 003/228] Run sonarcloud in github actions --- .github/workflows/test.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..25438c1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +on: + pull_request: + push: + branches: ['main', 'dev'] +name: CI +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v4 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From 8879696ba1f05fff0311ae42c7f9a27fd46a9ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Risto=20Nyk=C3=A4nen?= Date: Fri, 11 Jul 2025 09:08:14 +0300 Subject: [PATCH 004/228] upgraded the vulnerable packages --- package-lock.json | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index be595cb..e64a742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -540,9 +540,10 @@ } }, "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==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1091,9 +1092,9 @@ "integrity": "sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==" }, "node_modules/fastify": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.0.tgz", - "integrity": "sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==", + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", "funding": [ { "type": "github", @@ -2581,9 +2582,10 @@ } }, "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==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" From 81873b5d5779a34208dd61e129649f218df29eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Risto=20Nyk=C3=A4nen?= Date: Fri, 11 Jul 2025 10:06:41 +0300 Subject: [PATCH 005/228] added the checkinID to the checkin-requests. Added error-checkin as well --- src/bin/hav-populate-email-queue.ts | 11 +++++++++-- src/bin/hav-send-emails-in-queue.ts | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 7d5f0d5..e0a3a87 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -112,7 +112,12 @@ const getNewHitsFromElasticsearch = async (subscription: any): Promise} A Promise that resolves to an empty object. */ const app = async (): Promise<{}> => { - server.Sentry.captureCheckIn({monitorSlug: 'hav-populate-email-queue', status: 'in_progress'}) + const checkInId = server.Sentry?.captureCheckIn( + { + monitorSlug: 'hav-populate-email-queue', + status: 'in_progress' + } + ); try { // Subscriptions @@ -199,10 +204,12 @@ const app = async (): Promise<{}> => { } } catch (error) { console.error(error) + server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error'}) server.Sentry?.captureException(error) + return {}; } - server.Sentry.captureCheckIn({monitorSlug: 'hav-populate-email-queue', status: 'ok'}) + server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok'}) return {} }; diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index 982da29..b1a74db 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -28,7 +28,7 @@ void server.register(atv) const BATCH_SIZE = 100 const app = async (): Promise<{}> => { - server.Sentry.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress'}) + const checkInId = server.Sentry?.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress'}) if (typeof server.mongo?.db === 'undefined') { console.error('MongoDB connection not working') @@ -115,7 +115,7 @@ const app = async (): Promise<{}> => { } } - server.Sentry.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'ok'}) + server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok'}) return {} } From 9a8b89fdabfd7af850b092e73c35f83cb9a9fa14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Risto=20Nyk=C3=A4nen?= Date: Fri, 11 Jul 2025 13:21:59 +0300 Subject: [PATCH 006/228] test fetching image from redhat --- openshift/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 219b27e..89e36bf 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM registry.access.redhat.com/ubi8/nodejs-20 ENV npm_config_cache=/app/.npm ENV APP_NAME rekry-hakuvahti From f90b2956fc64b06e42a882c093ce034ca276ecbe Mon Sep 17 00:00:00 2001 From: Jere Ljungberg Date: Tue, 29 Jul 2025 14:09:48 +0300 Subject: [PATCH 007/228] Filter out email from Sentry errors --- src/app.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/app.ts b/src/app.ts index 1fcb083..4da4324 100644 --- a/src/app.ts +++ b/src/app.ts @@ -27,6 +27,22 @@ const app: FastifyPluginAsync = async ( const release = process.env.SENTRY_RELEASE ?? ''; fastify.register(require('@immobiliarelabs/fastify-sentry'), { dsn: process.env.SENTRY_DSN, + beforeSend: (event: any) => { + 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 From 13f7fc355286ed7bd0e4e4fe53a29f7b5f7d4141 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 1 Sep 2025 10:48:43 +0300 Subject: [PATCH 008/228] Update sonarcloud --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 25438c1..a976d8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,6 @@ jobs: fetch-depth: 0 - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v4 + uses: SonarSource/sonarqube-scan-action@v5 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From d02544bf8939a0ed01a5b87711ca38e98c721c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Mon, 8 Sep 2025 09:29:19 +0300 Subject: [PATCH 009/228] Improve mongo initialization. Sometimes creating collections would fail because server was not ready before connecting to mongodb. --- src/bin/hav-init-mongodb.ts | 175 +++++++++++++++++++----------------- 1 file changed, 94 insertions(+), 81 deletions(-) diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 91e8184..de5bb4b 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -1,3 +1,13 @@ +/** + * MongoDB Database Initialization Script + * + * Creates required collections with validation schemas for the Hakuvahti application: + * - queue: Email queue for outbound notifications + * - subscription: Search subscriptions with user preferences + * + * Must be run before starting the application to ensure proper database structure. + */ + import fastify from 'fastify' import mongodb from '../plugins/mongodb'; import dotenv from 'dotenv' @@ -6,98 +16,101 @@ dotenv.config() const server = fastify({}) -// Register only needed plugins void server.register(mongodb) -const app = async (): Promise<{}> => { - const createQueue = await server.mongo.db?.createCollection("queue", { - validator: { - $jsonSchema: { - bsonType: "object", - title: "Hakuvahti email queue", - required: ["email", "content"], - properties: { - _id: { - "bsonType": "objectId" - }, - email: { - bsonType: "string", - }, - content: { - bsonType: "string", +const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => { + try { + // Email queue collection: stores pending notification emails + const createQueue = await server.mongo.db?.createCollection("queue", { + validator: { + $jsonSchema: { + bsonType: "object", + title: "Hakuvahti email queue", + required: ["email", "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", - }, - elastic_query: { - bsonType: "string", - }, - query: { - 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" - }, - created: { - bsonType: "date" + // Subscription collection: stores user search criteria and metadata + 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", + }, + elastic_query: { + bsonType: "string", + }, + query: { + 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" + } } } } - } - }) + }) - server.log.debug(createQueue) - server.log.debug(createSubscription) + console.log('Queue collection created:', createQueue?.collectionName) + console.log('Subscription collection created:', createSubscription?.collectionName) - return {} + return { success: true } + } catch (error) { + console.error('Error initializing MongoDB:', error) + return { success: false, error } + } } -server.get('/', async function (request, reply) { - 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() - }) - +// Wait for Fastify and MongoDB plugin to be fully initialized before creating collections +server.ready(async (err) => { + if (err) { + console.error('Server failed to start:', err) + process.exit(1) + } + + console.log('Fastify server ready') + + const result = await initMongoDB() + console.log('MongoDB initialization result:', result) + + await server.close() + process.exit(result.success ? 0 : 1) // Exit with error code if initialization failed }) From e683c30e0aa8edd3977b2d521503deaeb0256b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:19:22 +0300 Subject: [PATCH 010/228] Add site configuration classes and types for multi-instance support --- src/lib/siteConfigurationLoader.ts | 135 +++++++++++++++++++++++++++++ src/types/siteConfig.ts | 44 ++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/lib/siteConfigurationLoader.ts create mode 100644 src/types/siteConfig.ts diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts new file mode 100644 index 0000000..58d7284 --- /dev/null +++ b/src/lib/siteConfigurationLoader.ts @@ -0,0 +1,135 @@ +import * as fs from 'fs' +import * as path from 'path' +import { SiteConfigurationType, SiteConfigurationMapType, SiteConfigurationFileType, SiteEnvironmentConfigType } from '../types/siteConfig' + +export class SiteConfigurationLoader { + + private static instance: SiteConfigurationLoader + private configurations: SiteConfigurationMapType = {} + private loaded = false + + private constructor() {} + + public static getInstance(): SiteConfigurationLoader { + if (!SiteConfigurationLoader.instance) { + SiteConfigurationLoader.instance = new SiteConfigurationLoader() + } + + return SiteConfigurationLoader.instance + } + + 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') + } + + 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 any)[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}`) + } + + // Flatten to runtime configuration + this.configurations[siteId] = { + id: siteId, + name: rawConfig.name, + urls: envConfig.urls, + subscription: envConfig.subscription, + mail: envConfig.mail + } + } catch (error) { + throw new Error(`Failed to load configuration from ${filePath}: ${error}`) + } + } + + this.loaded = true + } + + /** + * Gets all 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 + */ + 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 + */ + private 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 + */ + private validateEnvironmentConfiguration(config: unknown): config is SiteEnvironmentConfigType { + if (typeof config !== 'object' || config === null) { + return false + } + const required = ['urls', 'subscription', 'mail'] + return required.every(prop => prop in config) + } +} diff --git a/src/types/siteConfig.ts b/src/types/siteConfig.ts new file mode 100644 index 0000000..6ca5f75 --- /dev/null +++ b/src/types/siteConfig.ts @@ -0,0 +1,44 @@ +import { 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 + +export const SiteSubscriptionSettings = Type.Object({ + maxAge: Type.Number(), + unconfirmedMaxAge: Type.Number(), + expiryNotificationDays: Type.Number(), +}) +export type SiteSubscriptionSettingsType = Static + +export const SiteMailSettings = Type.Object({ + templatePath: Type.String(), +}) +export type SiteMailSettingsType = Static + +export const SiteEnvironmentConfig = Type.Object({ + urls: SiteLanguageUrls, + subscription: SiteSubscriptionSettings, + mail: SiteMailSettings, +}) +export type SiteEnvironmentConfigType = Static + +export const SiteConfigurationFile = Type.Object({ + name: Type.String(), +}, { additionalProperties: SiteEnvironmentConfig }) +export type SiteConfigurationFileType = Static + +export const SiteConfiguration = Type.Object({ + id: Type.String(), + name: Type.String(), + urls: SiteLanguageUrls, + subscription: SiteSubscriptionSettings, + mail: SiteMailSettings, +}) +export type SiteConfigurationType = Static +export const SiteConfigurationMap = Type.Record(Type.String(), SiteConfiguration) +export type SiteConfigurationMapType = Static From 5ac1db90b50f888bfd8533bb783165eb3be83de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:20:22 +0300 Subject: [PATCH 011/228] Load template variables from site config instead of env --- src/lib/email.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/lib/email.ts b/src/lib/email.ts index 338744c..0bcc044 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,20 +1,12 @@ import { sprightly } from "sprightly"; import { SubscriptionCollectionLanguageType } from "../types/subscription" import { PartialDrupalNodeType } from "../types/elasticproxy" -import dotenv from 'dotenv' - -dotenv.config() - -// Base dir for email templates -const dir = process.env.MAIL_TEMPLATE_PATH || 'dist/templates' - -// Base url for the website (not HAV) -const baseUrl: string = process.env.BASE_URL || 'http://localhost:3000' +import { SiteConfigurationType } from "../types/siteConfig" // Subscription confirmation email -export const confirmationEmail = async (lang: SubscriptionCollectionLanguageType, data: { link: string; }) => { +export const confirmationEmail = async (lang: SubscriptionCollectionLanguageType, data: { link: string; }, siteConfig: SiteConfigurationType) => { try { - return sprightly('dist/templates/' + dir + '/confirmation_' + lang + '.html', { + return sprightly('dist/templates/' + siteConfig.mail.templatePath + '/confirmation_' + lang + '.html', { lang: lang, link: data.link, }); @@ -28,9 +20,9 @@ export const expiryEmail = async (lang: SubscriptionCollectionLanguageType, data link: string, search_description: string, removal_date: string, - remove_link: string }) => { + remove_link: string }, siteConfig: SiteConfigurationType) => { try { - return sprightly('dist/templates/' + dir + '/expiry_notification_' + lang + '.html', { + return sprightly('dist/templates/' + siteConfig.mail.templatePath + '/expiry_notification_' + lang + '.html', { lang: lang, link: data.link, search_description: data.search_description, @@ -48,17 +40,17 @@ export const newHitsEmail = async (lang: SubscriptionCollectionLanguageType, dat search_description: string, search_link: string, remove_link: string, - created_date: string }) => { + created_date: string }, siteConfig: SiteConfigurationType) => { try { const hitsContent = data.hits.map(item => sprightly('dist/templates/link_text.html', { - link: baseUrl + item.url, + link: siteConfig.urls.base + item.url, content: item.title, })).join('') - return sprightly(`dist/templates/${dir}/newhits_${lang}.html`, { + return sprightly(`dist/templates/${siteConfig.mail.templatePath}/newhits_${lang}.html`, { lang: lang, hits: hitsContent, - search_link: baseUrl + data.search_link, + search_link: siteConfig.urls.base + data.search_link, remove_link: data.remove_link, search_description: data.search_description, created_date: data.created_date From 02e46225bd42d4ecef3b58b22331f228489af91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:21:32 +0300 Subject: [PATCH 012/228] Add conf for rekry. needs to check prod and stage variables later --- conf/rekry.json | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 conf/rekry.json diff --git a/conf/rekry.json b/conf/rekry.json new file mode 100644 index 0000000..06ad3b5 --- /dev/null +++ b/conf/rekry.json @@ -0,0 +1,35 @@ +{ + "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" + } + } +} From f0482256dd793ae63e770bd54a449d4b131105bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:22:05 +0300 Subject: [PATCH 013/228] Remove variables that have been moved to conf --- .env.dist | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.env.dist b/.env.dist index 20ef4a1..6684d83 100644 --- a/.env.dist +++ b/.env.dist @@ -1,21 +1,22 @@ +# Environment configuration ENVIRONMENT=dev FASTIFY_PORT=3000 + +# Database configuration MONGODB=mongodb://mongodb:27017/hakuvahti + +# 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 + +# 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 From a4ebfecde578d2a76482af29f747225d073e56df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:22:23 +0300 Subject: [PATCH 014/228] Add siteid to type --- src/types/subscription.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 4417d39..03d1741 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -20,6 +20,7 @@ export const SubscriptionCollection = Type.Object({ 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, @@ -44,6 +45,7 @@ export const SubscriptionRequest = Type.Object({ elastic_query: Type.String(), query: Type.String(), search_description: Type.Optional(Type.String()), + site_id: Type.String(), lang: SubscriptionCollectionLanguage }) export type SubscriptionRequestType = Static From 5fd1ffca06bad0df3babbd922e0c3e8038d2c961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:23:26 +0300 Subject: [PATCH 015/228] Add site configuration --- src/routes/addSubscription.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index d872820..b56b292 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -21,6 +21,7 @@ import { import { confirmationEmail } from '../lib/email' import { QueueInsertDocumentType } from '../types/mailer' +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader' // Add subscription to given query parameters @@ -55,6 +56,18 @@ const subscription: FastifyPluginAsync = async ( .send({ error: 'Could not find hashed email. Subscription not added.' }) request.body.email = request.atvResponse.atvDocumentId; + // 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.' }) + } + // Subscription data that goes to collection const subscription: Partial = { ...request.body, @@ -74,11 +87,11 @@ const subscription: FastifyPluginAsync = async ( } // Insert email in queue - - const subscribeLinkBase = fastify.localizedEnvVar('BASE_URL', request.body.lang) + const langKey = request.body.lang.toLowerCase() as keyof typeof siteConfig.urls + const subscribeLinkBase = (langKey in siteConfig.urls) ? siteConfig.urls[langKey] : siteConfig.urls.base const emailContent = await confirmationEmail(request.body.lang, { link: subscribeLinkBase + `/hakuvahti/confirm?subscription=${response.insertedId}&hash=${hash}` - }) + }, siteConfig) // Email data to queue const email:QueueInsertDocumentType = { From 1e55fdbe693775ff315c224c69f32fccf5aa03e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:24:10 +0300 Subject: [PATCH 016/228] Add siteid to document index. This needs to be improved --- src/bin/hav-init-mongodb.ts | 145 +++++++++++++++++++++--------------- 1 file changed, 83 insertions(+), 62 deletions(-) diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index de5bb4b..48c67ca 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -20,77 +20,98 @@ void server.register(mongodb) const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => { try { + const db = server.mongo.db! + + // Check if collections exist + const collections = await db.listCollections().toArray() + const existingCollections = collections.map(c => c.name) + + let queueResult = null + let subscriptionResult = null + // Email queue collection: stores pending notification emails - const createQueue = await server.mongo.db?.createCollection("queue", { - validator: { - $jsonSchema: { - bsonType: "object", - title: "Hakuvahti email queue", - required: ["email", "content"], - properties: { - _id: { - "bsonType": "objectId" - }, - email: { - bsonType: "string", - }, - content: { - bsonType: "string", + if (!existingCollections.includes('queue')) { + queueResult = await db.createCollection("queue", { + validator: { + $jsonSchema: { + bsonType: "object", + title: "Hakuvahti email queue", + required: ["email", "content"], + properties: { + _id: { + "bsonType": "objectId" + }, + email: { + bsonType: "string", + }, + content: { + bsonType: "string", + } } } } - } - }) + }) + console.log('Queue collection created:', queueResult?.collectionName) + } else { + console.log('Queue collection already exists') + } // Subscription collection: stores user search criteria and metadata - 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", - }, - elastic_query: { - bsonType: "string", - }, - query: { - 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" + 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.log('Queue collection created:', createQueue?.collectionName) - console.log('Subscription collection created:', createSubscription?.collectionName) + }) + console.log('Subscription collection created:', subscriptionResult?.collectionName) + } else { + console.log('Subscription collection already exists') + console.log('NOTE: Existing subscription documents may lack site_id field') + console.log('Consider running a migration to add site_id to existing documents') + } return { success: true } } catch (error) { From 29509f142977ee2d998755ccfce0b96c97b4b808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:24:24 +0300 Subject: [PATCH 017/228] Add site_id to example rest call --- test/requests/addSubscription.rest | 1 + 1 file changed, 1 insertion(+) diff --git a/test/requests/addSubscription.rest b/test/requests/addSubscription.rest index d28cf3f..b5485b4 100644 --- a/test/requests/addSubscription.rest +++ b/test/requests/addSubscription.rest @@ -8,5 +8,6 @@ token: test "query": "/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja?area_filter=eastern&employment=90&employment=91&language=fi&task_areas=258&page=1", "email": "testi@mailhog.local", "search_description": "Esimerkkihaku", + "site_id": "rekry", "lang": "fi" } From 6f191c231a56ed730d24b60e28739b55aab9faed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:25:40 +0300 Subject: [PATCH 018/228] Refactor populate to support site configurations and some refactoring to functions --- src/bin/hav-populate-email-queue.ts | 229 ++++++++++++++++------------ 1 file changed, 133 insertions(+), 96 deletions(-) diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index e0a3a87..7309add 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -3,6 +3,7 @@ import mongodb from '../plugins/mongodb' import elasticproxy from '../plugins/elasticproxy' import dotenv from 'dotenv' import { SubscriptionCollectionLanguageType, SubscriptionCollectionType, SubscriptionStatus } from '../types/subscription' +import { SiteConfigurationType } from '../types/siteConfig' import decode from '../plugins/base64' import encode from '../plugins/base64' import '../plugins/sentry' @@ -12,6 +13,7 @@ import { } from '../types/elasticproxy' import { expiryEmail, newHitsEmail } from '../lib/email' import { QueueInsertDocumentType } from '../types/mailer' +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader' dotenv.config() @@ -31,8 +33,12 @@ 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()}`] +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 @@ -40,18 +46,23 @@ export const localizedEnvVar = (envVarBase: string, langCode: SubscriptionCollec // ElasticProxy and add them to email queue /** - * Deletes subscriptions older than a specified number of days with a certain status. + * Deletes subscriptions older than a specified number of days with a certain status for a specific site. * * @param {SubscriptionStatus} modifyStatus - the status to modify subscriptions * @param {number} olderThanDays - the number of days to consider for deletion + * @param {string} siteId - the site ID to filter subscriptions * @return {Promise} Promise that resolves when the subscriptions are deleted */ -const massDeleteSubscriptions = async (modifyStatus: SubscriptionStatus, olderThanDays: number): Promise => { +const massDeleteSubscriptions = async (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, created: { $lt: dateLimit } }) + await collection.deleteMany({ + status: modifyStatus, + site_id: siteId, + created: { $lt: dateLimit } + }) } catch (error) { console.error(error) @@ -64,9 +75,10 @@ const massDeleteSubscriptions = async (modifyStatus: SubscriptionStatus, olderTh * 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): boolean => { +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 @@ -77,8 +89,8 @@ const checkShouldSendExpiryNotification = (subscription: Partial} A Promise that resolves to an empty object. + * @param {SiteConfiguration} siteConfig - The site configuration to process + * @return {Promise} A Promise that resolves when processing is complete */ -const app = async (): Promise<{}> => { - const checkInId = server.Sentry?.captureCheckIn( - { - monitorSlug: 'hav-populate-email-queue', - status: 'in_progress' - } - ); +const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Promise => { + const collection = server.mongo.db!.collection('subscription') + const queueCollection = server.mongo.db!.collection('queue') - 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) - } + // List of all enabled subscriptions for this site + const result = await collection.find({ + status: SubscriptionStatus.ACTIVE, + site_id: siteConfig.id + }).toArray() - const newHits = await getNewHitsFromElasticsearch(subscription) + for (const subscription of result) { + const localizedBaseUrl = getLocalizedUrl(siteConfig, subscription.lang) - // No new hits - if (newHits.length === 0) { - continue - } - - // Email content object + // If subscription should expire soon, send an expiration email + if (checkShouldSendExpiryNotification(subscription as Partial, siteConfig)) { + await collection.updateOne( + { _id: subscription._id }, + { $set: { expiry_notification_sent: 1 } } + ) - // 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 subscriptionValidForDays = siteConfig.subscription.maxAge + 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 emailContent = await newHitsEmail(subscription.lang, { - created_date: formattedCreatedDate, + const expiryEmailContent = await expiryEmail(subscription.lang, { search_description: subscription.search_description, - search_link: subscription.query, + link: siteConfig.urls.base + subscription.query, + removal_date: formattedExpiryDate, remove_link: localizedBaseUrl + '/hakuvahti/unsubscribe?subscription=' + subscription._id + '&hash=' + subscription.hash, - hits: newHits - }) + }, siteConfig) - const email:QueueInsertDocumentType = { + const expiryEmailToQueue: QueueInsertDocumentType = { email: subscription.email, - content: emailContent + content: expiryEmailContent } // Add email to queue - await queueCollection.insertOne(email) + await queueCollection.insertOne(expiryEmailToQueue) + } - // Set last checked timestamp to this moment - const dateUnixtime: number = Math.floor(new Date().getTime() / 1000) + const newHits = await getNewHitsFromElasticsearch(subscription) - await collection.updateOne( - { _id: subscription._id }, - { $set: { last_checked: dateUnixtime } } - ) + // No new hits + if (newHits.length === 0) { + continue + } + + // 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 + }, siteConfig) + + 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 } } + ) + } +} + +/** + * Main application function that processes all site configurations. + * + * @return {Promise<{}>} A Promise that resolves to an empty object. + */ +const app = async (): Promise<{}> => { + const checkInId = server.Sentry?.captureCheckIn({ + monitorSlug: 'hav-populate-email-queue', + status: 'in_progress' + }); + + try { + console.log('Environment:', process.env.ENVIRONMENT || 'dev') + console.log('Loading site configurations...') + + // Load site configurations + const configLoader = SiteConfigurationLoader.getInstance() + await configLoader.loadConfigurations() + const siteConfigs = configLoader.getConfigurations() + + console.log('Loaded configurations for sites:', Object.keys(siteConfigs)) + + // Process each site configuration + for (const [siteId, siteConfig] of Object.entries(siteConfigs)) { + console.log(`Processing subscriptions for site: ${siteId}`) + await processSiteSubscriptions(siteConfig) + } + } catch (error) { - console.error(error) + console.error('Configuration loading error:', error) server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error'}) server.Sentry?.captureException(error) return {}; @@ -214,15 +247,19 @@ const app = async (): Promise<{}> => { }; 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) + // Load site configurations + const configLoader = SiteConfigurationLoader.getInstance() + await configLoader.loadConfigurations() + const siteConfigs = configLoader.getConfigurations() + + // Clean up expired subscriptions for each site + for (const [siteId, siteConfig] of Object.entries(siteConfigs)) { + // Remove expired subscriptions that haven't been confirmed + await massDeleteSubscriptions(SubscriptionStatus.INACTIVE, siteConfig.subscription.unconfirmedMaxAge, siteId) + + // Remove expired subscriptions + await massDeleteSubscriptions(SubscriptionStatus.ACTIVE, siteConfig.subscription.maxAge, siteId) + } // Loop through subscriptions and add new results to email queue return await app() From 8a3f6bd085681214b2ab504bfdeda4bf516dfad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 08:26:02 +0300 Subject: [PATCH 019/228] Update readme about site config --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 167d873..1a78100 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,12 @@ Pre-requisities to use Hakuvahti are: - 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) @@ -39,7 +39,7 @@ Pre-requisities to use Hakuvahti are: - 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 +- Configure site-specific settings in `conf/` directory (see Configuration section below) - `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 @@ -55,7 +55,7 @@ Pre-requisities to use Hakuvahti are: - 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 +- Configure site-specific settings in `conf/` directory (see Configuration section below) - 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) @@ -63,6 +63,72 @@ Pre-requisities to use Hakuvahti are: - `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) +## Configuration + +### 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 `dev` if `ENVIRONMENT` is not set +- Use `ENVIRONMENT=prod` for production deployment +- Any environment name can be used (e.g., `staging`, `test`) + +### 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 From b7ef5edba5455ef34ff146dd4461b7123bb9c4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 19 Sep 2025 09:53:58 +0300 Subject: [PATCH 020/228] Add tests for siteConfigurationLoader --- test/lib/siteConfigurationLoader.test.ts | 375 +++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 test/lib/siteConfigurationLoader.test.ts diff --git a/test/lib/siteConfigurationLoader.test.ts b/test/lib/siteConfigurationLoader.test.ts new file mode 100644 index 0000000..3f667b1 --- /dev/null +++ b/test/lib/siteConfigurationLoader.test.ts @@ -0,0 +1,375 @@ +import { test } from 'node:test' +import * as assert from 'node:assert' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import { SiteConfigurationLoader } from '../../src/lib/siteConfigurationLoader' + +const mockRekryConfig = { + 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' + } + } +} + +const mockAnotherConfig = { + name: 'another-site', + dev: { + urls: { + base: 'https://another.docker.so', + en: 'https://another.docker.so/en', + fi: 'https://another.docker.so/fi', + sv: 'https://another.docker.so/sv' + }, + subscription: { + maxAge: 60, + unconfirmedMaxAge: 3, + expiryNotificationDays: 2 + }, + mail: { + templatePath: 'another' + } + }, + prod: { + urls: { + base: 'https://another.hel.fi', + en: 'https://another.hel.fi/en', + fi: 'https://another.hel.fi/fi', + sv: 'https://another.hel.fi/sv' + }, + subscription: { + maxAge: 60, + unconfirmedMaxAge: 3, + expiryNotificationDays: 2 + }, + mail: { + templatePath: 'another' + } + } +} + +let tempDir: string +let originalCwd: string +let originalEnv: string | undefined + +test('SiteConfigurationLoader', async (t) => { + // Setup: Create temporary directory and mock files + await t.before(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'siteconfig-test-')) + originalCwd = process.cwd() + originalEnv = process.env.ENVIRONMENT + + // Change to temp directory + process.chdir(tempDir) + + // Create conf directory with test files + const confDir = path.join(tempDir, 'conf') + fs.mkdirSync(confDir) + + fs.writeFileSync( + path.join(confDir, 'rekry.json'), + JSON.stringify(mockRekryConfig, null, 2) + ) + + fs.writeFileSync( + path.join(confDir, 'another.json'), + JSON.stringify(mockAnotherConfig, null, 2) + ) + }) + + await t.after(async () => { + // Cleanup + process.chdir(originalCwd) + if (originalEnv !== undefined) { + process.env.ENVIRONMENT = originalEnv + } else { + delete process.env.ENVIRONMENT + } + fs.rmSync(tempDir, { recursive: true, force: true }) + + // Reset singleton instance for clean testing + ;(SiteConfigurationLoader as any).instance = undefined + }) + + await t.beforeEach(() => { + // Reset singleton instance before each test + ;(SiteConfigurationLoader as any).instance = undefined + + // Reset environment to default + process.env.ENVIRONMENT = 'dev' + + // Ensure clean test files exist + const confDir = path.join(tempDir, 'conf') + if (fs.existsSync(confDir)) { + // Remove all files + const files = fs.readdirSync(confDir) + for (const file of files) { + fs.unlinkSync(path.join(confDir, file)) + } + } else { + fs.mkdirSync(confDir) + } + + // Recreate original test files + fs.writeFileSync( + path.join(confDir, 'rekry.json'), + JSON.stringify(mockRekryConfig, null, 2) + ) + + fs.writeFileSync( + path.join(confDir, 'another.json'), + JSON.stringify(mockAnotherConfig, null, 2) + ) + }) + + await t.test('getInstance returns singleton instance', () => { + const instance1 = SiteConfigurationLoader.getInstance() + const instance2 = SiteConfigurationLoader.getInstance() + assert.strictEqual(instance1, instance2) + }) + + await t.test('loadConfigurations loads config files successfully', async () => { + process.env.ENVIRONMENT = 'dev' + + const loader = SiteConfigurationLoader.getInstance() + await loader.loadConfigurations() + + const configs = loader.getConfigurations() + assert.strictEqual(Object.keys(configs).length, 2) + assert.strictEqual(configs.rekry.name, 'rekry') + assert.strictEqual(configs.rekry.urls.base, 'https://helfi-rekry.docker.so') + assert.strictEqual(configs['another'].name, 'another-site') + }) + + await t.test('loadConfigurations uses prod environment when specified', async () => { + process.env.ENVIRONMENT = 'prod' + + const loader = SiteConfigurationLoader.getInstance() + await loader.loadConfigurations() + + const rekryConfig = loader.getConfiguration('rekry') + assert.strictEqual(rekryConfig?.urls.base, 'https://hel.fi') + }) + + await t.test('loadConfigurations defaults to dev environment', async () => { + delete process.env.ENVIRONMENT + + const loader = SiteConfigurationLoader.getInstance() + await loader.loadConfigurations() + + const rekryConfig = loader.getConfiguration('rekry') + assert.strictEqual(rekryConfig?.urls.base, 'https://helfi-rekry.docker.so') + }) + + await t.test('getConfiguration returns specific site config', async () => { + process.env.ENVIRONMENT = 'dev' + + const loader = SiteConfigurationLoader.getInstance() + await loader.loadConfigurations() + + const rekryConfig = loader.getConfiguration('rekry') + assert.ok(rekryConfig) + assert.strictEqual(rekryConfig.id, 'rekry') + assert.strictEqual(rekryConfig.name, 'rekry') + assert.strictEqual(rekryConfig.subscription.maxAge, 90) + assert.strictEqual(rekryConfig.mail.templatePath, 'rekry') + }) + + await t.test('getConfiguration returns undefined for non-existent site', async () => { + process.env.ENVIRONMENT = 'dev' + + const loader = SiteConfigurationLoader.getInstance() + await loader.loadConfigurations() + + const config = loader.getConfiguration('non-existent') + assert.strictEqual(config, undefined) + }) + + await t.test('getSiteIds returns array of site IDs', async () => { + process.env.ENVIRONMENT = 'dev' + + const loader = SiteConfigurationLoader.getInstance() + await loader.loadConfigurations() + + const siteIds = loader.getSiteIds() + assert.ok(Array.isArray(siteIds)) + assert.strictEqual(siteIds.length, 2) + assert.ok(siteIds.includes('rekry')) + assert.ok(siteIds.includes('another')) + }) + + await t.test('throws error when configuration directory does not exist', async () => { + // Remove conf directory + fs.rmSync(path.join(tempDir, 'conf'), { recursive: true, force: true }) + + const loader = SiteConfigurationLoader.getInstance() + + await assert.rejects( + () => loader.loadConfigurations(), + /Configuration directory not found/ + ) + }) + + await t.test('throws error when no JSON files found', async () => { + // Empty the conf directory + const confDir = path.join(tempDir, 'conf') + fs.rmSync(confDir, { recursive: true, force: true }) + fs.mkdirSync(confDir) + + const loader = SiteConfigurationLoader.getInstance() + + await assert.rejects( + () => loader.loadConfigurations(), + /No JSON configuration files found/ + ) + }) + + await t.test('throws error when environment not found in config', async () => { + process.env.ENVIRONMENT = 'staging' // Not present in mock config + + // Ensure we have config files for this test + const confDir = path.join(tempDir, 'conf') + if (!fs.existsSync(path.join(confDir, 'rekry.json'))) { + fs.writeFileSync( + path.join(confDir, 'rekry.json'), + JSON.stringify(mockRekryConfig, null, 2) + ) + } + + const loader = SiteConfigurationLoader.getInstance() + + await assert.rejects( + () => loader.loadConfigurations(), + /Environment 'staging' not found in configuration/ + ) + }) + + await t.test('throws error when accessing methods before loading', () => { + const loader = SiteConfigurationLoader.getInstance() + + assert.throws( + () => loader.getConfigurations(), + /Configurations not loaded/ + ) + + assert.throws( + () => loader.getConfiguration('rekry'), + /Configurations not loaded/ + ) + + assert.throws( + () => loader.getSiteIds(), + /Configurations not loaded/ + ) + }) + + await t.test('throws error for invalid JSON file', async () => { + // Clean up first to ensure only this test file exists + const confDir = path.join(tempDir, 'conf') + const files = fs.readdirSync(confDir) + for (const file of files) { + fs.unlinkSync(path.join(confDir, file)) + } + + // Write invalid JSON + fs.writeFileSync(path.join(confDir, 'invalid.json'), '{ invalid json') + + const loader = SiteConfigurationLoader.getInstance() + + await assert.rejects( + () => loader.loadConfigurations(), + /Failed to load configuration/ + ) + }) + + await t.test('throws error for missing required properties in config', async () => { + // Clean up first to ensure only this test file exists + const confDir = path.join(tempDir, 'conf') + const files = fs.readdirSync(confDir) + for (const file of files) { + fs.unlinkSync(path.join(confDir, file)) + } + + // Reset to dev environment for this test + process.env.ENVIRONMENT = 'dev' + + // Write config without required properties + fs.writeFileSync(path.join(confDir, 'missing-props.json'), JSON.stringify({ + name: 'test', + dev: { + urls: { base: 'test' } + // Missing subscription and mail properties + } + })) + + const loader = SiteConfigurationLoader.getInstance() + + await assert.rejects( + () => loader.loadConfigurations(), + /Invalid environment configuration/ + ) + }) + + await t.test('prevents multiple loadConfigurations calls', async () => { + process.env.ENVIRONMENT = 'dev' + + // Clean up first to ensure we have clean test files + const confDir = path.join(tempDir, 'conf') + const files = fs.readdirSync(confDir) + for (const file of files) { + fs.unlinkSync(path.join(confDir, file)) + } + + // Recreate original test files + fs.writeFileSync( + path.join(confDir, 'rekry.json'), + JSON.stringify(mockRekryConfig, null, 2) + ) + + fs.writeFileSync( + path.join(confDir, 'another.json'), + JSON.stringify(mockAnotherConfig, null, 2) + ) + + const loader = SiteConfigurationLoader.getInstance() + + // First call should load + await loader.loadConfigurations() + const firstResult = loader.getConfigurations() + + // Second call should return immediately without reloading + await loader.loadConfigurations() + const secondResult = loader.getConfigurations() + + assert.strictEqual(firstResult, secondResult) + }) +}) From 3a144217d90c9994582a430890ccc2dc9d89072d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 23 Sep 2025 11:17:28 +0300 Subject: [PATCH 021/228] Update packages for security updates --- package-lock.json | 195 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index e64a742..cf309fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -497,13 +497,13 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -680,6 +680,19 @@ "node": ">=8" } }, + "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": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -980,6 +993,20 @@ "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", @@ -1004,6 +1031,51 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "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": ">= 0.4" + } + }, + "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": ">= 0.4" + } + }, + "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.4" + } + }, + "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", @@ -1267,12 +1339,15 @@ } }, "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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": { @@ -1305,6 +1380,15 @@ "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/generify/-/generify-4.2.0.tgz", @@ -1327,6 +1411,43 @@ "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", @@ -1367,6 +1488,18 @@ "node": ">= 6" } }, + "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": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1375,6 +1508,45 @@ "node": ">=8" } }, + "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": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/help-me": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", @@ -1781,6 +1953,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", From 0c4fa0470079a2bec44d0f2bc3cd08c753df1dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 23 Sep 2025 15:08:25 +0300 Subject: [PATCH 022/228] Remove console.logging --- src/bin/hav-init-mongodb.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 48c67ca..1403d45 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -107,10 +107,6 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => } }) console.log('Subscription collection created:', subscriptionResult?.collectionName) - } else { - console.log('Subscription collection already exists') - console.log('NOTE: Existing subscription documents may lack site_id field') - console.log('Consider running a migration to add site_id to existing documents') } return { success: true } From b029c862359e4f833311632891659384c87eb7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 23 Sep 2025 15:08:52 +0300 Subject: [PATCH 023/228] Add migrate scripts for site_id for documents and schema. --- README.md | 8 +++ package.json | 2 + src/bin/hav-migrate-site-id.ts | 113 +++++++++++++++++++++++++++++++++ src/bin/hav-update-schema.ts | 93 +++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 src/bin/hav-migrate-site-id.ts create mode 100644 src/bin/hav-update-schema.ts diff --git a/README.md b/README.md index 1a78100..43a9539 100644 --- a/README.md +++ b/README.md @@ -243,3 +243,11 @@ Adds following emails to the email queue: `npm run hav:send-emails-in-queue` Sends emails in queue that were generated by `hav:populate-email-queue` + +### 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/package.json b/package.json index 8362587..9b483b9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "dev:start": "npm run copy:assets; fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", "info": "fastify print-routes ./routes/root.ts", "hav:init-mongodb": "node dist/bin/hav-init-mongodb.js", + "hav:migrate-site-id": "node dist/bin/hav-migrate-site-id.js", + "hav:update-schema": "node dist/bin/hav-update-schema.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" }, diff --git a/src/bin/hav-migrate-site-id.ts b/src/bin/hav-migrate-site-id.ts new file mode 100644 index 0000000..4f05937 --- /dev/null +++ b/src/bin/hav-migrate-site-id.ts @@ -0,0 +1,113 @@ +/** + * Migration Script: Add site_id to existing subscription documents + * + * This script safely adds site_id field to existing subscription documents + * that don't have this field. Uses configurable default site_id. + */ + +import fastify from 'fastify' +import mongodb from '../plugins/mongodb' +import dotenv from 'dotenv' + +dotenv.config() + +const server = fastify({}) +void server.register(mongodb) + +interface MigrationOptions { + defaultSiteId: string + dryRun: boolean + batchSize: number +} + +const migrateSiteId = async (options: MigrationOptions): Promise<{ success: boolean; updated: number; error?: unknown }> => { + try { + const db = server.mongo.db! + const collection = db.collection('subscription') + + // Find documents without site_id + const documentsWithoutSiteId = await collection.find({ + site_id: { $exists: false } + }).toArray() + + console.log(`Found ${documentsWithoutSiteId.length} documents without site_id`) + + if (documentsWithoutSiteId.length === 0) { + return { success: true, updated: 0 } + } + + if (options.dryRun) { + console.log('DRY RUN - Would update the following documents:') + documentsWithoutSiteId.forEach((doc, index) => { + console.log(`${index + 1}. ${doc._id} - email: ${doc.email}`) + }) + return { success: true, updated: 0 } + } + + // Update documents in batches + let totalUpdated = 0 + const batchSize = options.batchSize + + for (let i = 0; i < documentsWithoutSiteId.length; i += batchSize) { + const batch = documentsWithoutSiteId.slice(i, i + batchSize) + const ids = batch.map(doc => doc._id) + + const result = await collection.updateMany( + { _id: { $in: ids } }, + { + $set: { + site_id: options.defaultSiteId, + modified: new Date() + } + } + ) + + totalUpdated += result.modifiedCount + console.log(`Updated batch ${Math.floor(i / batchSize) + 1}: ${result.modifiedCount} documents`) + } + + console.log(`Migration completed: ${totalUpdated} documents updated with site_id: ${options.defaultSiteId}`) + return { success: true, updated: totalUpdated } + + } catch (error) { + console.error('Error during migration:', error) + return { success: false, updated: 0, error } + } +} + +// CLI argument parsing +const args = process.argv.slice(2) +const dryRun = args.includes('--dry-run') +const batchSize = parseInt(args.find(arg => arg.startsWith('--batch-size='))?.split('=')[1] || '100') + +// Get site_id from first argument (required) +const siteId = args.find(arg => !arg.startsWith('--')) +if (!siteId) { + console.error('Error: site_id is required') + console.error('Usage: npm run hav:migrate-site-id [--dry-run] [--batch-size=100]') + console.error('Example: npm run hav:migrate-site-id rekry') + process.exit(1) +} + +server.ready(async (err) => { + if (err) { + console.error('Server failed to start:', err) + process.exit(1) + } + + console.log('Starting site_id migration...') + console.log(`Target site_id: ${siteId}`) + console.log(`Dry run: ${dryRun}`) + console.log(`Batch size: ${batchSize}`) + + const result = await migrateSiteId({ + defaultSiteId: siteId, + dryRun, + batchSize + }) + + console.log('Migration result:', result) + + await server.close() + process.exit(result.success ? 0 : 1) +}) diff --git a/src/bin/hav-update-schema.ts b/src/bin/hav-update-schema.ts new file mode 100644 index 0000000..f373198 --- /dev/null +++ b/src/bin/hav-update-schema.ts @@ -0,0 +1,93 @@ +/** + * Schema Update Script: Add site_id as required field to subscription collection validator + * + * This script updates the MongoDB collection validator to make site_id a required field. + * Run this AFTER migrating existing documents to have site_id. + */ + +import fastify from 'fastify' +import mongodb from '../plugins/mongodb' +import dotenv from 'dotenv' + +dotenv.config() + +const server = fastify({}) +void server.register(mongodb) + +const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => { + try { + const db = server.mongo.db! + + const result = await db.command({ + collMod: '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.log('Schema updated successfully:', result) + return { success: true } + + } catch (error) { + console.error('Error updating schema:', error) + return { success: false, error } + } +} + +server.ready(async (err) => { + if (err) { + console.error('Server failed to start:', err) + process.exit(1) + } + + console.log('Updating subscription collection schema to require site_id...') + + const result = await updateSchema() + console.log('Schema update result:', result) + + await server.close() + process.exit(result.success ? 0 : 1) +}) From d67ae055c3093bcdcc0b3ae8041cb65d912260e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 23 Sep 2025 15:10:26 +0300 Subject: [PATCH 024/228] Remove unnecessary comments --- src/bin/hav-migrate-site-id.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/bin/hav-migrate-site-id.ts b/src/bin/hav-migrate-site-id.ts index 4f05937..2dbd05f 100644 --- a/src/bin/hav-migrate-site-id.ts +++ b/src/bin/hav-migrate-site-id.ts @@ -1,8 +1,5 @@ /** * Migration Script: Add site_id to existing subscription documents - * - * This script safely adds site_id field to existing subscription documents - * that don't have this field. Uses configurable default site_id. */ import fastify from 'fastify' From 8913a582f468d02de83539540ab217c0df4e7d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 25 Sep 2025 07:48:20 +0300 Subject: [PATCH 025/228] Move elasticproxy to config json --- conf/rekry.json | 6 ++++-- src/bin/hav-populate-email-queue.ts | 6 +++--- src/lib/siteConfigurationLoader.ts | 5 +++-- src/plugins/elasticproxy.ts | 11 ++++++----- src/types/siteConfig.ts | 2 ++ 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/conf/rekry.json b/conf/rekry.json index 06ad3b5..10c4b76 100644 --- a/conf/rekry.json +++ b/conf/rekry.json @@ -14,7 +14,8 @@ }, "mail": { "templatePath": "rekry" - } + }, + "elasticProxyUrl": "https://elastic-helfi-rekry.docker.so" }, "prod": { "urls": { @@ -30,6 +31,7 @@ }, "mail": { "templatePath": "rekry" - } + }, + "elasticProxyUrl": "" } } diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 7309add..1a76707 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -97,13 +97,13 @@ const checkShouldSendExpiryNotification = (subscription: Partial= subscriptionExpiryNotificationSentAt.getTime() } -const getNewHitsFromElasticsearch = async (subscription: any): Promise => { +const getNewHitsFromElasticsearch = async (subscription: SubscriptionCollectionType & { _id: any }, siteConfig: SiteConfigurationType): 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) + const elasticResponse: ElasticProxyJsonResponseType = await server.queryElasticProxy(siteConfig.elasticProxyUrl, elasticQuery) // Filter out new hits: return (elasticResponse?.hits?.hits ?? []) @@ -168,7 +168,7 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom await queueCollection.insertOne(expiryEmailToQueue) } - const newHits = await getNewHitsFromElasticsearch(subscription) + const newHits = await getNewHitsFromElasticsearch(subscription as SubscriptionCollectionType & { _id: any }, siteConfig) // No new hits if (newHits.length === 0) { diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts index 58d7284..e1f1101 100644 --- a/src/lib/siteConfigurationLoader.ts +++ b/src/lib/siteConfigurationLoader.ts @@ -65,7 +65,8 @@ export class SiteConfigurationLoader { name: rawConfig.name, urls: envConfig.urls, subscription: envConfig.subscription, - mail: envConfig.mail + mail: envConfig.mail, + elasticProxyUrl: envConfig.elasticProxyUrl } } catch (error) { throw new Error(`Failed to load configuration from ${filePath}: ${error}`) @@ -129,7 +130,7 @@ export class SiteConfigurationLoader { if (typeof config !== 'object' || config === null) { return false } - const required = ['urls', 'subscription', 'mail'] + const required = ['urls', 'subscription', 'mail', 'elasticProxyUrl'] return required.every(prop => prop in config) } } diff --git a/src/plugins/elasticproxy.ts b/src/plugins/elasticproxy.ts index 2bf5fc6..e15f1aa 100644 --- a/src/plugins/elasticproxy.ts +++ b/src/plugins/elasticproxy.ts @@ -10,16 +10,17 @@ export interface ElasticProxyPluginOptions { /** * Sends a query to the ElasticSearch proxy. + * @param elasticProxyBaseUrl - The base URL of the ElasticSearch proxy. * @param elasticQueryJson - The JSON string representing the ElasticSearch query. * @returns 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 elasticProxyUrl: string = elasticProxyBaseUrl + (elasticQueryJson.startsWith("{}\n") ? '/_msearch' : '/_search'); const contentType: string = elasticQueryJson.startsWith("{}\n") ? 'application/x-ndjson' : 'application/json'; try { @@ -57,6 +58,6 @@ export default fp(async (fastify, opts) => { declare module 'fastify' { export interface FastifyInstance { - queryElasticProxy(elasticQueryJson: string): Promise + queryElasticProxy(elasticProxyBaseUrl: string, elasticQueryJson: string): Promise } } diff --git a/src/types/siteConfig.ts b/src/types/siteConfig.ts index 6ca5f75..dc860ab 100644 --- a/src/types/siteConfig.ts +++ b/src/types/siteConfig.ts @@ -24,6 +24,7 @@ export const SiteEnvironmentConfig = Type.Object({ urls: SiteLanguageUrls, subscription: SiteSubscriptionSettings, mail: SiteMailSettings, + elasticProxyUrl: Type.String(), }) export type SiteEnvironmentConfigType = Static @@ -38,6 +39,7 @@ export const SiteConfiguration = Type.Object({ urls: SiteLanguageUrls, subscription: SiteSubscriptionSettings, mail: SiteMailSettings, + elasticProxyUrl: Type.String(), }) export type SiteConfigurationType = Static export const SiteConfigurationMap = Type.Record(Type.String(), SiteConfiguration) From 8424c9de4be2f8ba15e2a5cda55f03831f30cd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 25 Sep 2025 16:58:22 +0300 Subject: [PATCH 026/228] Fix this diabolical bug finally, for some reason elastic index has nodes without field_publication_starts which crashes lookup for new results --- src/bin/hav-populate-email-queue.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 1a76707..0edc0a9 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -107,7 +107,13 @@ const getNewHitsFromElasticsearch = async (subscription: SubscriptionCollectionT // Filter out new hits: return (elasticResponse?.hits?.hits ?? []) - .filter((hit: { _source: { field_publication_starts: number[]; }; }) => hit._source.field_publication_starts[0] >= lastChecked) + .filter((hit: any) => { + 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) { From d4ee4fa80326c50b8b6aefe8acd2cd0f1533fcd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 26 Sep 2025 14:16:51 +0300 Subject: [PATCH 027/228] Testing instructions --- documentation/testing.md | 128 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 documentation/testing.md diff --git a/documentation/testing.md b/documentation/testing.md new file mode 100644 index 0000000..f65e554 --- /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. + +**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-email-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-email-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. From 3773efc928c623441b21fb7385911359fd5a013b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 26 Sep 2025 14:25:28 +0300 Subject: [PATCH 028/228] Add link to repo --- documentation/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/testing.md b/documentation/testing.md index f65e554..8192a39 100644 --- a/documentation/testing.md +++ b/documentation/testing.md @@ -12,7 +12,7 @@ This guide provides step-by-step instructions for installing, configuring, and t ### Step 1: Install Rekry with Helbit Integration -**Goal:** Install the Rekry by following the official instructions provided in the GitHub repository. +**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. From c6d447c4ade1b47f32260b747d597d575521f768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 26 Sep 2025 15:38:40 +0300 Subject: [PATCH 029/228] Add all envs to config --- .env.dist | 2 +- conf/rekry.json | 52 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.env.dist b/.env.dist index 6684d83..b069acb 100644 --- a/.env.dist +++ b/.env.dist @@ -1,5 +1,5 @@ # Environment configuration -ENVIRONMENT=dev +ENVIRONMENT=local FASTIFY_PORT=3000 # Database configuration diff --git a/conf/rekry.json b/conf/rekry.json index 10c4b76..7fd5a22 100644 --- a/conf/rekry.json +++ b/conf/rekry.json @@ -1,6 +1,6 @@ { "name": "rekry", - "dev": { + "local": { "urls": { "base": "https://helfi-rekry.docker.so", "en": "https://helfi-rekry.docker.so/en", @@ -15,14 +15,48 @@ "mail": { "templatePath": "rekry" }, - "elasticProxyUrl": "https://elastic-helfi-rekry.docker.so" + "elasticProxyUrl": "https://elastic-helfi-rekry.docker.so/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": 90, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 3 + }, + "mail": { + "templatePath": "rekry" + }, + "elasticProxyUrl": "https://rekry-elastic-proxy-test.agw.arodevtest.hel.fi/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": 90, + "unconfirmedMaxAge": 5, + "expiryNotificationDays": 3 + }, + "mail": { + "templatePath": "rekry" + }, + "elasticProxyUrl": "https://rekry-elastic-proxy-staging.api.hel.ninja/job_listings" }, - "prod": { + "production": { "urls": { - "base": "https://hel.fi", - "en": "https://hel.fi/en", - "fi": "https://hel.fi/fi", - "sv": "https://hel.fi/sv" + "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": 90, @@ -32,6 +66,6 @@ "mail": { "templatePath": "rekry" }, - "elasticProxyUrl": "" - } + "elasticProxyUrl": "https://rekry-elastic-proxy.api.hel.ninja/job_listings" + } } From b9fce6eeee2547dcbb9197e580156aa896ca2913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 26 Sep 2025 15:43:10 +0300 Subject: [PATCH 030/228] Update documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 43a9539..6957adc 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ Example configuration structure: The system automatically selects the correct environment configuration based on the `ENVIRONMENT` variable: - Defaults to `dev` if `ENVIRONMENT` is not set -- Use `ENVIRONMENT=prod` for production deployment -- Any environment name can be used (e.g., `staging`, `test`) +- Use `ENVIRONMENT=production` for production deployment +- Sites usually have `local`, `dev`, `staging` and `production` environments ### Configuration Properties From 449bbdb6862c9cf58668c8c8b62385de3ec5acab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 26 Sep 2025 16:31:00 +0300 Subject: [PATCH 031/228] Add local env --- src/types/environment.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/environment.ts b/src/types/environment.ts index 1c676ff..7f82521 100644 --- a/src/types/environment.ts +++ b/src/types/environment.ts @@ -3,7 +3,8 @@ import { Type } from '@sinclair/typebox' export enum Environment { PRODUCTION = 'production', STAGING = 'staging', - DEV = 'dev' + DEV = 'dev', + LOCAL = 'local', } export const EnvironmentType = Type.Enum(Environment) From 0c7f84a8184ce6d8c580df1c6f4b849b1a8c3e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 30 Sep 2025 15:22:21 +0300 Subject: [PATCH 032/228] Fix the self-cert check to refer to local instead of dev, which doesnt refer to local anymore --- src/plugins/elasticproxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/elasticproxy.ts b/src/plugins/elasticproxy.ts index e15f1aa..ec52bb6 100644 --- a/src/plugins/elasticproxy.ts +++ b/src/plugins/elasticproxy.ts @@ -25,7 +25,7 @@ const queryElasticProxy = async (elasticProxyBaseUrl: string, elasticQueryJson: try { let rejectUnauthorized = true - if (process.env.ENVIRONMENT === 'dev') { + if (process.env.ENVIRONMENT === 'local') { // On dev/local, ignore errors with docker certs rejectUnauthorized = false } From 229582e0d1b295429ff71797197423e1ccb9d0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 1 Oct 2025 10:53:28 +0300 Subject: [PATCH 033/228] Adds linter packages --- package-lock.json | 3965 +++++++++++++++++++++++++++++++++++++++------ package.json | 8 + 2 files changed, 3436 insertions(+), 537 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf309fd..56e2d53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,13 @@ "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "concurrently": "^8.2.2", + "eslint": "^8.57.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.1", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", "typescript": "^5.2.2" @@ -67,6 +73,118 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@fastify/ajv-compiler": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz", @@ -131,6 +249,63 @@ "@sinclair/typebox": ">=0.26 <=0.32" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, "node_modules/@immobiliarelabs/fastify-sentry": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@immobiliarelabs/fastify-sentry/-/fastify-sentry-8.0.2.tgz", @@ -194,6 +369,47 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, "node_modules/@sentry-internal/tracing": { "version": "7.109.0", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.109.0.tgz", @@ -308,6 +524,12 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.11.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.4.tgz", @@ -354,128 +576,525 @@ "@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==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", + "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", + "dev": true, "dependencies": { - "event-target-shim": "^5.0.0" + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/type-utils": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" }, "engines": { - "node": ">=6.5" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.45.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "node_modules/@typescript-eslint/parser": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", + "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=0.4.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", + "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.45.0", + "@typescript-eslint/types": "^8.45.0", + "debug": "^4.3.4" + }, "engines": { - "node": ">=0.4.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", + "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", + "dev": true, "dependencies": { - "debug": "^4.3.4" + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0" }, "engines": { - "node": ">= 14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", + "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "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==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", + "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", + "dev": true, "dependencies": { - "ajv": "^8.0.0" + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" }, - "peerDependencies": { - "ajv": "^8.0.0" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@typescript-eslint/types": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", + "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", + "dev": true, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", + "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", + "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "@typescript-eslint/project-service": "8.45.0", + "@typescript-eslint/tsconfig-utils": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "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==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", + "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", + "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.45.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, + "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", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "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", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/atomic-sleep": { @@ -486,6 +1105,21 @@ "node": ">=8.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/avvio": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", @@ -680,12 +1314,30 @@ "node": ">=8" } }, - "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": { + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" }, @@ -693,6 +1345,31 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -832,6 +1509,12 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -912,6 +1595,57 @@ "node": ">=18" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -957,6 +1691,46 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -982,6 +1756,18 @@ "node": ">=0.3.1" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "16.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", @@ -1031,6 +1817,74 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1076,6 +1930,35 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1084,90 +1967,579 @@ "node": ">=6" } }, - "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, "engines": { - "node": ">=0.8.x" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "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/fast-copy": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", - "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" + "node_modules/eslint-config-airbnb-base": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", + "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", + "dev": true, + "dependencies": { + "confusing-browser-globals": "^1.0.10", + "object.assign": "^4.1.2", + "object.entries": "^1.1.5", + "semver": "^6.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "peerDependencies": { + "eslint": "^7.32.0 || ^8.2.0", + "eslint-plugin-import": "^2.25.2" + } }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" + "node_modules/eslint-config-airbnb-base/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } }, - "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==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, "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", - "rfdc": "^1.2.0" + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "dependencies": { - "fast-decode-uri-component": "^1.0.1" + "ms": "^2.1.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", + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, "engines": { - "node": ">=6" + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "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==" - }, - "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==" + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } }, - "node_modules/fastify": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", - "funding": [ + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/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==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint/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==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/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==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "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/fast-copy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", + "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "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==", + "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", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "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==" + }, + "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==" + }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "funding": [ { "type": "github", "url": "https://github.com/sponsors/fastify" @@ -1179,254 +2551,734 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.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" + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.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" + } + }, + "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==", + "dependencies": { + "@fastify/deepmerge": "^1.2.0", + "chalk": "^4.1.2", + "chokidar": "^3.5.2", + "close-with-grace": "^1.1.0", + "commist": "^3.0.0", + "dotenv": "^16.0.0", + "fastify": "^4.0.0", + "fastify-plugin": "^4.0.0", + "generify": "^4.0.0", + "help-me": "^4.0.1", + "is-docker": "^2.0.0", + "make-promises-safe": "^5.1.0", + "pino-pretty": "^10.1.0", + "pkg-up": "^3.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.5", + "yargs-parser": "^21.1.1" + }, + "bin": { + "fastify": "cli.js" + } + }, + "node_modules/fastify-mailer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/fastify-mailer/-/fastify-mailer-2.3.1.tgz", + "integrity": "sha512-SKMkgws+nYXLW1wwZuStxfRZ/y+QFfAfIakv70VSyqxm8uHqelbDX8p8d7bFUNOTH8gg5VeQnXymrKjKD6hOJw==", + "dependencies": { + "fastify-plugin": "^3.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "nodemailer": ">=6.0.0" + } + }, + "node_modules/fastify-mailer/node_modules/fastify-plugin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.1.tgz", + "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==" + }, + "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==", + "dev": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "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==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "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/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/generify": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/generify/-/generify-4.2.0.tgz", + "integrity": "sha512-b4cVhbPfbgbCZtK0dcUc1lASitXGEAIqukV5DDAyWm25fomWnV+C+a1yXvqikcRZXHN2j0pSDyj3cTfzq8pC7Q==", + "dependencies": { + "isbinaryfile": "^4.0.2", + "pump": "^3.0.0", + "split2": "^3.0.0", + "walker": "^1.0.6" + }, + "bin": { + "generify": "generify.js" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "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-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-value": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-3.0.1.tgz", + "integrity": "sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "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" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/fastify-cli": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/fastify-cli/-/fastify-cli-6.0.1.tgz", - "integrity": "sha512-iGN4ULaftZr1qR7OTOQT4tbsduneQWXeF85EUnMeYGmxo5PPfrJ/o9r+X7hMgdnUGCDu2STCYDlMKNYtYYGdgA==", + "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": { - "@fastify/deepmerge": "^1.2.0", - "chalk": "^4.1.2", - "chokidar": "^3.5.2", - "close-with-grace": "^1.1.0", - "commist": "^3.0.0", - "dotenv": "^16.0.0", - "fastify": "^4.0.0", - "fastify-plugin": "^4.0.0", - "generify": "^4.0.0", - "help-me": "^4.0.1", - "is-docker": "^2.0.0", - "make-promises-safe": "^5.1.0", - "pino-pretty": "^10.1.0", - "pkg-up": "^3.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.3.5", - "yargs-parser": "^21.1.1" + "has-symbols": "^1.0.3" }, - "bin": { - "fastify": "cli.js" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fastify-mailer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/fastify-mailer/-/fastify-mailer-2.3.1.tgz", - "integrity": "sha512-SKMkgws+nYXLW1wwZuStxfRZ/y+QFfAfIakv70VSyqxm8uHqelbDX8p8d7bFUNOTH8gg5VeQnXymrKjKD6hOJw==", + "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": { - "fastify-plugin": "^3.0.1" + "function-bind": "^1.1.2" }, "engines": { - "node": ">=10" - }, - "peerDependencies": { - "nodemailer": ">=6.0.0" + "node": ">= 0.4" } }, - "node_modules/fastify-mailer/node_modules/fastify-plugin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.1.tgz", - "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==" + "node_modules/help-me": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", + "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", + "dependencies": { + "glob": "^8.0.0", + "readable-stream": "^3.6.0" + } }, - "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==", - "dev": true, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "license": "ISC", + "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", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "dependencies": { - "reusify": "^1.0.4" + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" } }, - "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", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": { - "to-regex-range": "^5.0.1" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=8" + "node": ">= 14" } }, - "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==", - "license": "MIT", + "node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" + "agent-base": "^7.0.2", + "debug": "4" }, "engines": { - "node": ">=14" + "node": ">= 14" } }, - "node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dependencies": { - "locate-path": "^3.0.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "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==", + "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": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } - ], - "license": "MIT", + ] + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "node": ">= 4" } }, - "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==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "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" - }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "engines": { - "node": ">= 6" + "node": ">=4" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { - "node": ">= 0.6" + "node": ">=0.8.19" } }, - "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/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/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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/generify": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/generify/-/generify-4.2.0.tgz", - "integrity": "sha512-b4cVhbPfbgbCZtK0dcUc1lASitXGEAIqukV5DDAyWm25fomWnV+C+a1yXvqikcRZXHN2j0pSDyj3cTfzq8pC7Q==", + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "dependencies": { - "isbinaryfile": "^4.0.2", - "pump": "^3.0.0", - "split2": "^3.0.0", - "walker": "^1.0.6" + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, - "bin": { - "generify": "generify.js" + "engines": { + "node": ">= 0.4" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "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==", "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">= 0.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", + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "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" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -1435,64 +3287,60 @@ "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", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.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", - "integrity": "sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==", - "dependencies": { - "isobject": "^3.0.1" }, - "engines": { - "node": ">=6.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "has-bigints": "^1.0.2" }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "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": { - "is-glob": "^4.0.1" + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">= 6" + "node": ">=8" } }, - "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", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -1500,19 +3348,26 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -1520,13 +3375,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { + "node_modules/is-data-view": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, "dependencies": { - "has-symbols": "^1.0.3" + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -1535,199 +3392,301 @@ "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", + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "dependencies": { - "function-bind": "^1.1.2" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/help-me": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", - "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", - "dependencies": { - "glob": "^8.0.0", - "readable-stream": "^3.6.0" + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "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-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, "dependencies": { - "whatwg-encoding": "^3.1.1" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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/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==", + "engines": { + "node": ">=8" + } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "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": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 14" + "node": ">=0.10.0" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">= 14" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "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", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "dependencies": { - "binary-extensions": "^2.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "bin": { - "is-docker": "cli.js" + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, "dependencies": { - "is-extglob": "^2.1.1" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, "engines": { - "node": ">=0.12.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/isbinaryfile": { "version": "4.0.10", @@ -1805,6 +3764,18 @@ "node": ">=10" } }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "24.0.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", @@ -1867,6 +3838,12 @@ "node": ">=18" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, "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", @@ -1880,6 +3857,46 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/light-my-request": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", @@ -1909,6 +3926,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -1975,6 +3998,28 @@ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2072,6 +4117,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "node_modules/nodemailer": { "version": "6.9.9", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz", @@ -2080,18 +4131,124 @@ "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==", + "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", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/nwsapi": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", - "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", @@ -2109,6 +4266,40 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -2142,6 +4333,18 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -2177,6 +4380,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2346,6 +4555,24 @@ "node": ">=8" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -2404,6 +4631,26 @@ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2443,12 +4690,54 @@ "node": ">= 12.13.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "dev": true }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2470,6 +4759,26 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2502,11 +4811,93 @@ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "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/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "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": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -2516,6 +4907,25 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2535,6 +4945,39 @@ } ] }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex2": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", @@ -2579,20 +5022,66 @@ "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" - }, - "bin": { - "semver": "bin/semver.js" + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" } }, - "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==" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2626,6 +5115,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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", @@ -2686,6 +5247,19 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2707,6 +5281,62 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2718,6 +5348,15 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2744,6 +5383,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2802,6 +5453,12 @@ "node": "*" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -2878,6 +5535,18 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -2921,12 +5590,48 @@ } } }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2939,6 +5644,80 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", @@ -2952,6 +5731,24 @@ "node": ">=14.17" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -3096,6 +5893,100 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 9b483b9..737fbae 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "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", "info": "fastify print-routes ./routes/root.ts", + "lint": "eslint --fix src/ --ext .ts --no-error-on-unmatched-pattern", + "lint:check": "eslint src/ --ext .ts --no-error-on-unmatched-pattern", "hav:init-mongodb": "node dist/bin/hav-init-mongodb.js", "hav:migrate-site-id": "node dist/bin/hav-migrate-site-id.js", "hav:update-schema": "node dist/bin/hav-update-schema.js", @@ -47,7 +49,13 @@ "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "concurrently": "^8.2.2", + "eslint": "^8.57.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.1", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", "typescript": "^5.2.2" From ec54091d9978ab8f267608e1e4bc697c6b2f9866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 1 Oct 2025 11:18:39 +0300 Subject: [PATCH 034/228] Add linter configs --- .eslintignore | 28 ++++++++++++ .eslintrc.json | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..5aaa1ac --- /dev/null +++ b/.eslintignore @@ -0,0 +1,28 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Coverage +coverage/ + +# Test files (optional - remove if you want to lint tests) +test/ + +# Logs +*.log + +# Environment files +.env +.env.* + +# IDE +.vscode/ +.idea/ + +# Git +.git/ + +# Generated type definitions +process-env.d.ts diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..d64660f --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,113 @@ +{ + "root": true, + "env": { + "node": true, + "es6": true + }, + "extends": [ + "airbnb-base", + "prettier" + ], + "parserOptions": { + "ecmaVersion": 2022 + }, + "rules": { + "prefer-arrow-callback": ["off"], + "quotes": ["warn", "single"], + "semi": ["error", "always"], + "consistent-return": ["off"], + "no-underscore-dangle": ["off"], + "import/no-extraneous-dependencies": ["error", {"devDependencies": true}], + "max-nested-callbacks": ["warn", 5], + "no-plusplus": [ + "warn", + { + "allowForLoopAfterthoughts": true + } + ], + "no-param-reassign": ["off"], + "no-prototype-builtins": ["off"], + "valid-jsdoc": [ + "warn", + { + "prefer": { + "returns": "return", + "property": "prop" + }, + "requireReturn": false + } + ], + "no-unused-vars": ["warn"], + "operator-linebreak": [ + "error", + "after", + { "overrides": { "?": "ignore", ":": "ignore" } } + ], + "no-console": ["warn", { "allow": ["info", "debug", "warn", "error"] }] + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".mjs", ".ts"], + "moduleDirectory": ["node_modules"] + } + } + }, + "overrides": [ + { + "files": ["*.ts"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module", + "project": "./tsconfig.json", + "warnOnUnsupportedTypeScriptVersion": true + }, + "plugins": ["@typescript-eslint"], + "rules": { + "default-case": "off", + "no-dupe-class-members": "off", + "no-undef": "off", + "no-array-constructor": "off", + "no-redeclare": "off", + "no-use-before-define": "off", + "@typescript-eslint/consistent-type-assertions": "warn", + "@typescript-eslint/no-array-constructor": "warn", + "@typescript-eslint/no-redeclare": "warn", + "@typescript-eslint/no-use-before-define": [ + "warn", + { + "functions": false, + "classes": false, + "variables": false, + "typedefs": false + } + ], + "no-unused-expressions": "off", + "@typescript-eslint/no-unused-expressions": [ + "error", + { + "allowShortCircuit": true, + "allowTernary": true, + "allowTaggedTemplates": true + } + ], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "args": "none", + "ignoreRestSiblings": true + } + ], + "no-useless-constructor": "off", + "no-console": ["warn", { "allow": ["warn", "error", "info", "debug"] }], + "camelcase": "off", + "@typescript-eslint/no-useless-constructor": "warn", + "import/extensions": 0, + "import/no-unresolved": 0, + "import/prefer-default-export": 0 + } + } + ] +} From d783bf98fbf83690512f2f63bc64a90dba8d347e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 1 Oct 2025 15:10:59 +0300 Subject: [PATCH 035/228] Add ESLint with Airbnb TypeScript style guide - Configure ESLint with @typescript-eslint parser - Apply auto-formatting (quotes, semicolons, spacing) - Convert for loops to functional patterns (reduce, forEach) - Fix plugin registration in bin scripts (void pattern) - Fix template literal spacing in URL generation - Add Promise.resolve() returns to all reduce callbacks - All logic preserved, fully backwards compatible Tested: All scripts working correctly in local environment --- src/app.ts | 21 +-- src/bin/hav-init-mongodb.ts | 104 ++++++------ src/bin/hav-migrate-site-id.ts | 104 ++++++------ src/bin/hav-populate-email-queue.ts | 240 +++++++++++++++------------- src/bin/hav-send-emails-in-queue.ts | 144 +++++++++-------- src/bin/hav-update-schema.ts | 74 +++++---- src/lib/email.ts | 54 +++---- src/lib/siteConfigurationLoader.ts | 95 ++++++----- src/plugins/atv.ts | 106 ++++++------ src/plugins/base64.ts | 10 +- src/plugins/elasticproxy.ts | 36 ++--- src/plugins/localizedenvvar.ts | 8 +- src/plugins/mailer.ts | 20 +-- src/plugins/mongodb.ts | 10 +- src/plugins/randhash.ts | 10 +- src/plugins/sensible.ts | 8 +- src/plugins/sentry.ts | 5 +- src/plugins/token.ts | 12 +- src/routes/addSubscription.ts | 60 +++---- src/routes/confirmSubscription.ts | 24 +-- src/routes/deleteSubscription.ts | 32 ++-- src/routes/healthzAndReadiness.ts | 16 +- src/routes/root.ts | 12 +- src/types/atv.ts | 8 +- src/types/elasticproxy.ts | 10 +- src/types/environment.ts | 4 +- src/types/error.ts | 4 +- src/types/mailer.ts | 8 +- src/types/siteConfig.ts | 16 +- src/types/subscription.ts | 16 +- 30 files changed, 664 insertions(+), 607 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4da4324..5625f2f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import { join } from 'path'; import AutoLoad, { AutoloadPluginOptions } from '@fastify/autoload'; import { FastifyPluginAsync, FastifyServerOptions } from 'fastify'; +import fastifySentry from '@immobiliarelabs/fastify-sentry'; import { Environment } from './types/environment'; export interface AppOptions extends FastifyServerOptions, Partial { @@ -8,24 +9,24 @@ export interface AppOptions extends FastifyServerOptions, Partial = async ( fastify, opts ): Promise => { if (process.env.ENVIRONMENT === undefined) { - throw new Error('ENVIRONMENT environment variable is not set') + throw new Error('ENVIRONMENT 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: any) => { if (!event?.request?.data) { @@ -44,9 +45,9 @@ const app: FastifyPluginAsync = async ( return event; }, environment: env, - release: release, + release, setErrorHandler: true - }) + }); await Promise.all([ fastify.register(AutoLoad, { @@ -59,8 +60,8 @@ const app: FastifyPluginAsync = async ( options: opts, ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/ }) - ]) -} + ]); +}; export default app; -export { app, options } +export { app, options }; diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 1403d45..3eff671 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -8,126 +8,132 @@ * Must be run before starting the application to ensure proper database structure. */ -import fastify from 'fastify' +import fastify from 'fastify'; +import dotenv from 'dotenv'; import mongodb from '../plugins/mongodb'; -import dotenv from 'dotenv' -dotenv.config() +dotenv.config(); -const server = fastify({}) +const server = fastify({}); -void server.register(mongodb) +// eslint-disable-next-line no-void +void server.register(mongodb); const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => { try { - const db = server.mongo.db! + const db = server.mongo.db!; // Check if collections exist - const collections = await db.listCollections().toArray() - const existingCollections = collections.map(c => c.name) + const collections = await db.listCollections().toArray(); + const existingCollections = collections.map(c => c.name); - let queueResult = null - let subscriptionResult = null + let queueResult = null; + let subscriptionResult = null; // Email queue collection: stores pending notification emails if (!existingCollections.includes('queue')) { - queueResult = await db.createCollection("queue", { + queueResult = await db.createCollection('queue', { validator: { $jsonSchema: { - bsonType: "object", - title: "Hakuvahti email queue", - required: ["email", "content"], + bsonType: 'object', + title: 'Hakuvahti email queue', + required: ['email', 'content'], properties: { _id: { - "bsonType": "objectId" + 'bsonType': 'objectId' }, email: { - bsonType: "string", + bsonType: 'string', }, content: { - bsonType: "string", + bsonType: 'string', } } } } - }) - console.log('Queue collection created:', queueResult?.collectionName) + }); + // eslint-disable-next-line no-console + console.log('Queue collection created:', queueResult?.collectionName); } else { - console.log('Queue collection already exists') + // eslint-disable-next-line no-console + console.log('Queue collection already exists'); } // Subscription collection: stores user search criteria and metadata if (!existingCollections.includes('subscription')) { - subscriptionResult = await db.createCollection("subscription", { + subscriptionResult = await db.createCollection('subscription', { validator: { $jsonSchema: { - bsonType: "object", - title: "Hakuvahti entries", - required: ["email", "elastic_query", "query", "site_id"], + bsonType: 'object', + title: 'Hakuvahti entries', + required: ['email', 'elastic_query', 'query', 'site_id'], properties: { _id: { - "bsonType": "objectId" + 'bsonType': 'objectId' }, email: { - bsonType: "string", + bsonType: 'string', }, elastic_query: { - bsonType: "string", + bsonType: 'string', }, query: { - bsonType: "string", + bsonType: 'string', }, site_id: { - bsonType: "string", + bsonType: 'string', }, hash: { - bsonType: "string", + bsonType: 'string', }, expiry_notification_sent: { - bsonType: "int", + bsonType: 'int', minimum: 0, maximum: 1, }, status: { - bsonType: "int", + bsonType: 'int', minimum: 0, // 0: unconfirmed, 1: active, 2: expired maximum: 2, }, last_checked: { - bsonType: "int" + bsonType: 'int' }, modified: { - bsonType: "date" + bsonType: 'date' }, created: { - bsonType: "date" + bsonType: 'date' } } } } - }) - console.log('Subscription collection created:', subscriptionResult?.collectionName) + }); + // eslint-disable-next-line no-console + console.log('Subscription collection created:', subscriptionResult?.collectionName); } - return { success: true } + return { success: true }; } catch (error) { - console.error('Error initializing MongoDB:', error) - return { success: false, error } + console.error('Error initializing MongoDB:', error); + return { success: false, error }; } -} +}; // Wait for Fastify and MongoDB plugin to be fully initialized before creating collections server.ready(async (err) => { if (err) { - console.error('Server failed to start:', err) - process.exit(1) + console.error('Server failed to start:', err); + process.exit(1); } - console.log('Fastify server ready') + // eslint-disable-next-line no-console + console.log('Fastify server ready'); - const result = await initMongoDB() - console.log('MongoDB initialization result:', result) + const result = await initMongoDB(); + // eslint-disable-next-line no-console + console.log('MongoDB initialization result:', result); - await server.close() - process.exit(result.success ? 0 : 1) // Exit with error code if initialization failed -}) + await server.close(); + process.exit(result.success ? 0 : 1); // Exit with error code if initialization failed +}); diff --git a/src/bin/hav-migrate-site-id.ts b/src/bin/hav-migrate-site-id.ts index 2dbd05f..282aae6 100644 --- a/src/bin/hav-migrate-site-id.ts +++ b/src/bin/hav-migrate-site-id.ts @@ -2,14 +2,15 @@ * Migration Script: Add site_id to existing subscription documents */ -import fastify from 'fastify' -import mongodb from '../plugins/mongodb' -import dotenv from 'dotenv' +import fastify from 'fastify'; +import dotenv from 'dotenv'; +import mongodb from '../plugins/mongodb'; -dotenv.config() +dotenv.config(); -const server = fastify({}) -void server.register(mongodb) +const server = fastify({}); +// eslint-disable-next-line no-void +void server.register(mongodb); interface MigrationOptions { defaultSiteId: string @@ -19,36 +20,40 @@ interface MigrationOptions { const migrateSiteId = async (options: MigrationOptions): Promise<{ success: boolean; updated: number; error?: unknown }> => { try { - const db = server.mongo.db! - const collection = db.collection('subscription') + const db = server.mongo.db!; + const collection = db.collection('subscription'); // Find documents without site_id const documentsWithoutSiteId = await collection.find({ site_id: { $exists: false } - }).toArray() + }).toArray(); - console.log(`Found ${documentsWithoutSiteId.length} documents without site_id`) + // eslint-disable-next-line no-console + console.log(`Found ${documentsWithoutSiteId.length} documents without site_id`); if (documentsWithoutSiteId.length === 0) { - return { success: true, updated: 0 } + return { success: true, updated: 0 }; } if (options.dryRun) { - console.log('DRY RUN - Would update the following documents:') + // eslint-disable-next-line no-console + console.log('DRY RUN - Would update the following documents:'); documentsWithoutSiteId.forEach((doc, index) => { - console.log(`${index + 1}. ${doc._id} - email: ${doc.email}`) - }) - return { success: true, updated: 0 } + // eslint-disable-next-line no-console + console.log(`${index + 1}. ${doc._id} - email: ${doc.email}`); + }); + return { success: true, updated: 0 }; } // Update documents in batches - let totalUpdated = 0 - const batchSize = options.batchSize + let totalUpdated = 0; + const {batchSize} = options; for (let i = 0; i < documentsWithoutSiteId.length; i += batchSize) { - const batch = documentsWithoutSiteId.slice(i, i + batchSize) - const ids = batch.map(doc => doc._id) + const batch = documentsWithoutSiteId.slice(i, i + batchSize); + const ids = batch.map(doc => doc._id); + // eslint-disable-next-line no-await-in-loop const result = await collection.updateMany( { _id: { $in: ids } }, { @@ -57,54 +62,61 @@ const migrateSiteId = async (options: MigrationOptions): Promise<{ success: bool modified: new Date() } } - ) + ); - totalUpdated += result.modifiedCount - console.log(`Updated batch ${Math.floor(i / batchSize) + 1}: ${result.modifiedCount} documents`) + totalUpdated += result.modifiedCount; + // eslint-disable-next-line no-console + console.log(`Updated batch ${Math.floor(i / batchSize) + 1}: ${result.modifiedCount} documents`); } - console.log(`Migration completed: ${totalUpdated} documents updated with site_id: ${options.defaultSiteId}`) - return { success: true, updated: totalUpdated } + // eslint-disable-next-line no-console + console.log(`Migration completed: ${totalUpdated} documents updated with site_id: ${options.defaultSiteId}`); + return { success: true, updated: totalUpdated }; } catch (error) { - console.error('Error during migration:', error) - return { success: false, updated: 0, error } + console.error('Error during migration:', error); + return { success: false, updated: 0, error }; } -} +}; // CLI argument parsing -const args = process.argv.slice(2) -const dryRun = args.includes('--dry-run') -const batchSize = parseInt(args.find(arg => arg.startsWith('--batch-size='))?.split('=')[1] || '100') +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const batchSize = parseInt(args.find(arg => arg.startsWith('--batch-size='))?.split('=')[1] || '100', 10); // Get site_id from first argument (required) -const siteId = args.find(arg => !arg.startsWith('--')) +const siteId = args.find(arg => !arg.startsWith('--')); if (!siteId) { - console.error('Error: site_id is required') - console.error('Usage: npm run hav:migrate-site-id [--dry-run] [--batch-size=100]') - console.error('Example: npm run hav:migrate-site-id rekry') - process.exit(1) + console.error('Error: site_id is required'); + console.error('Usage: npm run hav:migrate-site-id [--dry-run] [--batch-size=100]'); + console.error('Example: npm run hav:migrate-site-id rekry'); + process.exit(1); } server.ready(async (err) => { if (err) { - console.error('Server failed to start:', err) - process.exit(1) + console.error('Server failed to start:', err); + process.exit(1); } - console.log('Starting site_id migration...') - console.log(`Target site_id: ${siteId}`) - console.log(`Dry run: ${dryRun}`) - console.log(`Batch size: ${batchSize}`) + // eslint-disable-next-line no-console + console.log('Starting site_id migration...'); + // eslint-disable-next-line no-console + console.log(`Target site_id: ${siteId}`); + // eslint-disable-next-line no-console + console.log(`Dry run: ${dryRun}`); + // eslint-disable-next-line no-console + console.log(`Batch size: ${batchSize}`); const result = await migrateSiteId({ defaultSiteId: siteId, dryRun, batchSize - }) + }); - console.log('Migration result:', result) + // eslint-disable-next-line no-console + console.log('Migration result:', result); - await server.close() - process.exit(result.success ? 0 : 1) -}) + await server.close(); + process.exit(result.success ? 0 : 1); +}); diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 0edc0a9..0cba4d6 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -1,45 +1,48 @@ -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 { SiteConfigurationType } from '../types/siteConfig' -import decode from '../plugins/base64' -import encode from '../plugins/base64' -import '../plugins/sentry' +import fastify from 'fastify'; +import dotenv from 'dotenv'; +import fastifySentry from '@immobiliarelabs/fastify-sentry'; + +import { expiryEmail, newHitsEmail } from '../lib/email'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import base64Plugin from '../plugins/base64'; +import elasticproxy from '../plugins/elasticproxy'; +import mongodb from '../plugins/mongodb'; +import '../plugins/sentry'; import { ElasticProxyJsonResponseType, PartialDrupalNodeType -} from '../types/elasticproxy' -import { expiryEmail, newHitsEmail } from '../lib/email' -import { QueueInsertDocumentType } from '../types/mailer' -import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader' +} from '../types/elasticproxy'; +import { QueueInsertDocumentType } from '../types/mailer'; +import { SiteConfigurationType } from '../types/siteConfig'; +import { SubscriptionCollectionLanguageType, SubscriptionCollectionType, SubscriptionStatus } from '../types/subscription'; -dotenv.config() +dotenv.config(); -const server = fastify({}) +const server = fastify({}); const release = process.env.SENTRY_RELEASE ?? ''; -server.register(require('@immobiliarelabs/fastify-sentry'), { +server.register(fastifySentry, { dsn: process.env.SENTRY_DSN, environment: process.env.ENVIRONMENT, - release: release, + release, setErrorHandler: true -}) +}); // Register only needed plugins -void server.register(mongodb) -void server.register(elasticproxy) -void server.register(encode) -void server.register(decode) +// eslint-disable-next-line no-void +void server.register(mongodb); +// eslint-disable-next-line no-void +void server.register(elasticproxy); +// eslint-disable-next-line no-void +void server.register(base64Plugin); export const getLocalizedUrl = (siteConfig: SiteConfigurationType, langCode: SubscriptionCollectionLanguageType): string => { - const langKey = langCode.toLowerCase() as keyof typeof siteConfig.urls + const langKey = langCode.toLowerCase() as keyof typeof siteConfig.urls; if (langKey in siteConfig.urls) { - return siteConfig.urls[langKey] + return siteConfig.urls[langKey]; } - return siteConfig.urls.base -} + return siteConfig.urls.base; +}; // Command line/cron application // to query for new results for subscriptions from @@ -54,22 +57,22 @@ export const getLocalizedUrl = (siteConfig: SiteConfigurationType, langCode: Sub * @return {Promise} Promise that resolves when the subscriptions are deleted */ const massDeleteSubscriptions = async (modifyStatus: SubscriptionStatus, olderThanDays: number, siteId: string): Promise => { - const collection = server.mongo.db?.collection('subscription') + const collection = server.mongo.db?.collection('subscription'); if (collection) { - const dateLimit: Date = new Date(Date.now() - (olderThanDays * 24 * 60 * 60 * 1000)) + 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) + console.error(error); - throw new Error('Could not delete subscriptions. See logs for errors.') + throw new Error('Could not delete subscriptions. See logs for errors.'); } } -} +}; /** * Checks if an expiry notification should be sent for a given subscription. @@ -81,48 +84,48 @@ const massDeleteSubscriptions = async (modifyStatus: SubscriptionStatus, olderTh 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 + return false; } // Notification already sent if (subscription.expiry_notification_sent === 1) { - return false + 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)) + 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() -} + return Date.now() >= subscriptionExpiryNotificationSentAt.getTime(); +}; const getNewHitsFromElasticsearch = async (subscription: SubscriptionCollectionType & { _id: any }, siteConfig: SiteConfigurationType): Promise => { - const elasticQuery: string = server.b64decode(subscription.elastic_query) - const lastChecked: number = subscription.last_checked ? subscription.last_checked : Math.floor(new Date().getTime() / 1000) + 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(siteConfig.elasticProxyUrl, elasticQuery) + const elasticResponse: ElasticProxyJsonResponseType = await server.queryElasticProxy(siteConfig.elasticProxyUrl, elasticQuery); // Filter out new hits: return (elasticResponse?.hits?.hits ?? []) .filter((hit: any) => { - const publicationStarts = hit?._source?.field_publication_starts + const publicationStarts = hit?._source?.field_publication_starts; if (!Array.isArray(publicationStarts) || publicationStarts.length === 0) { - return false + return false; } - return publicationStarts[0] >= lastChecked + return publicationStarts[0] >= lastChecked; }) - .map((hit: { _source: PartialDrupalNodeType; }) => hit._source) + .map((hit: { _source: PartialDrupalNodeType; }) => hit._source); } catch (err) { - console.error(`Query ${elasticQuery} for ${subscription._id} failed`) - server.Sentry?.captureException(err) + console.error(`Query ${elasticQuery} for ${subscription._id} failed`); + server.Sentry?.captureException(err); } - return [] -} + return []; +}; /** * Processes subscriptions for a specific site configuration. @@ -131,58 +134,61 @@ const getNewHitsFromElasticsearch = async (subscription: SubscriptionCollectionT * @return {Promise} A Promise that resolves when processing is complete */ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Promise => { - const collection = server.mongo.db!.collection('subscription') - const queueCollection = server.mongo.db!.collection('queue') + const collection = server.mongo.db!.collection('subscription'); + const queueCollection = server.mongo.db!.collection('queue'); // List of all enabled subscriptions for this site const result = await collection.find({ status: SubscriptionStatus.ACTIVE, site_id: siteConfig.id - }).toArray() + }).toArray(); - for (const subscription of result) { - const localizedBaseUrl = getLocalizedUrl(siteConfig, subscription.lang) + // Process subscriptions sequentially to avoid overwhelming the system + await result.reduce(async (previousPromise, subscription) => { + await previousPromise; + + const localizedBaseUrl = getLocalizedUrl(siteConfig, subscription.lang); // If subscription should expire soon, send an expiration email if (checkShouldSendExpiryNotification(subscription as Partial, siteConfig)) { await collection.updateOne( { _id: subscription._id }, { $set: { expiry_notification_sent: 1 } } - ) + ); - const subscriptionValidForDays = siteConfig.subscription.maxAge - 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 subscriptionValidForDays = siteConfig.subscription.maxAge; + 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: siteConfig.urls.base + subscription.query, removal_date: formattedExpiryDate, - remove_link: localizedBaseUrl + '/hakuvahti/unsubscribe?subscription=' + subscription._id + '&hash=' + subscription.hash, - }, siteConfig) + remove_link: `${localizedBaseUrl}/hakuvahti/unsubscribe?subscription=${subscription._id}&hash=${subscription.hash}`, + }, siteConfig); const expiryEmailToQueue: QueueInsertDocumentType = { email: subscription.email, content: expiryEmailContent - } + }; // Add email to queue - await queueCollection.insertOne(expiryEmailToQueue) + await queueCollection.insertOne(expiryEmailToQueue); } - const newHits = await getNewHitsFromElasticsearch(subscription as SubscriptionCollectionType & { _id: any }, siteConfig) + const newHits = await getNewHitsFromElasticsearch(subscription as SubscriptionCollectionType & { _id: any }, siteConfig); // No new hits if (newHits.length === 0) { - continue + return Promise.resolve(); } // Format Mongo DateTime to EU format for email. - const createdDate: string = new Date(subscription.created).toISOString().substring(0, 10) + 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()}`; @@ -191,27 +197,29 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom created_date: formattedCreatedDate, search_description: subscription.search_description, search_link: subscription.query, - remove_link: localizedBaseUrl + '/hakuvahti/unsubscribe?subscription=' + subscription._id + '&hash=' + subscription.hash, + remove_link: `${localizedBaseUrl}/hakuvahti/unsubscribe?subscription=${subscription._id}&hash=${subscription.hash}`, hits: newHits - }, siteConfig) + }, siteConfig); const email: QueueInsertDocumentType = { email: subscription.email, content: emailContent - } + }; // Add email to queue - await queueCollection.insertOne(email) + await queueCollection.insertOne(email); // Set last checked timestamp to this moment - const dateUnixtime: number = Math.floor(new Date().getTime() / 1000) + const dateUnixtime: number = Math.floor(new Date().getTime() / 1000); await collection.updateOne( { _id: subscription._id }, { $set: { last_checked: dateUnixtime } } - ) - } -} + ); + + return Promise.resolve(); + }, Promise.resolve()); +}; /** * Main application function that processes all site configurations. @@ -225,63 +233,75 @@ const app = async (): Promise<{}> => { }); try { - console.log('Environment:', process.env.ENVIRONMENT || 'dev') - console.log('Loading site configurations...') + // eslint-disable-next-line no-console + console.log('Environment:', process.env.ENVIRONMENT || 'dev'); + // eslint-disable-next-line no-console + console.log('Loading site configurations...'); // Load site configurations - const configLoader = SiteConfigurationLoader.getInstance() - await configLoader.loadConfigurations() - const siteConfigs = configLoader.getConfigurations() + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfigs = configLoader.getConfigurations(); - console.log('Loaded configurations for sites:', Object.keys(siteConfigs)) + // eslint-disable-next-line no-console + console.log('Loaded configurations for sites:', Object.keys(siteConfigs)); // Process each site configuration - for (const [siteId, siteConfig] of Object.entries(siteConfigs)) { - console.log(`Processing subscriptions for site: ${siteId}`) - await processSiteSubscriptions(siteConfig) - } + await Object.entries(siteConfigs).reduce(async (previousPromise, [siteId, siteConfig]) => { + await previousPromise; + // eslint-disable-next-line no-console + console.log(`Processing subscriptions for site: ${siteId}`); + await processSiteSubscriptions(siteConfig); + return Promise.resolve(); + }, Promise.resolve()); } catch (error) { - console.error('Configuration loading error:', error) - server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error'}) - server.Sentry?.captureException(error) + console.error('Configuration loading error:', error); + server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error'}); + server.Sentry?.captureException(error); return {}; } - server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok'}) - return {} + server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok'}); + return {}; }; -server.get('/', async function (request, reply) { +server.get('/', async function handleRootRequest(request, reply) { // Load site configurations - const configLoader = SiteConfigurationLoader.getInstance() - await configLoader.loadConfigurations() - const siteConfigs = configLoader.getConfigurations() + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfigs = configLoader.getConfigurations(); // Clean up expired subscriptions for each site - for (const [siteId, siteConfig] of Object.entries(siteConfigs)) { + await Object.entries(siteConfigs).reduce(async (previousPromise, [siteId, siteConfig]) => { + await previousPromise; + // Remove expired subscriptions that haven't been confirmed - await massDeleteSubscriptions(SubscriptionStatus.INACTIVE, siteConfig.subscription.unconfirmedMaxAge, siteId) + await massDeleteSubscriptions(SubscriptionStatus.INACTIVE, siteConfig.subscription.unconfirmedMaxAge, siteId); // Remove expired subscriptions - await massDeleteSubscriptions(SubscriptionStatus.ACTIVE, siteConfig.subscription.maxAge, siteId) - } + await massDeleteSubscriptions(SubscriptionStatus.ACTIVE, siteConfig.subscription.maxAge, siteId); + + return Promise.resolve(); + }, Promise.resolve()); // Loop through subscriptions and add new results to email queue - return await app() -}) + return app(); +}); server.ready((err) => { - console.log('fastify server ready') + // eslint-disable-next-line no-console + console.log('fastify server ready'); server.inject({ method: 'GET', url: '/' - }, (err, response) => { + }, function handleInjectResponse(injectErr, response) { if (response) { - console.log(JSON.parse(response.payload)) + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); } - server.close() - }) + server.close(); + }); -}) +}); diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index b1a74db..9f6ae8a 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -1,103 +1,113 @@ -import fastify from 'fastify' +import fastify from 'fastify'; +import dotenv from 'dotenv'; +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import { JSDOM } from 'jsdom'; +import { ObjectId } from '@fastify/mongodb'; 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() +dotenv.config(); -const server = fastify({}) +const server = fastify({}); const release = process.env.SENTRY_RELEASE ?? ''; -server.register(require('@immobiliarelabs/fastify-sentry'), { +server.register(fastifySentry, { dsn: process.env.SENTRY_DSN, environment: process.env.ENVIRONMENT, - release: release, + release, setErrorHandler: true -}) +}); // Register only needed plugins -void server.register(mailer) -void server.register(mongodb) -void server.register(atv) +// eslint-disable-next-line no-void +void server.register(mailer); +// eslint-disable-next-line no-void +void server.register(mongodb); +// eslint-disable-next-line no-void +void server.register(atv); // Command line/cron application to send all emails from queue collection -const BATCH_SIZE = 100 +const BATCH_SIZE = 100; const app = async (): Promise<{}> => { - const checkInId = server.Sentry?.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress'}) + const checkInId = server.Sentry?.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress'}); if (typeof server.mongo?.db === 'undefined') { - console.error('MongoDB connection not working') - throw new Error('MongoDB connection not working') + 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 + const queueCollection = server.mongo.db!.collection('queue'); - let hasMoreResults = true + let hasMoreResults = true; while (hasMoreResults) { - const result = await queueCollection.find({}).limit(BATCH_SIZE).toArray() + // eslint-disable-next-line no-await-in-loop + const result = await queueCollection.find({}).limit(BATCH_SIZE).toArray(); if (result.length === 0) { - hasMoreResults = false + hasMoreResults = false; } else { // Collect email ids as map - const emailIdsMap = new Map() + const emailIdsMap = new Map(); - for (const email of result) { - emailIdsMap.set(email.email, null) - } + result.forEach((email) => { + emailIdsMap.set(email.email, null); + }); // Get batch of email documents from ATV - const emailIds = [...emailIdsMap.keys()] - const emailDocuments:Partial = await server.atvGetDocumentBatch(emailIds) + const emailIds = [...emailIdsMap.keys()]; + // eslint-disable-next-line no-await-in-loop + const emailDocuments:Partial = await server.atvGetDocumentBatch(emailIds); // Update the email map with unencrypted email list if (emailDocuments.length > 0) { - for (const emailDocument of emailDocuments) { + emailDocuments.forEach((emailDocument) => { if (emailDocument?.id) { - emailIdsMap.set(emailDocument.id, emailDocument.content.email) + 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' + // Send emails sequentially to avoid overwhelming the system + // eslint-disable-next-line no-await-in-loop + await result.reduce(async (previousPromise, email) => { + await previousPromise; + + 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) + 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); - })) + 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) + server.Sentry?.captureException(error); console.error(error); } @@ -105,36 +115,40 @@ const app = async (): Promise<{}> => { // 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) }) + 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`) + console.error(`Could not delete email document with id ${email._id} from queue`); - throw Error('Deleting email from queue failed.') + throw Error('Deleting email from queue failed.'); } - } + + return Promise.resolve(); + }, Promise.resolve()); } } - server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok'}) - return {} -} + server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok'}); + return {}; +}; -server.get('/', async function (request, reply) { +server.get('/', async function handleRootRequest(request, reply) { // Send all emails from queue - return await app() -}) + return app(); +}); server.ready((err) => { - console.log('fastify server ready') + // eslint-disable-next-line no-console + console.log('fastify server ready'); server.inject({ method: 'GET', url: '/' - }, (err, response) => { + }, function handleInjectResponse(injectErr, response) { if (response) { - console.log(JSON.parse(response.payload)) + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); } - server.close() - }) + server.close(); + }); -}) +}); diff --git a/src/bin/hav-update-schema.ts b/src/bin/hav-update-schema.ts index f373198..d365ab4 100644 --- a/src/bin/hav-update-schema.ts +++ b/src/bin/hav-update-schema.ts @@ -5,89 +5,93 @@ * Run this AFTER migrating existing documents to have site_id. */ -import fastify from 'fastify' -import mongodb from '../plugins/mongodb' -import dotenv from 'dotenv' +import fastify from 'fastify'; +import dotenv from 'dotenv'; +import mongodb from '../plugins/mongodb'; -dotenv.config() +dotenv.config(); -const server = fastify({}) -void server.register(mongodb) +const server = fastify({}); +// eslint-disable-next-line no-void +void server.register(mongodb); const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => { try { - const db = server.mongo.db! + const db = server.mongo.db!; const result = await db.command({ collMod: 'subscription', validator: { $jsonSchema: { - bsonType: "object", - title: "Hakuvahti entries", - required: ["email", "elastic_query", "query", "site_id"], + bsonType: 'object', + title: 'Hakuvahti entries', + required: ['email', 'elastic_query', 'query', 'site_id'], properties: { _id: { - "bsonType": "objectId" + 'bsonType': 'objectId' }, email: { - bsonType: "string", + bsonType: 'string', }, elastic_query: { - bsonType: "string", + bsonType: 'string', }, query: { - bsonType: "string", + bsonType: 'string', }, site_id: { - bsonType: "string", + bsonType: 'string', }, hash: { - bsonType: "string", + bsonType: 'string', }, expiry_notification_sent: { - bsonType: "int", + bsonType: 'int', minimum: 0, maximum: 1, }, status: { - bsonType: "int", + bsonType: 'int', minimum: 0, // 0: unconfirmed, 1: active, 2: expired maximum: 2, }, last_checked: { - bsonType: "int" + bsonType: 'int' }, modified: { - bsonType: "date" + bsonType: 'date' }, created: { - bsonType: "date" + bsonType: 'date' } } } } - }) + }); - console.log('Schema updated successfully:', result) - return { success: true } + // eslint-disable-next-line no-console + console.log('Schema updated successfully:', result); + return { success: true }; } catch (error) { - console.error('Error updating schema:', error) - return { success: false, error } + console.error('Error updating schema:', error); + return { success: false, error }; } -} +}; server.ready(async (err) => { if (err) { - console.error('Server failed to start:', err) - process.exit(1) + console.error('Server failed to start:', err); + process.exit(1); } - console.log('Updating subscription collection schema to require site_id...') + // eslint-disable-next-line no-console + console.log('Updating subscription collection schema to require site_id...'); - const result = await updateSchema() - console.log('Schema update result:', result) + const result = await updateSchema(); + // eslint-disable-next-line no-console + console.log('Schema update result:', result); - await server.close() - process.exit(result.success ? 0 : 1) -}) + await server.close(); + process.exit(result.success ? 0 : 1); +}); diff --git a/src/lib/email.ts b/src/lib/email.ts index 0bcc044..4e424f0 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,38 +1,26 @@ -import { sprightly } from "sprightly"; -import { SubscriptionCollectionLanguageType } from "../types/subscription" -import { PartialDrupalNodeType } from "../types/elasticproxy" -import { SiteConfigurationType } from "../types/siteConfig" +import { sprightly } from 'sprightly'; +import { SubscriptionCollectionLanguageType } from '../types/subscription'; +import { PartialDrupalNodeType } from '../types/elasticproxy'; +import { SiteConfigurationType } from '../types/siteConfig'; // Subscription confirmation email -export const confirmationEmail = async (lang: SubscriptionCollectionLanguageType, data: { link: string; }, siteConfig: SiteConfigurationType) => { - try { - return sprightly('dist/templates/' + siteConfig.mail.templatePath + '/confirmation_' + lang + '.html', { - lang: lang, - link: data.link, - }); - } catch (error) { - throw error - } -} +export const confirmationEmail = async (lang: SubscriptionCollectionLanguageType, data: { link: string; }, siteConfig: SiteConfigurationType) => sprightly(`dist/templates/${ siteConfig.mail.templatePath }/confirmation_${ lang }.html`, { + lang, + link: data.link, +}); // Notification before subscription expires export const expiryEmail = async (lang: SubscriptionCollectionLanguageType, data: { link: string, search_description: string, removal_date: string, - remove_link: string }, siteConfig: SiteConfigurationType) => { - try { - return sprightly('dist/templates/' + siteConfig.mail.templatePath + '/expiry_notification_' + lang + '.html', { - lang: lang, - link: data.link, - search_description: data.search_description, - remove_link: data.remove_link, - removal_date: data.removal_date - }); - } catch (error) { - throw error - } -} + remove_link: string }, siteConfig: SiteConfigurationType) => sprightly(`dist/templates/${ siteConfig.mail.templatePath }/expiry_notification_${ lang }.html`, { + lang, + link: data.link, + search_description: data.search_description, + remove_link: data.remove_link, + removal_date: data.removal_date +}); // Email with list of new search monitor hits export const newHitsEmail = async (lang: SubscriptionCollectionLanguageType, data: { @@ -45,18 +33,18 @@ export const newHitsEmail = async (lang: SubscriptionCollectionLanguageType, dat const hitsContent = data.hits.map(item => sprightly('dist/templates/link_text.html', { link: siteConfig.urls.base + item.url, content: item.title, - })).join('') + })).join(''); return sprightly(`dist/templates/${siteConfig.mail.templatePath}/newhits_${lang}.html`, { - lang: lang, + 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 - }) + }); } catch (error) { - console.error(error) - throw error + console.error(error); + throw error; } -} +}; diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts index e1f1101..28501d0 100644 --- a/src/lib/siteConfigurationLoader.ts +++ b/src/lib/siteConfigurationLoader.ts @@ -1,62 +1,66 @@ -import * as fs from 'fs' -import * as path from 'path' -import { SiteConfigurationType, SiteConfigurationMapType, SiteConfigurationFileType, SiteEnvironmentConfigType } from '../types/siteConfig' +import * as fs from 'fs'; +import * as path from 'path'; +import { SiteConfigurationType, SiteConfigurationMapType, SiteConfigurationFileType, SiteEnvironmentConfigType } from '../types/siteConfig'; export class SiteConfigurationLoader { - private static instance: SiteConfigurationLoader - private configurations: SiteConfigurationMapType = {} - private loaded = false + 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() + SiteConfigurationLoader.instance = new SiteConfigurationLoader(); } - return SiteConfigurationLoader.instance + return SiteConfigurationLoader.instance; } public async loadConfigurations(): Promise { if (this.loaded) { - return + return; } - const environment = process.env.ENVIRONMENT || 'dev' - const configDir = path.resolve(process.cwd(), 'conf') + 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}`) + throw new Error(`Configuration directory not found: ${configDir}`); } const files = fs.readdirSync(configDir) - .filter(file => file.endsWith('.json')) + .filter(file => file.endsWith('.json')); if (files.length === 0) { - throw new Error('No JSON configuration files found in conf/ directory') + 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) + 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) + const fileContent = fs.readFileSync(filePath, 'utf8'); + const rawConfig: SiteConfigurationFileType = JSON.parse(fileContent); if (!this.validateRawConfiguration(rawConfig)) { - throw new Error(`Invalid configuration structure in ${filePath}`) + throw new Error(`Invalid configuration structure in ${filePath}`); } // Extract environment-specific config - const envConfig = (rawConfig as any)[environment] as SiteEnvironmentConfigType + const envConfig = (rawConfig as any)[environment] as SiteEnvironmentConfigType; if (!envConfig) { - throw new Error(`Environment '${environment}' not found in configuration ${filePath}`) + throw new Error(`Environment '${environment}' not found in configuration ${filePath}`); } if (!this.validateEnvironmentConfiguration(envConfig)) { - throw new Error(`Invalid environment configuration for '${environment}' in ${filePath}`) + throw new Error(`Invalid environment configuration for '${environment}' in ${filePath}`); } // Flatten to runtime configuration @@ -67,70 +71,79 @@ export class SiteConfigurationLoader { subscription: envConfig.subscription, mail: envConfig.mail, elasticProxyUrl: envConfig.elasticProxyUrl - } + }; } catch (error) { - throw new Error(`Failed to load configuration from ${filePath}: ${error}`) + throw new Error(`Failed to load configuration from ${filePath}: ${error}`); } } - this.loaded = true + 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.') + throw new Error('Configurations not loaded. Call loadConfigurations() first.'); } - return this.configurations + 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.') + throw new Error('Configurations not loaded. Call loadConfigurations() first.'); } - return this.configurations[siteId] + return this.configurations[siteId]; } public getSiteIds(): string[] { if (!this.loaded) { - throw new Error('Configurations not loaded. Call loadConfigurations() first.') + throw new Error('Configurations not loaded. Call loadConfigurations() first.'); } - return Object.keys(this.configurations) + 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 */ - private validateRawConfiguration(config: unknown): config is SiteConfigurationFileType { + // eslint-disable-next-line class-methods-use-this + public validateRawConfiguration(config: unknown): config is SiteConfigurationFileType { if (typeof config !== 'object' || config === null) { - return false + return false; } - const configObj = config as Record + const configObj = config as Record; // Must have 'name' property if (!('name' in configObj) || typeof configObj.name !== 'string') { - return false + return false; } // Must have at least one environment configuration (excluding 'name') - const envKeys = Object.keys(configObj).filter(key => key !== 'name') - return envKeys.length > 0 + 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 */ - private validateEnvironmentConfiguration(config: unknown): config is SiteEnvironmentConfigType { + // eslint-disable-next-line class-methods-use-this + public validateEnvironmentConfiguration(config: unknown): config is SiteEnvironmentConfigType { if (typeof config !== 'object' || config === null) { - return false + return false; } - const required = ['urls', 'subscription', 'mail', 'elasticProxyUrl'] - return required.every(prop => prop in config) + const required = ['urls', 'subscription', 'mail', 'elasticProxyUrl']; + return required.every(prop => prop in config); } } diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index f08e0ba..3558cb4 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -1,11 +1,11 @@ -import fp from 'fastify-plugin' -import axios, { AxiosResponse } from 'axios' +import fp from 'fastify-plugin'; +import axios, { AxiosResponse } from 'axios'; +import type { FastifyRequest as FastifyRequestType } from 'fastify'; import { AtvDocumentBatchType, AtvDocumentType, - AtvResponseType } from '../types/atv' -import { SubscriptionRequestType } from '../types/subscription' -import { FastifyRequest } from 'fastify/types/request' + AtvResponseType } from '../types/atv'; +import { SubscriptionRequestType } from '../types/subscription'; export interface AtvPluginOptions { } @@ -22,19 +22,19 @@ const atvFetchContentById = async (atvDocumentId: 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 = +process.env.SUBSCRIPTION_MAX_AGE!; + deleteAfter.setDate(deleteAfter.getDate() + maxAge); // Minimal document required by ATV const documentObject: Partial = { @@ -60,7 +60,7 @@ const atvCreateDocumentWithEmail = async (email: string): Promise> = await axios.post( `${process.env.ATV_API_URL}/v1/documents/`, @@ -71,15 +71,15 @@ const atvCreateDocumentWithEmail = async (email: string): Promise> = await axios.post( `${process.env.ATV_API_URL}/v1/documents/batch-list/`, @@ -102,15 +102,15 @@ const atvGetDocumentBatch = async (emails: string[]): Promise { +const requestEmailHook = async (request: FastifyRequestType) => { try { // Hook only runs on POST requests if (request.method !== 'POST') { - return + 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() + const body: Partial = request.body as Partial; + const email: string = (body.email as string)?.trim(); if (!isValidEmail(email)) { - throw new Error('Invalid email format') + throw new Error('Invalid email format'); } - const atvDocument: Partial = await atvCreateDocumentWithEmail(email) - const atvDocumentId: string | undefined = atvDocument.id + const atvDocument: Partial = await atvCreateDocumentWithEmail(email); + const atvDocumentId: string | undefined = atvDocument.id; if (atvDocumentId) { request.atvResponse = { - atvDocumentId: atvDocumentId, - } + atvDocumentId, + }; } } catch (error) { - console.error('An error occurred:', error) - throw new Error('Could not create document to ATV. Cannot subscribe.') + console.error('An error occurred:', error); + throw new Error('Could not create document to ATV. Cannot subscribe.'); } -} +}; 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 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) + fastify.addHook('preHandler', requestEmailHook); // Expose atvQueryEmail function to global scope - fastify.decorate('atvQueryEmail', async function (atvDocumentId: string) { - return atvFetchContentById(atvDocumentId) - }) + fastify.decorate('atvQueryEmail', async function atvQueryEmail(atvDocumentId: string) { + return atvFetchContentById(atvDocumentId); + }); // Expose atvCreateDocumentWithEmail function to global scope - fastify.decorate('atvCreateDocumentWithEmail', async function (email: string) { - return atvCreateDocumentWithEmail(email) - }) + fastify.decorate('atvCreateDocumentWithEmail', async function atvCreateDocumentWithEmailHandler(email: string) { + return atvCreateDocumentWithEmail(email); + }); // 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); + }); -}) +}); declare module 'fastify' { export interface FastifyRequest { diff --git a/src/plugins/base64.ts b/src/plugins/base64.ts index d695c44..e5e25f4 100644 --- a/src/plugins/base64.ts +++ b/src/plugins/base64.ts @@ -1,5 +1,5 @@ -import fp from 'fastify-plugin' -import { Buffer } from 'buffer' +import fp from 'fastify-plugin'; +import { Buffer } from 'buffer'; // Helper plugin to encode/decode base64. // Functions can be used through import or through Fastify instance. @@ -11,9 +11,9 @@ export const decode = (str: string):string => Buffer.from(str, 'base64').toStrin 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) -}) + fastify.decorate('b64decode', decode); + fastify.decorate('b64encode', encode); +}); declare module 'fastify' { export interface FastifyInstance { diff --git a/src/plugins/elasticproxy.ts b/src/plugins/elasticproxy.ts index ec52bb6..f0ebe95 100644 --- a/src/plugins/elasticproxy.ts +++ b/src/plugins/elasticproxy.ts @@ -1,7 +1,7 @@ import axios from 'axios'; -import fp from 'fastify-plugin' +import fp from 'fastify-plugin'; +import https from 'https'; import { ElasticProxyJsonResponseType } from '../types/elasticproxy'; -import https from 'https' // Query Elastic Proxy @@ -10,51 +10,51 @@ export interface ElasticProxyPluginOptions { /** * Sends a query to the ElasticSearch proxy. - * @param elasticProxyBaseUrl - The base URL of 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 (elasticProxyBaseUrl: string, elasticQueryJson: string): Promise => { if (!elasticProxyBaseUrl) { - throw new Error('elasticProxyBaseUrl is required') + throw new Error('elasticProxyBaseUrl is required'); } // Elastic proxy supports ndjson (multipart json requests) or single json searches - const elasticProxyUrl: string = elasticProxyBaseUrl + (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 + 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 } } - ) + ); 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) -}) + fastify.decorate('queryElasticProxy', queryElasticProxy); +}); declare module 'fastify' { export interface FastifyInstance { diff --git a/src/plugins/localizedenvvar.ts b/src/plugins/localizedenvvar.ts index 7c3ed67..7e2b73d 100644 --- a/src/plugins/localizedenvvar.ts +++ b/src/plugins/localizedenvvar.ts @@ -4,13 +4,11 @@ import { SubscriptionCollectionLanguageType } from '../types/subscription'; export interface localizedEnvVarPluginPluginOptions { } -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..7ee4687 100644 --- a/src/plugins/mailer.ts +++ b/src/plugins/mailer.ts @@ -1,10 +1,10 @@ -import fp from 'fastify-plugin' -import { FastifyInstance } from 'fastify' -import { FastifyMailer } from '../types/mailer' +import fp from 'fastify-plugin'; +import { FastifyInstance } from 'fastify'; +import { FastifyMailer } from '../types/mailer'; // Initialize mailer as plugin -export default fp(async function (fastify: FastifyInstance) { +export default fp(async function mailerPlugin(fastify: FastifyInstance) { const opts = { defaults: { from: process.env.MAIL_FROM @@ -12,18 +12,20 @@ export default fp(async function (fastify: FastifyInstance) { 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 } } - } + }; - 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..973e0fa 100644 --- a/src/plugins/mongodb.ts +++ b/src/plugins/mongodb.ts @@ -1,12 +1,12 @@ -import fp from 'fastify-plugin' -import mongo from '@fastify/mongodb' -import { FastifyInstance } from 'fastify' +import fp from 'fastify-plugin'; +import mongo from '@fastify/mongodb'; +import { FastifyInstance } from 'fastify'; // MongoDB connection -export default fp(async function (fastify: FastifyInstance) { +export default fp(async function mongodbPlugin(fastify: FastifyInstance) { fastify.register(mongo, { url: process.env.MONGODB, forceClose: true - }) + }); }); diff --git a/src/plugins/randhash.ts b/src/plugins/randhash.ts index 191c135..9657ecf 100644 --- a/src/plugins/randhash.ts +++ b/src/plugins/randhash.ts @@ -1,4 +1,4 @@ -import fp from 'fastify-plugin' +import fp from 'fastify-plugin'; // Helper plugin for random hash @@ -6,10 +6,10 @@ export interface RandHashPluginOptions { } export default fp(async (fastify, opts) => { - fastify.decorate('getRandHash', function () { - return (Math.random() + 1).toString(36).substring(2) - }) -}) + 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..b16ec7c 100644 --- a/src/plugins/sensible.ts +++ b/src/plugins/sensible.ts @@ -1,8 +1,8 @@ -import fp from 'fastify-plugin' -import sensible, { SensibleOptions } from '@fastify/sensible' +import fp from 'fastify-plugin'; +import sensible, { SensibleOptions } from '@fastify/sensible'; // This plugins adds some utilities to handle http errors export default fp(async (fastify) => { - fastify.register(sensible) -}) + fastify.register(sensible); +}); diff --git a/src/plugins/sentry.ts b/src/plugins/sentry.ts index 6d98605..2b27c1c 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 * as Sentry from '@sentry/node'; declare module 'fastify' { export interface FastifyInstance { - Sentry: typeof Sentry + Sentry: typeof Sentry; } } diff --git a/src/plugins/token.ts b/src/plugins/token.ts index 3f8b901..b31958b 100644 --- a/src/plugins/token.ts +++ b/src/plugins/token.ts @@ -1,4 +1,4 @@ -import fp from 'fastify-plugin' +import fp from 'fastify-plugin'; // Validate token in request headers @@ -6,21 +6,21 @@ 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 + return true; } if (!request.headers.token) { reply .code(403) .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Authentication failed.'}) + .send({ error: 'Authentication failed.'}); } // TODO: Do something with the token - return true - }) -}) + return true; + }); +}); declare module 'fastify' { export interface FastifyRequest { diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index b56b292..12a854a 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -3,7 +3,7 @@ import { FastifyRequest, FastifyReply, FastifyInstance -} from 'fastify' +} from 'fastify'; import { SubscriptionResponse, @@ -12,16 +12,16 @@ import { SubscriptionRequest, SubscriptionRequestType, SubscriptionStatus -} from '../types/subscription' +} from '../types/subscription'; import { Generic500Error, Generic500ErrorType -} from '../types/error' +} from '../types/error'; -import { confirmationEmail } from '../lib/email' -import { QueueInsertDocumentType } from '../types/mailer' -import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader' +import { confirmationEmail } from '../lib/email'; +import { QueueInsertDocumentType } from '../types/mailer'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; // Add subscription to given query parameters @@ -44,34 +44,34 @@ const subscription: FastifyPluginAsync = async ( request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply ) => { - const mongodb = fastify.mongo - const collection = mongodb.db?.collection('subscription') - const hash = fastify.getRandHash() + 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.' }) + .send({ error: 'Could not find hashed email. Subscription not added.' }); request.body.email = request.atvResponse.atvDocumentId; // Load site configuration - const configLoader = SiteConfigurationLoader.getInstance() - await configLoader.loadConfigurations() - const siteConfig = configLoader.getConfiguration(request.body.site_id) + 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.' }) + .send({ error: 'Invalid site_id provided.' }); } // Subscription data that goes to collection - const subscription: Partial = { + const subscriptionData: Partial = { ...request.body, - hash: hash, + hash, created: new Date(), modified: new Date(), last_checked: Math.floor(Date.now() / 1000), @@ -79,36 +79,36 @@ const subscription: FastifyPluginAsync = async ( status: SubscriptionStatus.INACTIVE }; - const response = await collection?.insertOne(subscription) + const response = await collection?.insertOne(subscriptionData); if (!response) { - fastify.log.debug(response) + fastify.log.debug(response); - throw new Error('Adding new subscription failed. See logs.') + throw new Error('Adding new subscription failed. See logs.'); } // Insert email in queue - const langKey = request.body.lang.toLowerCase() as keyof typeof siteConfig.urls - const subscribeLinkBase = (langKey in siteConfig.urls) ? siteConfig.urls[langKey] : siteConfig.urls.base + const langKey = request.body.lang.toLowerCase() as keyof typeof siteConfig.urls; + const subscribeLinkBase = (langKey in siteConfig.urls) ? siteConfig.urls[langKey] : siteConfig.urls.base; const emailContent = await confirmationEmail(request.body.lang, { - link: subscribeLinkBase + `/hakuvahti/confirm?subscription=${response.insertedId}&hash=${hash}` - }, siteConfig) + link: `${subscribeLinkBase }/hakuvahti/confirm?subscription=${response.insertedId}&hash=${hash}` + }, siteConfig); // Email data to queue const email:QueueInsertDocumentType = { email: request.body.email, content: emailContent - } + }; - const q = mongodb.db?.collection('queue') - await q?.insertOne(email) + const q = mongodb.db?.collection('queue'); + await q?.insertOne(email); - fastify.log.debug(emailContent) + fastify.log.debug(emailContent); return reply .code(200) .header('Content-Type', 'application/json; charset=utf-8') .send(response); - }) -} + }); +}; -export default subscription +export default subscription; diff --git a/src/routes/confirmSubscription.ts b/src/routes/confirmSubscription.ts index ed1938a..f628072 100644 --- a/src/routes/confirmSubscription.ts +++ b/src/routes/confirmSubscription.ts @@ -3,20 +3,20 @@ import { FastifyReply, FastifyInstance, FastifyRequest -} from 'fastify' +} from 'fastify'; +import { ObjectId } from '@fastify/mongodb'; import { Generic500Error, Generic500ErrorType -} from '../types/error' +} from '../types/error'; import { SubscriptionGenericPostResponse, SubscriptionGenericPostResponseType, SubscriptionStatus -} from '../types/subscription' +} from '../types/subscription'; -import { ObjectId } from '@fastify/mongodb' // Confirms subscription @@ -37,13 +37,13 @@ const confirmSubscription: FastifyPluginAsync = async ( request: FastifyRequest, reply: FastifyReply ) => { - const mongodb = fastify.mongo + const mongodb = fastify.mongo; const collection = mongodb.db?.collection('subscription'); - const { id, hash } = <{ id: string, hash: string }>request.params + const { id, hash } = request.params as { id: string, hash: string }; const subscription = await collection?.findOne({ _id: new ObjectId(id), - hash: hash, + hash, status: SubscriptionStatus.INACTIVE }); @@ -59,7 +59,7 @@ const confirmSubscription: FastifyPluginAsync = async ( await collection!.updateOne( { _id: new ObjectId(id) }, { $set: { status: SubscriptionStatus.ACTIVE } }, - ) + ); return reply .code(200) @@ -67,8 +67,8 @@ const confirmSubscription: FastifyPluginAsync = async ( .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..4790b89 100644 --- a/src/routes/deleteSubscription.ts +++ b/src/routes/deleteSubscription.ts @@ -3,18 +3,18 @@ import { FastifyReply, FastifyInstance, FastifyRequest -} from 'fastify' +} from 'fastify'; +import { ObjectId } from '@fastify/mongodb'; import { Generic500Error, Generic500ErrorType -} from '../types/error' +} from '../types/error'; import { SubscriptionGenericPostResponse, SubscriptionGenericPostResponseType -} from '../types/subscription' -import { ObjectId } from '@fastify/mongodb' +} from '../types/subscription'; // Deletes subscription @@ -35,14 +35,14 @@ const deleteSubscription: FastifyPluginAsync = async ( request: FastifyRequest, reply: FastifyReply ) => { - const mongodb = fastify.mongo + const mongodb = fastify.mongo; const collection = mongodb.db?.collection('subscription'); - const { id, hash } = <{ id: string, hash: string }>request.params + const { id, hash } = request.params as { id: string, hash: string }; // Check that subscription exists and hash matches const subscription = await collection?.findOne({ _id: new ObjectId(id), - hash: hash + hash }); if (!subscription) { @@ -51,17 +51,17 @@ const deleteSubscription: FastifyPluginAsync = async ( .send({ statusCode: 404, statusMessage: 'Subscription not found.' - }) + }); } // Delete subscription - const result = await collection?.deleteOne({ _id: new ObjectId(id) }) + const result = await collection?.deleteOne({ _id: new ObjectId(id) }); fastify.log.info({ level: 'info', message: 'Subscription deleted', - result: result - }) + result + }); if (result?.deletedCount === 0) { return reply @@ -69,7 +69,7 @@ const deleteSubscription: FastifyPluginAsync = async ( .send({ statusCode: 404, statusMessage: 'Subscription not found.' - }) + }); } return reply @@ -77,8 +77,8 @@ const deleteSubscription: FastifyPluginAsync = async ( .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..198bd3a 100644 --- a/src/routes/healthzAndReadiness.ts +++ b/src/routes/healthzAndReadiness.ts @@ -25,14 +25,12 @@ const healthzAndReadiness: FastifyPluginAsync = async ( }, async ( request: FastifyRequest, reply: FastifyReply - ) => { - return reply + ) => reply .code(200) .send({ statusCode: 200, message: 'OK' - }) - }) + })); fastify.get('/readiness', { schema: { @@ -70,16 +68,16 @@ const healthzAndReadiness: FastifyPluginAsync = async ( .send({ statusCode: 200, message: 'OK' - }) - } catch (error) { + }); + } catch { return reply .code(500) .send({ statusCode: 500, message: 'MongoDB connection failed' - }) + }); } - }) -} + }); +}; export default healthzAndReadiness; diff --git a/src/routes/root.ts b/src/routes/root.ts index 27918c7..f416ad3 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,9 +1,9 @@ -import { FastifyPluginAsync } from 'fastify' +import { FastifyPluginAsync } from 'fastify'; const root: FastifyPluginAsync = async (fastify, opts): Promise => { - fastify.get('/', async function (request, reply) { - return { root: true } - }) -} + fastify.get('/', async function rootHandler(request, reply) { + return { root: true }; + }); +}; -export default root +export default root; diff --git a/src/types/atv.ts b/src/types/atv.ts index 73ac05e..e428e51 100644 --- a/src/types/atv.ts +++ b/src/types/atv.ts @@ -1,8 +1,8 @@ -import { Static, Type } from '@sinclair/typebox' +import { Static, Type } from '@sinclair/typebox'; export const AtvResponse = Type.Object({ atvDocumentId: Type.String() -}) +}); export type AtvResponseType = Static @@ -64,12 +64,12 @@ export const AtvDocument = Type.Object({ // Attachments attachments: Type.Optional(Type.Array(Type.Any())) -}) +}); export type AtvDocumentType = Static export const AtvDocumentBatch = Type.Object({ document_ids: Type.Array(Type.String()) -}) +}); export type AtvDocumentBatchType = Static diff --git a/src/types/elasticproxy.ts b/src/types/elasticproxy.ts index c6d67cd..44a63d2 100644 --- a/src/types/elasticproxy.ts +++ b/src/types/elasticproxy.ts @@ -1,4 +1,4 @@ -import { Static, Type } from '@sinclair/typebox' +import { Static, Type } from '@sinclair/typebox'; export const ElasticProxyResponseItem = Type.Object({ took: Type.Number(), @@ -7,21 +7,21 @@ export const ElasticProxyResponseItem = Type.Object({ 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 export const ElasticProxyJsonResponse = Type.Object({ took: Type.Number(), hits: Type.Object(Type.Unknown()), responses: Type.Array(ElasticProxyResponseItem), -}) +}); export type ElasticProxyJsonResponseType = Static export const PartialDrupalNode = Type.Object({ @@ -31,5 +31,5 @@ export const PartialDrupalNode = Type.Object({ 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 7f82521..ffb15d4 100644 --- a/src/types/environment.ts +++ b/src/types/environment.ts @@ -1,4 +1,4 @@ -import { Type } from '@sinclair/typebox' +import { Type } from '@sinclair/typebox'; export enum Environment { PRODUCTION = 'production', @@ -7,4 +7,4 @@ export enum Environment { 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..267ee1d 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -1,8 +1,8 @@ -import { Static, Type } from '@sinclair/typebox' +import { Static, Type } from '@sinclair/typebox'; export const Generic500Error = Type.Object({ email: Type.Optional(Type.String()), error: Type.Optional(Type.String()), -}) +}); export type Generic500ErrorType = Static diff --git a/src/types/mailer.ts b/src/types/mailer.ts index 5889a8b..d3bb032 100644 --- a/src/types/mailer.ts +++ b/src/types/mailer.ts @@ -1,5 +1,5 @@ -import { Transporter } from 'nodemailer' -import { Static, Type } from '@sinclair/typebox' +import { Transporter } from 'nodemailer'; +import { Static, Type } from '@sinclair/typebox'; export interface FastifyMailerNamedInstance { [namespace: string]: Transporter; @@ -11,13 +11,13 @@ 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/siteConfig.ts b/src/types/siteConfig.ts index dc860ab..88aca5f 100644 --- a/src/types/siteConfig.ts +++ b/src/types/siteConfig.ts @@ -1,23 +1,23 @@ -import { Static, Type } from '@sinclair/typebox' +import { 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 export const SiteSubscriptionSettings = Type.Object({ maxAge: Type.Number(), unconfirmedMaxAge: Type.Number(), expiryNotificationDays: Type.Number(), -}) +}); export type SiteSubscriptionSettingsType = Static export const SiteMailSettings = Type.Object({ templatePath: Type.String(), -}) +}); export type SiteMailSettingsType = Static export const SiteEnvironmentConfig = Type.Object({ @@ -25,12 +25,12 @@ export const SiteEnvironmentConfig = Type.Object({ subscription: SiteSubscriptionSettings, mail: SiteMailSettings, elasticProxyUrl: Type.String(), -}) +}); export type SiteEnvironmentConfigType = Static export const SiteConfigurationFile = Type.Object({ name: Type.String(), -}, { additionalProperties: SiteEnvironmentConfig }) +}, { additionalProperties: SiteEnvironmentConfig }); export type SiteConfigurationFileType = Static export const SiteConfiguration = Type.Object({ @@ -40,7 +40,7 @@ export const SiteConfiguration = Type.Object({ subscription: SiteSubscriptionSettings, mail: SiteMailSettings, elasticProxyUrl: Type.String(), -}) +}); export type SiteConfigurationType = Static -export const SiteConfigurationMap = Type.Record(Type.String(), SiteConfiguration) +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 03d1741..6de66af 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -1,17 +1,17 @@ -import { Static, Type } from '@sinclair/typebox' +import { Static, Type } from '@sinclair/typebox'; export enum SubscriptionStatus { DISABLED = 2, ACTIVE = 1, 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 SubscriptionCollection = Type.Object({ @@ -27,7 +27,7 @@ export const SubscriptionCollection = Type.Object({ last_checked: Type.Optional(Type.Number()), expiry_notification_sent: Type.Enum(SubscriptionStatus), status: Type.Enum(SubscriptionStatus) -}) +}); export type SubscriptionCollectionType = Static // MongoDB response when inserting: @@ -36,7 +36,7 @@ export const SubscriptionResponse = Type.Object({ // This is actually MongoDB's ObjectId object: insertedId: Type.Optional(Type.Unknown()), -}) +}); export type SubscriptionResponseType = Static // Request to add new subscription: @@ -47,13 +47,13 @@ export const SubscriptionRequest = Type.Object({ search_description: Type.Optional(Type.String()), site_id: Type.String(), lang: SubscriptionCollectionLanguage -}) +}); export type SubscriptionRequestType = Static // Generic request with SubscriptionId export const SubscriptionGenericPostRequest = Type.Object({ id: Type.String() -}) +}); export type SubscriptionGenericPostRequestType = Static // Generic response with id and status code @@ -61,5 +61,5 @@ export const SubscriptionGenericPostResponse = Type.Object({ id: Type.Optional(Type.String()), statusCode: Type.Number(), statusMessage: Type.Optional(Type.String()) -}) +}); export type SubscriptionGenericPostResponseType = Static From e9d6ca4b0eac638079316090c241164fc4f27951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 2 Oct 2025 15:11:21 +0300 Subject: [PATCH 036/228] Add Biome instead of eslint --- .eslintignore | 28 - .eslintrc.json | 113 - biome.json | 33 + package-lock.json | 4061 +++++---------------------- package.json | 11 +- src/app.ts | 28 +- src/bin/hav-init-mongodb.ts | 42 +- src/bin/hav-migrate-site-id.ts | 67 +- src/bin/hav-populate-email-queue.ts | 192 +- src/bin/hav-send-emails-in-queue.ts | 80 +- src/bin/hav-update-schema.ts | 31 +- src/lib/email.ts | 78 +- src/lib/siteConfigurationLoader.ts | 30 +- src/plugins/atv.ts | 64 +- src/plugins/base64.ts | 9 +- src/plugins/elasticproxy.ts | 23 +- src/plugins/localizedenvvar.ts | 8 +- src/plugins/mailer.ts | 16 +- src/plugins/mongodb.ts | 10 +- src/plugins/randhash.ts | 3 +- src/plugins/sensible.ts | 2 +- src/plugins/sentry.ts | 8 +- src/plugins/token.ts | 4 +- src/routes/addSubscription.ts | 180 +- src/routes/confirmSubscription.ts | 98 +- src/routes/deleteSubscription.ts | 110 +- src/routes/healthzAndReadiness.ts | 125 +- src/routes/root.ts | 2 +- src/types/atv.ts | 14 +- src/types/elasticproxy.ts | 46 +- src/types/error.ts | 8 +- src/types/mailer.ts | 8 +- src/types/siteConfig.ts | 25 +- src/types/subscription.ts | 30 +- 34 files changed, 1366 insertions(+), 4221 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.json create mode 100644 biome.json diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 5aaa1ac..0000000 --- a/.eslintignore +++ /dev/null @@ -1,28 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Coverage -coverage/ - -# Test files (optional - remove if you want to lint tests) -test/ - -# Logs -*.log - -# Environment files -.env -.env.* - -# IDE -.vscode/ -.idea/ - -# Git -.git/ - -# Generated type definitions -process-env.d.ts diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index d64660f..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "root": true, - "env": { - "node": true, - "es6": true - }, - "extends": [ - "airbnb-base", - "prettier" - ], - "parserOptions": { - "ecmaVersion": 2022 - }, - "rules": { - "prefer-arrow-callback": ["off"], - "quotes": ["warn", "single"], - "semi": ["error", "always"], - "consistent-return": ["off"], - "no-underscore-dangle": ["off"], - "import/no-extraneous-dependencies": ["error", {"devDependencies": true}], - "max-nested-callbacks": ["warn", 5], - "no-plusplus": [ - "warn", - { - "allowForLoopAfterthoughts": true - } - ], - "no-param-reassign": ["off"], - "no-prototype-builtins": ["off"], - "valid-jsdoc": [ - "warn", - { - "prefer": { - "returns": "return", - "property": "prop" - }, - "requireReturn": false - } - ], - "no-unused-vars": ["warn"], - "operator-linebreak": [ - "error", - "after", - { "overrides": { "?": "ignore", ":": "ignore" } } - ], - "no-console": ["warn", { "allow": ["info", "debug", "warn", "error"] }] - }, - "settings": { - "import/resolver": { - "node": { - "extensions": [".js", ".mjs", ".ts"], - "moduleDirectory": ["node_modules"] - } - } - }, - "overrides": [ - { - "files": ["*.ts"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "project": "./tsconfig.json", - "warnOnUnsupportedTypeScriptVersion": true - }, - "plugins": ["@typescript-eslint"], - "rules": { - "default-case": "off", - "no-dupe-class-members": "off", - "no-undef": "off", - "no-array-constructor": "off", - "no-redeclare": "off", - "no-use-before-define": "off", - "@typescript-eslint/consistent-type-assertions": "warn", - "@typescript-eslint/no-array-constructor": "warn", - "@typescript-eslint/no-redeclare": "warn", - "@typescript-eslint/no-use-before-define": [ - "warn", - { - "functions": false, - "classes": false, - "variables": false, - "typedefs": false - } - ], - "no-unused-expressions": "off", - "@typescript-eslint/no-unused-expressions": [ - "error", - { - "allowShortCircuit": true, - "allowTernary": true, - "allowTaggedTemplates": true - } - ], - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "args": "none", - "ignoreRestSiblings": true - } - ], - "no-useless-constructor": "off", - "no-console": ["warn", { "allow": ["warn", "error", "info", "debug"] }], - "camelcase": "off", - "@typescript-eslint/no-useless-constructor": "warn", - "import/extensions": 0, - "import/no-unresolved": 0, - "import/prefer-default-export": 0 - } - } - ] -} 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/package-lock.json b/package-lock.json index 56e2d53..b96c453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,17 +27,12 @@ "sprightly": "^2.0.1" }, "devDependencies": { + "@biomejs/biome": "^2.2.4", "@types/jsdom": "^21.1.6", "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", "concurrently": "^8.2.2", - "eslint": "^8.57.0", - "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.29.1", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", "typescript": "^5.2.2" @@ -61,128 +56,179 @@ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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, - "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=14.21.3" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "opencollective", + "url": "https://opencollective.com/biome" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "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/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "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": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=14.21.3" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "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, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=14.21.3" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "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": ">= 4" + "node": ">=14.21.3" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "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/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "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, - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "*" + "node": ">=14.21.3" } }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=12" } }, "node_modules/@fastify/ajv-compiler": { @@ -249,63 +295,6 @@ "@sinclair/typebox": ">=0.26 <=0.32" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true - }, "node_modules/@immobiliarelabs/fastify-sentry": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@immobiliarelabs/fastify-sentry/-/fastify-sentry-8.0.2.tgz", @@ -369,47 +358,6 @@ "sparse-bitfield": "^3.0.3" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true - }, "node_modules/@sentry-internal/tracing": { "version": "7.109.0", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.109.0.tgz", @@ -524,12 +472,6 @@ "parse5": "^7.0.0" } }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, "node_modules/@types/node": { "version": "20.11.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.4.tgz", @@ -576,371 +518,105 @@ "@types/webidl-conversions": "*" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", - "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", - "dev": true, + "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": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/type-utils": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "event-target-shim": "^5.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.45.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">=6.5" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", - "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">=0.4.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", - "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.45.0", - "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", - "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", - "dev": true, + "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==", "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0" + "ajv": "^8.0.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "peerDependencies": { + "ajv": "^8.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", - "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", - "dev": true, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": ">=8" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", - "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", - "dev": true, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "color-convert": "^2.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", - "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", - "dev": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", - "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", - "dev": true, - "dependencies": { - "@typescript-eslint/project-service": "8.45.0", - "@typescript-eslint/tsconfig-utils": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", - "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", - "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "8.45.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true - }, - "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", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "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==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/anymatch": { @@ -961,137 +637,6 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1105,21 +650,6 @@ "node": ">=8.0.0" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/avvio": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", @@ -1314,24 +844,6 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "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", @@ -1345,31 +857,6 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1509,12 +996,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/confusing-browser-globals": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", - "dev": true - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1595,57 +1076,6 @@ "node": ">=18" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -1691,46 +1121,6 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1756,18 +1146,6 @@ "node": ">=0.3.1" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dotenv": { "version": "16.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", @@ -1817,74 +1195,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1930,35 +1240,6 @@ "node": ">= 0.4" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1967,1337 +1248,349 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, + "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==", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=6" } }, - "node_modules/eslint-config-airbnb-base": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", - "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", - "dev": true, - "dependencies": { - "confusing-browser-globals": "^1.0.10", - "object.assign": "^4.1.2", - "object.entries": "^1.1.5", - "semver": "^6.3.0" - }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "engines": { - "node": "^10.12.0 || >=12.0.0" - }, - "peerDependencies": { - "eslint": "^7.32.0 || ^8.2.0", - "eslint-plugin-import": "^2.25.2" + "node": ">=0.8.x" } }, - "node_modules/eslint-config-airbnb-base/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } + "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/eslint-config-prettier": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", - "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } + "node_modules/fast-copy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", + "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, + "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==", "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "@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", + "rfdc": "^1.2.0" } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "fast-decode-uri-component": "^1.0.1" } }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, + "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": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + "node": ">=6" } }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } + "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==" }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } + "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==" }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.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" } }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "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==", "dependencies": { - "brace-expansion": "^1.1.7" + "@fastify/deepmerge": "^1.2.0", + "chalk": "^4.1.2", + "chokidar": "^3.5.2", + "close-with-grace": "^1.1.0", + "commist": "^3.0.0", + "dotenv": "^16.0.0", + "fastify": "^4.0.0", + "fastify-plugin": "^4.0.0", + "generify": "^4.0.0", + "help-me": "^4.0.1", + "is-docker": "^2.0.0", + "make-promises-safe": "^5.1.0", + "pino-pretty": "^10.1.0", + "pkg-up": "^3.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.5", + "yargs-parser": "^21.1.1" }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "bin": { - "semver": "bin/semver.js" + "fastify": "cli.js" } }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, + "node_modules/fastify-mailer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/fastify-mailer/-/fastify-mailer-2.3.1.tgz", + "integrity": "sha512-SKMkgws+nYXLW1wwZuStxfRZ/y+QFfAfIakv70VSyqxm8uHqelbDX8p8d7bFUNOTH8gg5VeQnXymrKjKD6hOJw==", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "fastify-plugin": "^3.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=10" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "nodemailer": ">=6.0.0" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "node_modules/fastify-mailer/node_modules/fastify-plugin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.1.tgz", + "integrity": "sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==" }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } + "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==" }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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==", "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/eslint/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==", - "dev": true, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "reusify": "^1.0.4" } }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, + "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": { - "is-glob": "^4.0.3" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" + "node": ">=8" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/eslint/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==", - "dev": true, + "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==", + "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=14" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", "dependencies": { - "brace-expansion": "^1.1.7" + "locate-path": "^3.0.0" }, "engines": { - "node": "*" + "node": ">=6" } }, - "node_modules/eslint/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==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, + "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==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=4.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/eslint/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==", - "dev": true, + "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": { - "p-limit": "^3.0.2" + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=10" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/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==", - "dev": true, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "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": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 6" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "engines": { - "node": ">=0.10" + "node": ">= 0.6" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "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==", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, - "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/fast-copy": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", - "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "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==", - "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", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "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==" - }, - "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==" - }, - "node_modules/fastify": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.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" - } - }, - "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==", - "dependencies": { - "@fastify/deepmerge": "^1.2.0", - "chalk": "^4.1.2", - "chokidar": "^3.5.2", - "close-with-grace": "^1.1.0", - "commist": "^3.0.0", - "dotenv": "^16.0.0", - "fastify": "^4.0.0", - "fastify-plugin": "^4.0.0", - "generify": "^4.0.0", - "help-me": "^4.0.1", - "is-docker": "^2.0.0", - "make-promises-safe": "^5.1.0", - "pino-pretty": "^10.1.0", - "pkg-up": "^3.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.3.5", - "yargs-parser": "^21.1.1" - }, - "bin": { - "fastify": "cli.js" - } - }, - "node_modules/fastify-mailer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/fastify-mailer/-/fastify-mailer-2.3.1.tgz", - "integrity": "sha512-SKMkgws+nYXLW1wwZuStxfRZ/y+QFfAfIakv70VSyqxm8uHqelbDX8p8d7bFUNOTH8gg5VeQnXymrKjKD6hOJw==", - "dependencies": { - "fastify-plugin": "^3.0.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "nodemailer": ">=6.0.0" - } - }, - "node_modules/fastify-mailer/node_modules/fastify-plugin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.1.tgz", - "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==" - }, - "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==", - "dev": true, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true - }, - "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==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "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": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "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/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/generify": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/generify/-/generify-4.2.0.tgz", - "integrity": "sha512-b4cVhbPfbgbCZtK0dcUc1lASitXGEAIqukV5DDAyWm25fomWnV+C+a1yXvqikcRZXHN2j0pSDyj3cTfzq8pC7Q==", - "dependencies": { - "isbinaryfile": "^4.0.2", - "pump": "^3.0.0", - "split2": "^3.0.0", - "walker": "^1.0.6" - }, - "bin": { - "generify": "generify.js" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "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-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-value": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-3.0.1.tgz", - "integrity": "sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "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" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "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/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", + "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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "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==", + "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", - "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": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/help-me": { + "node_modules/generify": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", - "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", - "dependencies": { - "glob": "^8.0.0", - "readable-stream": "^3.6.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "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", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "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/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "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/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, + "resolved": "https://registry.npmjs.org/generify/-/generify-4.2.0.tgz", + "integrity": "sha512-b4cVhbPfbgbCZtK0dcUc1lASitXGEAIqukV5DDAyWm25fomWnV+C+a1yXvqikcRZXHN2j0pSDyj3cTfzq8pC7Q==", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "isbinaryfile": "^4.0.2", + "pump": "^3.0.0", + "split2": "^3.0.0", + "walker": "^1.0.6" }, - "engines": { - "node": ">= 0.4" - } - }, - "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==", - "engines": { - "node": ">= 0.10" + "bin": { + "generify": "generify.js" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, + "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": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", + "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", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3306,68 +1599,64 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, + "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": { - "has-bigints": "^1.0.2" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/get-value": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-3.0.1.tgz", + "integrity": "sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==", "dependencies": { - "binary-extensions": "^2.0.0" + "isobject": "^3.0.1" }, "engines": { - "node": ">=8" + "node": ">=6.0" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 6" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, + "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": ">= 0.4" }, @@ -3375,32 +1664,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-date-object": { + "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -3408,35 +1684,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "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-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, + "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": { - "call-bound": "^1.0.3" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -3445,248 +1699,199 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, + "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": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/help-me": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", + "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" + "glob": "^8.0.0", + "readable-stream": "^3.6.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "engines": { - "node": ">= 0.4" + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": { + "whatwg-encoding": "^3.1.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "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", + "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", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, "engines": { - "node": ">=0.12.0" + "node": ">= 0.8" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 14" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, + "node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, "engines": { - "node": ">=8" + "node": ">= 14" } }, - "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", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "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/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, + "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": { - "call-bound": "^1.0.3" - }, + "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==", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.10" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, + "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": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "dependencies": { - "which-typed-array": "^1.1.16" - }, + "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.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, + "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==", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, + "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": { - "call-bound": "^1.0.3" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, + "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.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.12.0" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "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", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, "node_modules/isbinaryfile": { "version": "4.0.10", @@ -3764,18 +1969,6 @@ "node": ">=10" } }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsdom": { "version": "24.0.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", @@ -3838,12 +2031,6 @@ "node": ">=18" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, "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", @@ -3857,46 +2044,6 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/light-my-request": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", @@ -3926,12 +2073,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3998,28 +2139,6 @@ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4116,140 +2235,28 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/nodemailer": { - "version": "6.9.9", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz", - "integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==", - "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", - "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, + }, + "node_modules/nodemailer": { + "version": "6.9.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz", + "integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==", "engines": { - "node": ">= 0.4" + "node": ">=6.0.0" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.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.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -4266,40 +2273,6 @@ "wrappy": "1" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -4333,18 +2306,6 @@ "node": ">=6" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -4380,12 +2341,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -4555,24 +2510,6 @@ "node": ">=8" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4631,26 +2568,6 @@ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "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/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -4690,54 +2607,12 @@ "node": ">= 12.13.0" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "dev": true }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4759,26 +2634,6 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -4811,93 +2666,11 @@ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "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/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "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": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -4907,25 +2680,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4945,39 +2699,6 @@ } ] }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safe-regex2": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", @@ -5022,66 +2743,20 @@ "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" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "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==" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.4" + "node": ">=10" } }, + "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==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5115,78 +2790,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "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", @@ -5247,19 +2850,6 @@ "node": ">= 0.8" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5281,62 +2871,6 @@ "node": ">=8" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5348,15 +2882,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5383,18 +2908,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5453,12 +2966,6 @@ "node": "*" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -5535,18 +3042,6 @@ "tree-kill": "cli.js" } }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -5590,48 +3085,12 @@ } } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5644,80 +3103,6 @@ "node": ">= 0.6" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", @@ -5731,24 +3116,6 @@ "node": ">=14.17" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -5893,100 +3260,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 737fbae..c2c848d 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "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", "info": "fastify print-routes ./routes/root.ts", - "lint": "eslint --fix src/ --ext .ts --no-error-on-unmatched-pattern", - "lint:check": "eslint src/ --ext .ts --no-error-on-unmatched-pattern", + "lint": "biome check --write src/", + "lint:check": "biome check src/", "hav:init-mongodb": "node dist/bin/hav-init-mongodb.js", "hav:migrate-site-id": "node dist/bin/hav-migrate-site-id.js", "hav:update-schema": "node dist/bin/hav-update-schema.js", @@ -45,17 +45,12 @@ "sprightly": "^2.0.1" }, "devDependencies": { + "@biomejs/biome": "^2.2.4", "@types/jsdom": "^21.1.6", "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", "concurrently": "^8.2.2", - "eslint": "^8.57.0", - "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.29.1", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", "typescript": "^5.2.2" diff --git a/src/app.ts b/src/app.ts index 5625f2f..b3d60c2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,20 +1,14 @@ -import { join } from 'path'; -import AutoLoad, { AutoloadPluginOptions } from '@fastify/autoload'; -import { FastifyPluginAsync, FastifyServerOptions } from 'fastify'; +import AutoLoad, { type AutoloadPluginOptions } from '@fastify/autoload'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import type { FastifyPluginAsync, FastifyServerOptions } from 'fastify'; +import { join } from 'path'; import { Environment } from './types/environment'; -export interface AppOptions extends FastifyServerOptions, Partial { - -} +export interface AppOptions extends FastifyServerOptions, Partial {} // Pass --options via CLI arguments in command to enable these options. -const options: AppOptions = { -}; +const options: AppOptions = {}; -const app: FastifyPluginAsync = async ( - fastify, - opts -): Promise => { +const app: FastifyPluginAsync = async (fastify, opts): Promise => { if (process.env.ENVIRONMENT === undefined) { throw new Error('ENVIRONMENT environment variable is not set'); } @@ -32,7 +26,7 @@ const app: FastifyPluginAsync = async ( if (!event?.request?.data) { return event; } - + const data = JSON.parse(event.request.data); if (!data.email) { @@ -46,20 +40,20 @@ const app: FastifyPluginAsync = async ( }, environment: env, release, - setErrorHandler: true + setErrorHandler: true, }); await Promise.all([ fastify.register(AutoLoad, { dir: join(__dirname, 'plugins'), options: opts, - ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/ + ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/, }), fastify.register(AutoLoad, { dir: join(__dirname, 'routes'), options: opts, - ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/ - }) + ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/, + }), ]); }; diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 3eff671..0ff9383 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -8,8 +8,8 @@ * Must be run before starting the application to ensure proper database structure. */ -import fastify from 'fastify'; import dotenv from 'dotenv'; +import fastify from 'fastify'; import mongodb from '../plugins/mongodb'; dotenv.config(); @@ -22,10 +22,10 @@ void server.register(mongodb); const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => { try { const db = server.mongo.db!; - + // Check if collections exist const collections = await db.listCollections().toArray(); - const existingCollections = collections.map(c => c.name); + const existingCollections = collections.map((c) => c.name); let queueResult = null; let subscriptionResult = null; @@ -40,17 +40,17 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => required: ['email', 'content'], properties: { _id: { - 'bsonType': 'objectId' + bsonType: 'objectId', }, email: { bsonType: 'string', }, content: { bsonType: 'string', - } - } - } - } + }, + }, + }, + }, }); // eslint-disable-next-line no-console console.log('Queue collection created:', queueResult?.collectionName); @@ -69,7 +69,7 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => required: ['email', 'elastic_query', 'query', 'site_id'], properties: { _id: { - 'bsonType': 'objectId' + bsonType: 'objectId', }, email: { bsonType: 'string', @@ -93,21 +93,21 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => }, status: { bsonType: 'int', - minimum: 0, // 0: unconfirmed, 1: active, 2: expired + minimum: 0, // 0: unconfirmed, 1: active, 2: expired maximum: 2, }, last_checked: { - bsonType: 'int' + bsonType: 'int', }, modified: { - bsonType: 'date' + bsonType: 'date', }, created: { - bsonType: 'date' - } - } - } - } + bsonType: 'date', + }, + }, + }, + }, }); // eslint-disable-next-line no-console console.log('Subscription collection created:', subscriptionResult?.collectionName); @@ -126,14 +126,14 @@ server.ready(async (err) => { console.error('Server failed to start:', err); process.exit(1); } - + // eslint-disable-next-line no-console console.log('Fastify server ready'); - + const result = await initMongoDB(); // eslint-disable-next-line no-console console.log('MongoDB initialization result:', result); - + await server.close(); - process.exit(result.success ? 0 : 1); // Exit with error code if initialization failed + process.exit(result.success ? 0 : 1); // Exit with error code if initialization failed }); diff --git a/src/bin/hav-migrate-site-id.ts b/src/bin/hav-migrate-site-id.ts index 282aae6..566f3b8 100644 --- a/src/bin/hav-migrate-site-id.ts +++ b/src/bin/hav-migrate-site-id.ts @@ -2,8 +2,8 @@ * Migration Script: Add site_id to existing subscription documents */ -import fastify from 'fastify'; import dotenv from 'dotenv'; +import fastify from 'fastify'; import mongodb from '../plugins/mongodb'; dotenv.config(); @@ -13,28 +13,32 @@ const server = fastify({}); void server.register(mongodb); interface MigrationOptions { - defaultSiteId: string - dryRun: boolean - batchSize: number + defaultSiteId: string; + dryRun: boolean; + batchSize: number; } -const migrateSiteId = async (options: MigrationOptions): Promise<{ success: boolean; updated: number; error?: unknown }> => { +const migrateSiteId = async ( + options: MigrationOptions, +): Promise<{ success: boolean; updated: number; error?: unknown }> => { try { const db = server.mongo.db!; const collection = db.collection('subscription'); - + // Find documents without site_id - const documentsWithoutSiteId = await collection.find({ - site_id: { $exists: false } - }).toArray(); - + const documentsWithoutSiteId = await collection + .find({ + site_id: { $exists: false }, + }) + .toArray(); + // eslint-disable-next-line no-console console.log(`Found ${documentsWithoutSiteId.length} documents without site_id`); - + if (documentsWithoutSiteId.length === 0) { return { success: true, updated: 0 }; } - + if (options.dryRun) { // eslint-disable-next-line no-console console.log('DRY RUN - Would update the following documents:'); @@ -44,35 +48,34 @@ const migrateSiteId = async (options: MigrationOptions): Promise<{ success: bool }); return { success: true, updated: 0 }; } - + // Update documents in batches let totalUpdated = 0; - const {batchSize} = options; - + const { batchSize } = options; + for (let i = 0; i < documentsWithoutSiteId.length; i += batchSize) { const batch = documentsWithoutSiteId.slice(i, i + batchSize); - const ids = batch.map(doc => doc._id); - + const ids = batch.map((doc) => doc._id); + // eslint-disable-next-line no-await-in-loop const result = await collection.updateMany( { _id: { $in: ids } }, - { - $set: { + { + $set: { site_id: options.defaultSiteId, - modified: new Date() - } - } + modified: new Date(), + }, + }, ); - + totalUpdated += result.modifiedCount; // eslint-disable-next-line no-console console.log(`Updated batch ${Math.floor(i / batchSize) + 1}: ${result.modifiedCount} documents`); } - + // eslint-disable-next-line no-console console.log(`Migration completed: ${totalUpdated} documents updated with site_id: ${options.defaultSiteId}`); return { success: true, updated: totalUpdated }; - } catch (error) { console.error('Error during migration:', error); return { success: false, updated: 0, error }; @@ -82,10 +85,10 @@ const migrateSiteId = async (options: MigrationOptions): Promise<{ success: bool // CLI argument parsing const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); -const batchSize = parseInt(args.find(arg => arg.startsWith('--batch-size='))?.split('=')[1] || '100', 10); +const batchSize = parseInt(args.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1] || '100', 10); // Get site_id from first argument (required) -const siteId = args.find(arg => !arg.startsWith('--')); +const siteId = args.find((arg) => !arg.startsWith('--')); if (!siteId) { console.error('Error: site_id is required'); console.error('Usage: npm run hav:migrate-site-id [--dry-run] [--batch-size=100]'); @@ -98,7 +101,7 @@ server.ready(async (err) => { console.error('Server failed to start:', err); process.exit(1); } - + // eslint-disable-next-line no-console console.log('Starting site_id migration...'); // eslint-disable-next-line no-console @@ -107,16 +110,16 @@ server.ready(async (err) => { console.log(`Dry run: ${dryRun}`); // eslint-disable-next-line no-console console.log(`Batch size: ${batchSize}`); - + const result = await migrateSiteId({ defaultSiteId: siteId, dryRun, - batchSize + batchSize, }); - + // eslint-disable-next-line no-console console.log('Migration result:', result); - + await server.close(); process.exit(result.success ? 0 : 1); }); diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 0cba4d6..5cbc806 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -1,6 +1,6 @@ -import fastify from 'fastify'; -import dotenv from 'dotenv'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import dotenv from 'dotenv'; +import fastify from 'fastify'; import { expiryEmail, newHitsEmail } from '../lib/email'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; @@ -8,13 +8,14 @@ import base64Plugin from '../plugins/base64'; import elasticproxy from '../plugins/elasticproxy'; import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; -import { - ElasticProxyJsonResponseType, - PartialDrupalNodeType -} from '../types/elasticproxy'; -import { QueueInsertDocumentType } from '../types/mailer'; -import { SiteConfigurationType } from '../types/siteConfig'; -import { SubscriptionCollectionLanguageType, SubscriptionCollectionType, SubscriptionStatus } from '../types/subscription'; +import type { ElasticProxyJsonResponseType, PartialDrupalNodeType } from '../types/elasticproxy'; +import type { QueueInsertDocumentType } from '../types/mailer'; +import type { SiteConfigurationType } from '../types/siteConfig'; +import { + type SubscriptionCollectionLanguageType, + type SubscriptionCollectionType, + SubscriptionStatus, +} from '../types/subscription'; dotenv.config(); @@ -25,7 +26,7 @@ server.register(fastifySentry, { dsn: process.env.SENTRY_DSN, environment: process.env.ENVIRONMENT, release, - setErrorHandler: true + setErrorHandler: true, }); // Register only needed plugins @@ -36,7 +37,10 @@ void server.register(elasticproxy); // eslint-disable-next-line no-void void server.register(base64Plugin); -export const getLocalizedUrl = (siteConfig: SiteConfigurationType, langCode: SubscriptionCollectionLanguageType): string => { +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]; @@ -56,15 +60,19 @@ export const getLocalizedUrl = (siteConfig: SiteConfigurationType, langCode: Sub * @param {string} siteId - the site ID to filter subscriptions * @return {Promise} Promise that resolves when the subscriptions are deleted */ -const massDeleteSubscriptions = async (modifyStatus: SubscriptionStatus, olderThanDays: number, siteId: string): Promise => { +const massDeleteSubscriptions = async ( + 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)); + const dateLimit: Date = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000); try { - await collection.deleteMany({ - status: modifyStatus, + await collection.deleteMany({ + status: modifyStatus, site_id: siteId, - created: { $lt: dateLimit } + created: { $lt: dateLimit }, }); } catch (error) { console.error(error); @@ -81,7 +89,10 @@ const massDeleteSubscriptions = async (modifyStatus: SubscriptionStatus, olderTh * @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 => { +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; @@ -94,31 +105,39 @@ const checkShouldSendExpiryNotification = (subscription: Partial= subscriptionExpiryNotificationSentAt.getTime(); }; -const getNewHitsFromElasticsearch = async (subscription: SubscriptionCollectionType & { _id: any }, siteConfig: SiteConfigurationType): Promise => { +const getNewHitsFromElasticsearch = async ( + subscription: SubscriptionCollectionType & { _id: any }, + siteConfig: SiteConfigurationType, +): Promise => { const elasticQuery: string = server.b64decode(subscription.elastic_query); - const lastChecked: number = subscription.last_checked ? subscription.last_checked : Math.floor(new Date().getTime() / 1000); + 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(siteConfig.elasticProxyUrl, elasticQuery); + const elasticResponse: ElasticProxyJsonResponseType = await server.queryElasticProxy( + siteConfig.elasticProxyUrl, + elasticQuery, + ); // Filter out new hits: return (elasticResponse?.hits?.hits ?? []) - .filter((hit: any) => { - 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); - + .filter((hit: any) => { + 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); @@ -138,49 +157,56 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom const queueCollection = server.mongo.db!.collection('queue'); // List of all enabled subscriptions for this site - const result = await collection.find({ - status: SubscriptionStatus.ACTIVE, - site_id: siteConfig.id - }).toArray(); + const result = await collection + .find({ + status: SubscriptionStatus.ACTIVE, + site_id: siteConfig.id, + }) + .toArray(); // Process subscriptions sequentially to avoid overwhelming the system await result.reduce(async (previousPromise, subscription) => { await previousPromise; - + const localizedBaseUrl = getLocalizedUrl(siteConfig, subscription.lang); // If subscription should expire soon, send an expiration email if (checkShouldSendExpiryNotification(subscription as Partial, siteConfig)) { - await collection.updateOne( - { _id: subscription._id }, - { $set: { expiry_notification_sent: 1 } } - ); + await collection.updateOne({ _id: subscription._id }, { $set: { expiry_notification_sent: 1 } }); const subscriptionValidForDays = siteConfig.subscription.maxAge; - const subscriptionExpiresAt = new Date(subscription.created).getTime() + (subscriptionValidForDays * 24 * 60 * 60 * 1000); + 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: siteConfig.urls.base + subscription.query, - removal_date: formattedExpiryDate, - remove_link: `${localizedBaseUrl}/hakuvahti/unsubscribe?subscription=${subscription._id}&hash=${subscription.hash}`, - }, siteConfig); + 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}`, + }, + siteConfig, + ); const expiryEmailToQueue: QueueInsertDocumentType = { email: subscription.email, - content: expiryEmailContent + content: expiryEmailContent, }; // Add email to queue await queueCollection.insertOne(expiryEmailToQueue); } - const newHits = await getNewHitsFromElasticsearch(subscription as SubscriptionCollectionType & { _id: any }, siteConfig); + const newHits = await getNewHitsFromElasticsearch( + subscription as SubscriptionCollectionType & { _id: any }, + siteConfig, + ); // No new hits if (newHits.length === 0) { @@ -193,17 +219,21 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom 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 - }, siteConfig); + 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, + }, + siteConfig, + ); const email: QueueInsertDocumentType = { email: subscription.email, - content: emailContent + content: emailContent, }; // Add email to queue @@ -212,11 +242,8 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom // 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 } } - ); - + await collection.updateOne({ _id: subscription._id }, { $set: { last_checked: dateUnixtime } }); + return Promise.resolve(); }, Promise.resolve()); }; @@ -229,7 +256,7 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom const app = async (): Promise<{}> => { const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-populate-email-queue', - status: 'in_progress' + status: 'in_progress', }); try { @@ -237,12 +264,12 @@ const app = async (): Promise<{}> => { console.log('Environment:', process.env.ENVIRONMENT || 'dev'); // eslint-disable-next-line no-console console.log('Loading site configurations...'); - + // Load site configurations const configLoader = SiteConfigurationLoader.getInstance(); await configLoader.loadConfigurations(); const siteConfigs = configLoader.getConfigurations(); - + // eslint-disable-next-line no-console console.log('Loaded configurations for sites:', Object.keys(siteConfigs)); @@ -254,15 +281,14 @@ const app = async (): Promise<{}> => { await processSiteSubscriptions(siteConfig); return Promise.resolve(); }, Promise.resolve()); - } catch (error) { console.error('Configuration loading error:', error); - server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error'}); + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error' }); server.Sentry?.captureException(error); return {}; } - server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok'}); + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok' }); return {}; }; @@ -275,13 +301,13 @@ server.get('/', async function handleRootRequest(request, reply) { // 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(SubscriptionStatus.INACTIVE, siteConfig.subscription.unconfirmedMaxAge, siteId); // Remove expired subscriptions await massDeleteSubscriptions(SubscriptionStatus.ACTIVE, siteConfig.subscription.maxAge, siteId); - + return Promise.resolve(); }, Promise.resolve()); @@ -292,16 +318,18 @@ server.get('/', async function handleRootRequest(request, reply) { server.ready((err) => { // eslint-disable-next-line no-console console.log('fastify server ready'); - server.inject({ - method: 'GET', - url: '/' - }, function handleInjectResponse(injectErr, response) { - if (response) { - // eslint-disable-next-line no-console - console.log(JSON.parse(response.payload)); - } - - server.close(); - }); - + server.inject( + { + method: 'GET', + url: '/', + }, + function handleInjectResponse(injectErr, response) { + if (response) { + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); + } + + server.close(); + }, + ); }); diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index 9f6ae8a..1c54104 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -1,13 +1,13 @@ -import fastify from 'fastify'; -import dotenv from 'dotenv'; +import { ObjectId } from '@fastify/mongodb'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import dotenv from 'dotenv'; +import fastify from 'fastify'; import { JSDOM } from 'jsdom'; -import { ObjectId } from '@fastify/mongodb'; -import mongodb from '../plugins/mongodb'; import atv from '../plugins/atv'; import mailer from '../plugins/mailer'; +import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; -import { AtvDocumentType } from '../types/atv'; +import type { AtvDocumentType } from '../types/atv'; dotenv.config(); @@ -18,7 +18,7 @@ server.register(fastifySentry, { dsn: process.env.SENTRY_DSN, environment: process.env.ENVIRONMENT, release, - setErrorHandler: true + setErrorHandler: true, }); // Register only needed plugins @@ -33,7 +33,7 @@ void server.register(atv); const BATCH_SIZE = 100; const app = async (): Promise<{}> => { - const checkInId = server.Sentry?.captureCheckIn({monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress'}); + const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress' }); if (typeof server.mongo?.db === 'undefined') { console.error('MongoDB connection not working'); @@ -53,7 +53,7 @@ const app = async (): Promise<{}> => { hasMoreResults = false; } else { // Collect email ids as map - const emailIdsMap = new Map(); + const emailIdsMap = new Map(); result.forEach((email) => { emailIdsMap.set(email.email, null); @@ -62,7 +62,7 @@ const app = async (): Promise<{}> => { // Get batch of email documents from ATV const emailIds = [...emailIdsMap.keys()]; // eslint-disable-next-line no-await-in-loop - const emailDocuments:Partial = await server.atvGetDocumentBatch(emailIds); + const emailDocuments: Partial = await server.atvGetDocumentBatch(emailIds); // Update the email map with unencrypted email list if (emailDocuments.length > 0) { @@ -77,7 +77,7 @@ const app = async (): Promise<{}> => { // eslint-disable-next-line no-await-in-loop await result.reduce(async (previousPromise, email) => { await previousPromise; - + const atvId = email.email; const plaintextEmail = emailIdsMap.get(email.email); const dom = new JSDOM(email.content); @@ -92,21 +92,23 @@ const app = async (): Promise<{}> => { 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); - }); + 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) { + } catch (error) { + // Continue even if sending email failed. server.Sentry?.captureException(error); console.error(error); @@ -115,19 +117,19 @@ const app = async (): Promise<{}> => { // 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) }); + 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.'); } - + return Promise.resolve(); }, Promise.resolve()); } } - server.Sentry?.captureCheckIn({checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok'}); + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok' }); return {}; }; @@ -139,16 +141,18 @@ server.get('/', async function handleRootRequest(request, reply) { server.ready((err) => { // eslint-disable-next-line no-console console.log('fastify server ready'); - server.inject({ - method: 'GET', - url: '/' - }, function handleInjectResponse(injectErr, response) { - if (response) { - // eslint-disable-next-line no-console - console.log(JSON.parse(response.payload)); - } - - server.close(); - }); + server.inject( + { + method: 'GET', + url: '/', + }, + function handleInjectResponse(injectErr, response) { + if (response) { + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); + } + server.close(); + }, + ); }); diff --git a/src/bin/hav-update-schema.ts b/src/bin/hav-update-schema.ts index d365ab4..7032d13 100644 --- a/src/bin/hav-update-schema.ts +++ b/src/bin/hav-update-schema.ts @@ -1,12 +1,12 @@ /** * Schema Update Script: Add site_id as required field to subscription collection validator - * + * * This script updates the MongoDB collection validator to make site_id a required field. * Run this AFTER migrating existing documents to have site_id. */ -import fastify from 'fastify'; import dotenv from 'dotenv'; +import fastify from 'fastify'; import mongodb from '../plugins/mongodb'; dotenv.config(); @@ -18,7 +18,7 @@ void server.register(mongodb); const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => { try { const db = server.mongo.db!; - + const result = await db.command({ collMod: 'subscription', validator: { @@ -28,7 +28,7 @@ const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => required: ['email', 'elastic_query', 'query', 'site_id'], properties: { _id: { - 'bsonType': 'objectId' + bsonType: 'objectId', }, email: { bsonType: 'string', @@ -52,27 +52,26 @@ const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => }, status: { bsonType: 'int', - minimum: 0, // 0: unconfirmed, 1: active, 2: expired + minimum: 0, // 0: unconfirmed, 1: active, 2: expired maximum: 2, }, last_checked: { - bsonType: 'int' + bsonType: 'int', }, modified: { - bsonType: 'date' + bsonType: 'date', }, created: { - bsonType: 'date' - } - } - } - } + bsonType: 'date', + }, + }, + }, + }, }); - + // eslint-disable-next-line no-console console.log('Schema updated successfully:', result); return { success: true }; - } catch (error) { console.error('Error updating schema:', error); return { success: false, error }; @@ -84,14 +83,14 @@ server.ready(async (err) => { console.error('Server failed to start:', err); process.exit(1); } - + // eslint-disable-next-line no-console console.log('Updating subscription collection schema to require site_id...'); const result = await updateSchema(); // eslint-disable-next-line no-console console.log('Schema update result:', result); - + await server.close(); process.exit(result.success ? 0 : 1); }); diff --git a/src/lib/email.ts b/src/lib/email.ts index 4e424f0..8aebd92 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,39 +1,59 @@ import { sprightly } from 'sprightly'; -import { SubscriptionCollectionLanguageType } from '../types/subscription'; -import { PartialDrupalNodeType } from '../types/elasticproxy'; -import { SiteConfigurationType } from '../types/siteConfig'; +import type { PartialDrupalNodeType } from '../types/elasticproxy'; +import type { SiteConfigurationType } from '../types/siteConfig'; +import type { SubscriptionCollectionLanguageType } from '../types/subscription'; // Subscription confirmation email -export const confirmationEmail = async (lang: SubscriptionCollectionLanguageType, data: { link: string; }, siteConfig: SiteConfigurationType) => sprightly(`dist/templates/${ siteConfig.mail.templatePath }/confirmation_${ lang }.html`, { - lang, - link: data.link, -}); +export const confirmationEmail = async ( + lang: SubscriptionCollectionLanguageType, + data: { link: string }, + siteConfig: SiteConfigurationType, +) => + sprightly(`dist/templates/${siteConfig.mail.templatePath}/confirmation_${lang}.html`, { + lang, + link: data.link, + }); // Notification before subscription expires -export const expiryEmail = async (lang: SubscriptionCollectionLanguageType, data: { - link: string, - search_description: string, - removal_date: string, - remove_link: string }, siteConfig: SiteConfigurationType) => sprightly(`dist/templates/${ siteConfig.mail.templatePath }/expiry_notification_${ lang }.html`, { - lang, - link: data.link, - search_description: data.search_description, - remove_link: data.remove_link, - removal_date: data.removal_date -}); +export const expiryEmail = async ( + lang: SubscriptionCollectionLanguageType, + data: { + link: string; + search_description: string; + removal_date: string; + remove_link: string; + }, + siteConfig: SiteConfigurationType, +) => + sprightly(`dist/templates/${siteConfig.mail.templatePath}/expiry_notification_${lang}.html`, { + lang, + link: data.link, + search_description: data.search_description, + remove_link: data.remove_link, + removal_date: data.removal_date, + }); // 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 }, siteConfig: SiteConfigurationType) => { +export const newHitsEmail = async ( + lang: SubscriptionCollectionLanguageType, + data: { + hits: PartialDrupalNodeType[]; + search_description: string; + search_link: string; + remove_link: string; + created_date: string; + }, + siteConfig: SiteConfigurationType, +) => { try { - const hitsContent = data.hits.map(item => sprightly('dist/templates/link_text.html', { - link: siteConfig.urls.base + 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/${siteConfig.mail.templatePath}/newhits_${lang}.html`, { lang, @@ -41,7 +61,7 @@ export const newHitsEmail = async (lang: SubscriptionCollectionLanguageType, dat search_link: siteConfig.urls.base + data.search_link, remove_link: data.remove_link, search_description: data.search_description, - created_date: data.created_date + created_date: data.created_date, }); } catch (error) { console.error(error); diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts index 28501d0..fc8db35 100644 --- a/src/lib/siteConfigurationLoader.ts +++ b/src/lib/siteConfigurationLoader.ts @@ -1,9 +1,13 @@ import * as fs from 'fs'; import * as path from 'path'; -import { SiteConfigurationType, SiteConfigurationMapType, SiteConfigurationFileType, SiteEnvironmentConfigType } from '../types/siteConfig'; +import type { + SiteConfigurationFileType, + SiteConfigurationMapType, + SiteConfigurationType, + SiteEnvironmentConfigType, +} from '../types/siteConfig'; export class SiteConfigurationLoader { - private static instance: SiteConfigurationLoader; private configurations: SiteConfigurationMapType = {}; @@ -28,13 +32,12 @@ export class SiteConfigurationLoader { 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')); + 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'); @@ -44,11 +47,11 @@ export class SiteConfigurationLoader { 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}`); } @@ -62,7 +65,7 @@ export class SiteConfigurationLoader { if (!this.validateEnvironmentConfiguration(envConfig)) { throw new Error(`Invalid environment configuration for '${environment}' in ${filePath}`); } - + // Flatten to runtime configuration this.configurations[siteId] = { id: siteId, @@ -70,7 +73,7 @@ export class SiteConfigurationLoader { urls: envConfig.urls, subscription: envConfig.subscription, mail: envConfig.mail, - elasticProxyUrl: envConfig.elasticProxyUrl + elasticProxyUrl: envConfig.elasticProxyUrl, }; } catch (error) { throw new Error(`Failed to load configuration from ${filePath}: ${error}`); @@ -110,7 +113,6 @@ export class SiteConfigurationLoader { return Object.keys(this.configurations); } - /** * Validates that a raw configuration file has required properties * @param {unknown} config - The configuration object to validate @@ -122,14 +124,14 @@ export class SiteConfigurationLoader { 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'); + const envKeys = Object.keys(configObj).filter((key) => key !== 'name'); return envKeys.length > 0; } @@ -144,6 +146,6 @@ export class SiteConfigurationLoader { return false; } const required = ['urls', 'subscription', 'mail', 'elasticProxyUrl']; - return required.every(prop => prop in config); + return required.every((prop) => prop in config); } } diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 3558cb4..9bff913 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -1,14 +1,10 @@ -import fp from 'fastify-plugin'; -import axios, { AxiosResponse } from 'axios'; +import axios, { type AxiosResponse } from 'axios'; import type { FastifyRequest as FastifyRequestType } from 'fastify'; -import { - AtvDocumentBatchType, - AtvDocumentType, - AtvResponseType } from '../types/atv'; -import { SubscriptionRequestType } from '../types/subscription'; +import fp from 'fastify-plugin'; +import type { AtvDocumentBatchType, AtvDocumentType, AtvResponseType } from '../types/atv'; +import type { SubscriptionRequestType } from '../types/subscription'; -export interface AtvPluginOptions { -} +export type AtvPluginOptions = {}; /** * Fetches content by document id from the ATV API. @@ -18,17 +14,19 @@ export interface AtvPluginOptions { */ 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 - } - }); + 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; - } - throw new Error('Empty content returned from API'); - + } + throw new Error('Empty content returned from API'); } catch (error: unknown) { console.error(error); @@ -53,13 +51,13 @@ const atvCreateDocumentWithEmail = async (email: string): Promise = { - '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: 'atvCreateDocumentWithEmail', + tos_record_id: timestamp, + delete_after: deleteAfter.toISOString().substring(0, 10), + content: JSON.stringify({ + email: email, + }), }; const response: AxiosResponse> = await axios.post( @@ -68,9 +66,9 @@ const atvCreateDocumentWithEmail = async (email: string): Promise> => { try { const documentObject: AtvDocumentBatchType = { - document_ids: emails + document_ids: emails, }; const response: AxiosResponse> = await axios.post( @@ -99,9 +97,9 @@ const atvGetDocumentBatch = async (emails: string[]): Promise { }; 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,}))$/; + 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()); }; @@ -172,7 +171,6 @@ export default fp(async (fastify, opts) => { fastify.decorate('atvGetDocumentBatch', async function atvGetDocumentBatchHandler(emails: string[]) { return atvGetDocumentBatch(emails); }); - }); declare module 'fastify' { diff --git a/src/plugins/base64.ts b/src/plugins/base64.ts index e5e25f4..67d2723 100644 --- a/src/plugins/base64.ts +++ b/src/plugins/base64.ts @@ -1,14 +1,13 @@ -import fp from 'fastify-plugin'; import { Buffer } from '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 = {}; -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); diff --git a/src/plugins/elasticproxy.ts b/src/plugins/elasticproxy.ts index f0ebe95..f779bcf 100644 --- a/src/plugins/elasticproxy.ts +++ b/src/plugins/elasticproxy.ts @@ -1,12 +1,11 @@ import axios from 'axios'; import fp from 'fastify-plugin'; import https from 'https'; -import { ElasticProxyJsonResponseType } from '../types/elasticproxy'; +import type { ElasticProxyJsonResponseType } from '../types/elasticproxy'; // Query Elastic Proxy -export interface ElasticProxyPluginOptions { -} +export type ElasticProxyPluginOptions = {}; /** * Sends a query to the ElasticSearch proxy. @@ -14,13 +13,17 @@ export interface ElasticProxyPluginOptions { * @param {string} elasticQueryJson - The JSON string representing the ElasticSearch query. * @return {Promise} The response data from the ElasticSearch proxy. */ -const queryElasticProxy = async (elasticProxyBaseUrl: string, elasticQueryJson: string): Promise => { +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 = elasticProxyBaseUrl + (elasticQueryJson.startsWith('{}\n') ? '/_msearch' : '/_search'); + const elasticProxyUrl: string = + elasticProxyBaseUrl + (elasticQueryJson.startsWith('{}\n') ? '/_msearch' : '/_search'); const contentType: string = elasticQueryJson.startsWith('{}\n') ? 'application/x-ndjson' : 'application/json'; try { @@ -36,12 +39,12 @@ const queryElasticProxy = async (elasticProxyBaseUrl: string, elasticQueryJson: elasticQueryJson + (elasticQueryJson.endsWith('\n') ? '' : '\n'), { httpsAgent: new https.Agent({ - rejectUnauthorized + rejectUnauthorized, }), headers: { - 'Content-Type': contentType - } - } + 'Content-Type': contentType, + }, + }, ); return response.data; @@ -58,6 +61,6 @@ export default fp(async (fastify, opts) => { declare module 'fastify' { export interface FastifyInstance { - queryElasticProxy(elasticProxyBaseUrl: string, elasticQueryJson: string): Promise + queryElasticProxy(elasticProxyBaseUrl: string, elasticQueryJson: string): Promise; } } diff --git a/src/plugins/localizedenvvar.ts b/src/plugins/localizedenvvar.ts index 7e2b73d..9d6d1ce 100644 --- a/src/plugins/localizedenvvar.ts +++ b/src/plugins/localizedenvvar.ts @@ -1,10 +1,10 @@ import fp from 'fastify-plugin'; -import { SubscriptionCollectionLanguageType } from '../types/subscription'; +import type { SubscriptionCollectionLanguageType } from '../types/subscription'; -export interface localizedEnvVarPluginPluginOptions { -} +export type localizedEnvVarPluginPluginOptions = {}; -export const localizedEnvVar = (envVarBase: string, langCode: SubscriptionCollectionLanguageType): string | undefined => 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); diff --git a/src/plugins/mailer.ts b/src/plugins/mailer.ts index 7ee4687..c24e4d3 100644 --- a/src/plugins/mailer.ts +++ b/src/plugins/mailer.ts @@ -1,23 +1,23 @@ +import type { FastifyInstance } from 'fastify'; import fp from 'fastify-plugin'; -import { FastifyInstance } from 'fastify'; -import { FastifyMailer } from '../types/mailer'; +import type { FastifyMailer } from '../types/mailer'; // Initialize mailer as plugin export default fp(async function mailerPlugin(fastify: FastifyInstance) { 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'), + 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, + }, + }, }; // eslint-disable-next-line global-require diff --git a/src/plugins/mongodb.ts b/src/plugins/mongodb.ts index 973e0fa..4697ec1 100644 --- a/src/plugins/mongodb.ts +++ b/src/plugins/mongodb.ts @@ -1,12 +1,12 @@ -import fp from 'fastify-plugin'; import mongo from '@fastify/mongodb'; -import { FastifyInstance } from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; // MongoDB connection export default fp(async function mongodbPlugin(fastify: FastifyInstance) { - fastify.register(mongo, { - url: process.env.MONGODB, - forceClose: true + fastify.register(mongo, { + url: process.env.MONGODB, + forceClose: true, }); }); diff --git a/src/plugins/randhash.ts b/src/plugins/randhash.ts index 9657ecf..00c7e8d 100644 --- a/src/plugins/randhash.ts +++ b/src/plugins/randhash.ts @@ -2,8 +2,7 @@ import fp from 'fastify-plugin'; // Helper plugin for random hash -export interface RandHashPluginOptions { -} +export type RandHashPluginOptions = {}; export default fp(async (fastify, opts) => { fastify.decorate('getRandHash', function getRandHash() { diff --git a/src/plugins/sensible.ts b/src/plugins/sensible.ts index b16ec7c..42787c3 100644 --- a/src/plugins/sensible.ts +++ b/src/plugins/sensible.ts @@ -1,5 +1,5 @@ +import sensible, { type SensibleOptions } from '@fastify/sensible'; import fp from 'fastify-plugin'; -import sensible, { SensibleOptions } from '@fastify/sensible'; // This plugins adds some utilities to handle http errors diff --git a/src/plugins/sentry.ts b/src/plugins/sentry.ts index 2b27c1c..2194f20 100644 --- a/src/plugins/sentry.ts +++ b/src/plugins/sentry.ts @@ -1,8 +1,8 @@ // eslint-disable-next-line import/no-extraneous-dependencies -import * as Sentry from '@sentry/node'; +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 index b31958b..4850549 100644 --- a/src/plugins/token.ts +++ b/src/plugins/token.ts @@ -13,7 +13,7 @@ export default fp(async (fastify, opts) => { reply .code(403) .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Authentication failed.'}); + .send({ error: 'Authentication failed.' }); } // TODO: Do something with the token @@ -24,6 +24,6 @@ export default fp(async (fastify, opts) => { declare module 'fastify' { export interface FastifyRequest { - tokenAuthentication?: boolean + tokenAuthentication?: boolean; } } diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index 12a854a..aa07ca8 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -1,114 +1,102 @@ -import { - FastifyPluginAsync, - FastifyRequest, - FastifyReply, - FastifyInstance -} from 'fastify'; - -import { - SubscriptionResponse, - SubscriptionResponseType, - SubscriptionCollectionType, - SubscriptionRequest, - SubscriptionRequestType, - SubscriptionStatus -} from '../types/subscription'; - -import { - Generic500Error, - Generic500ErrorType -} from '../types/error'; - +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; import { confirmationEmail } from '../lib/email'; -import { QueueInsertDocumentType } from '../types/mailer'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; +import type { QueueInsertDocumentType } from '../types/mailer'; +import { + type SubscriptionCollectionType, + SubscriptionRequest, + type SubscriptionRequestType, + SubscriptionResponse, + type SubscriptionResponseType, + SubscriptionStatus, +} from '../types/subscription'; // Add subscription to given query parameters -const subscription: FastifyPluginAsync = async ( - fastify: FastifyInstance, - opts: object -): Promise => { +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 - } - } - }, async ( - request: FastifyRequest<{ Body: SubscriptionRequestType }>, - reply: FastifyReply - ) => { - const mongodb = fastify.mongo; - const collection = mongodb.db?.collection('subscription'); - const hash = fastify.getRandHash(); + Body: SubscriptionRequestType; + Reply: SubscriptionResponseType | Generic500ErrorType; + }>( + '/subscription', + { + schema: { + body: SubscriptionRequest, + response: { + 200: SubscriptionResponse, + 500: Generic500Error, + }, + }, + }, + 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; - // 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; + // Load site configuration + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfig = configLoader.getConfiguration(request.body.site_id); - // 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.' }); - } + if (!siteConfig) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid site_id provided.' }); + } + + // Subscription data that goes to collection + const subscriptionData: Partial = { + ...request.body, + hash, + created: new Date(), + modified: new Date(), + last_checked: Math.floor(Date.now() / 1000), + expiry_notification_sent: SubscriptionStatus.INACTIVE, + status: SubscriptionStatus.INACTIVE, + }; - // Subscription data that goes to collection - const subscriptionData: Partial = { - ...request.body, - 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(subscriptionData); + if (!response) { + fastify.log.debug(response); - const response = await collection?.insertOne(subscriptionData); - if (!response) { - fastify.log.debug(response); + throw new Error('Adding new subscription failed. See logs.'); + } - throw new Error('Adding new subscription failed. See logs.'); - } - - // Insert email in queue - const langKey = request.body.lang.toLowerCase() as keyof typeof siteConfig.urls; - const subscribeLinkBase = (langKey in siteConfig.urls) ? siteConfig.urls[langKey] : siteConfig.urls.base; - const emailContent = await confirmationEmail(request.body.lang, { - link: `${subscribeLinkBase }/hakuvahti/confirm?subscription=${response.insertedId}&hash=${hash}` - }, siteConfig); + // Insert email in queue + const langKey = request.body.lang.toLowerCase() as keyof typeof siteConfig.urls; + const subscribeLinkBase = langKey in siteConfig.urls ? siteConfig.urls[langKey] : siteConfig.urls.base; + const emailContent = await confirmationEmail( + request.body.lang, + { + link: `${subscribeLinkBase}/hakuvahti/confirm?subscription=${response.insertedId}&hash=${hash}`, + }, + siteConfig, + ); - // Email data to queue - const email:QueueInsertDocumentType = { - email: request.body.email, - content: emailContent - }; + // Email data to queue + const email: QueueInsertDocumentType = { + email: request.body.email, + content: emailContent, + }; - const q = mongodb.db?.collection('queue'); - await q?.insertOne(email); + const q = mongodb.db?.collection('queue'); + await q?.insertOne(email); - fastify.log.debug(emailContent); + fastify.log.debug(emailContent); - return reply - .code(200) - .header('Content-Type', 'application/json; charset=utf-8') - .send(response); - }); + 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 f628072..ba849e2 100644 --- a/src/routes/confirmSubscription.ts +++ b/src/routes/confirmSubscription.ts @@ -1,74 +1,54 @@ -import { - FastifyPluginAsync, - FastifyReply, - FastifyInstance, - FastifyRequest -} from 'fastify'; - import { ObjectId } from '@fastify/mongodb'; -import { - Generic500Error, - Generic500ErrorType -} from '../types/error'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; -import { - SubscriptionGenericPostResponse, - SubscriptionGenericPostResponseType, - SubscriptionStatus +import { + SubscriptionGenericPostResponse, + type SubscriptionGenericPostResponseType, + SubscriptionStatus, } from '../types/subscription'; - // Confirms subscription - -const confirmSubscription: FastifyPluginAsync = async ( - fastify: FastifyInstance, - opts: object -): Promise => { + +const confirmSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, opts: object): Promise => { 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 } = request.params as { id: string, hash: string }; + 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 } = request.params as { id: string; hash: string }; - const subscription = await collection?.findOne({ - _id: new ObjectId(id), - hash, - status: SubscriptionStatus.INACTIVE - }); + const subscription = await collection?.findOne({ + _id: new ObjectId(id), + hash, + status: SubscriptionStatus.INACTIVE, + }); - if (!subscription) { - return reply - .code(404) - .send({ - statusCode: 404, - statusMessage: 'Subscription not found.' + if (!subscription) { + return reply.code(404).send({ + statusCode: 404, + statusMessage: 'Subscription not found.', }); - } + } - await collection!.updateOne( - { _id: new ObjectId(id) }, - { $set: { status: SubscriptionStatus.ACTIVE } }, - ); + await collection!.updateOne({ _id: new ObjectId(id) }, { $set: { status: SubscriptionStatus.ACTIVE } }); - return reply - .code(200) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ + return reply.code(200).header('Content-Type', 'application/json; charset=utf-8').send({ statusCode: 200, - statusMessage: 'Subscription enabled.' + statusMessage: 'Subscription enabled.', }); - }); + }, + ); }; - + export default confirmSubscription; diff --git a/src/routes/deleteSubscription.ts b/src/routes/deleteSubscription.ts index 4790b89..f424730 100644 --- a/src/routes/deleteSubscription.ts +++ b/src/routes/deleteSubscription.ts @@ -1,84 +1,64 @@ -import { - FastifyPluginAsync, - FastifyReply, - FastifyInstance, - FastifyRequest -} from 'fastify'; - import { ObjectId } from '@fastify/mongodb'; -import { - Generic500Error, - Generic500ErrorType -} from '../types/error'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; -import { - SubscriptionGenericPostResponse, - SubscriptionGenericPostResponseType -} from '../types/subscription'; +import { SubscriptionGenericPostResponse, type SubscriptionGenericPostResponseType } from '../types/subscription'; // Deletes subscription -const deleteSubscription: FastifyPluginAsync = async ( - fastify: FastifyInstance, - opts: object -): Promise => { +const deleteSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, opts: object): Promise => { fastify.delete<{ - Reply: SubscriptionGenericPostResponseType | Generic500ErrorType - }>('/subscription/delete/: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 } = request.params as { id: string, hash: string }; + Reply: SubscriptionGenericPostResponseType | Generic500ErrorType; + }>( + '/subscription/delete/: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 } = request.params as { id: string; hash: string }; - // Check that subscription exists and hash matches - const subscription = await collection?.findOne({ - _id: new ObjectId(id), - hash - }); + // Check that subscription exists and hash matches + const subscription = await collection?.findOne({ + _id: new ObjectId(id), + hash, + }); - if (!subscription) { - return reply - .code(404) - .send({ - statusCode: 404, - statusMessage: 'Subscription not found.' + if (!subscription) { + return reply.code(404).send({ + statusCode: 404, + statusMessage: 'Subscription not found.', }); - } + } - // Delete subscription - const result = await collection?.deleteOne({ _id: new ObjectId(id) }); + // Delete subscription + const result = await collection?.deleteOne({ _id: new ObjectId(id) }); - fastify.log.info({ - level: 'info', - message: 'Subscription deleted', - result - }); + fastify.log.info({ + level: 'info', + message: 'Subscription deleted', + result, + }); - if (result?.deletedCount === 0) { - return reply - .code(404) - .send({ - statusCode: 404, - statusMessage: 'Subscription not found.' + if (result?.deletedCount === 0) { + return reply.code(404).send({ + statusCode: 404, + statusMessage: 'Subscription not found.', }); - } + } - return reply - .code(200) - .send({ + return reply.code(200).send({ statusCode: 200, - message: 'Subscription deleted' + message: 'Subscription deleted', }); - }); + }, + ); }; export default deleteSubscription; diff --git a/src/routes/healthzAndReadiness.ts b/src/routes/healthzAndReadiness.ts index 198bd3a..accbcf4 100644 --- a/src/routes/healthzAndReadiness.ts +++ b/src/routes/healthzAndReadiness.ts @@ -1,83 +1,72 @@ -import { - FastifyPluginAsync, - FastifyReply, - FastifyInstance, - FastifyRequest -} from 'fastify'; +import type { FastifyInstance, 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: FastifyInstance, opts: object): Promise => { + fastify.get( + '/healthz', + { + schema: { + response: { + 200: { + type: 'object', + properties: { + statusCode: { type: 'number' }, + message: { type: 'string' }, + }, + required: ['statusCode', 'message'], }, - required: ['statusCode', 'message'] - } - } - } - }, async ( - request: FastifyRequest, - reply: FastifyReply - ) => 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', + { + 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' + message: 'OK', }); - } catch { - return reply - .code(500) - .send({ + } catch { + return reply.code(500).send({ statusCode: 500, - message: 'MongoDB connection failed' + message: 'MongoDB connection failed', }); - } - }); + } + }, + ); }; export default healthzAndReadiness; diff --git a/src/routes/root.ts b/src/routes/root.ts index f416ad3..d8c0906 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,4 +1,4 @@ -import { FastifyPluginAsync } from 'fastify'; +import type { FastifyPluginAsync } from 'fastify'; const root: FastifyPluginAsync = async (fastify, opts): Promise => { fastify.get('/', async function rootHandler(request, reply) { diff --git a/src/types/atv.ts b/src/types/atv.ts index e428e51..31df38b 100644 --- a/src/types/atv.ts +++ b/src/types/atv.ts @@ -1,10 +1,10 @@ -import { Static, Type } from '@sinclair/typebox'; +import { type Static, Type } from '@sinclair/typebox'; export const AtvResponse = Type.Object({ - atvDocumentId: Type.String() + atvDocumentId: Type.String(), }); -export type AtvResponseType = Static +export type AtvResponseType = Static; export const AtvDocument = Type.Object({ id: Type.Optional(Type.String()), @@ -63,13 +63,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/elasticproxy.ts b/src/types/elasticproxy.ts index 44a63d2..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() + 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 type ElasticProxyResponseItemType = Static; export const ElasticProxyResponseHits = Type.Object({ - total: Type.Unknown(), - max_score: Type.Unknown(), - hits: Type.Array(ElasticProxyResponseItem), + total: Type.Unknown(), + max_score: Type.Unknown(), + hits: Type.Array(ElasticProxyResponseItem), }); -export type ElasticProxyResponseHitsType = Static +export type ElasticProxyResponseHitsType = Static; export const ElasticProxyJsonResponse = Type.Object({ - took: Type.Number(), - hits: Type.Object(Type.Unknown()), - responses: Type.Array(ElasticProxyResponseItem), + took: Type.Number(), + hits: Type.Object(Type.Unknown()), + responses: Type.Array(ElasticProxyResponseItem), }); -export type ElasticProxyJsonResponseType = Static +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()) + _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 +export type PartialDrupalNodeType = Static; diff --git a/src/types/error.ts b/src/types/error.ts index 267ee1d..4249009 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -1,8 +1,8 @@ -import { Static, Type } from '@sinclair/typebox'; +import { type Static, Type } from '@sinclair/typebox'; 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 d3bb032..df28cb3 100644 --- a/src/types/mailer.ts +++ b/src/types/mailer.ts @@ -1,5 +1,5 @@ -import { Transporter } from 'nodemailer'; -import { Static, Type } from '@sinclair/typebox'; +import { type Static, Type } from '@sinclair/typebox'; +import type { Transporter } from 'nodemailer'; export interface FastifyMailerNamedInstance { [namespace: string]: Transporter; @@ -13,11 +13,11 @@ export const QueueDocument = Type.Object({ content: Type.String(), }); -export type QueueDocumentType = Static +export type QueueDocumentType = Static; export const QueueInsertDocument = Type.Object({ email: Type.String(), content: Type.String(), }); -export type QueueInsertDocumentType = Static +export type QueueInsertDocumentType = Static; diff --git a/src/types/siteConfig.ts b/src/types/siteConfig.ts index 88aca5f..553381b 100644 --- a/src/types/siteConfig.ts +++ b/src/types/siteConfig.ts @@ -1,4 +1,4 @@ -import { Static, Type } from '@sinclair/typebox'; +import { type Static, Type } from '@sinclair/typebox'; export const SiteLanguageUrls = Type.Object({ base: Type.String(), @@ -6,19 +6,19 @@ export const SiteLanguageUrls = Type.Object({ fi: Type.String(), sv: Type.String(), }); -export type SiteLanguageUrlsType = Static +export type SiteLanguageUrlsType = Static; export const SiteSubscriptionSettings = Type.Object({ maxAge: Type.Number(), unconfirmedMaxAge: Type.Number(), expiryNotificationDays: Type.Number(), }); -export type SiteSubscriptionSettingsType = Static +export type SiteSubscriptionSettingsType = Static; export const SiteMailSettings = Type.Object({ templatePath: Type.String(), }); -export type SiteMailSettingsType = Static +export type SiteMailSettingsType = Static; export const SiteEnvironmentConfig = Type.Object({ urls: SiteLanguageUrls, @@ -26,12 +26,15 @@ export const SiteEnvironmentConfig = Type.Object({ mail: SiteMailSettings, elasticProxyUrl: Type.String(), }); -export type SiteEnvironmentConfigType = Static +export type SiteEnvironmentConfigType = Static; -export const SiteConfigurationFile = Type.Object({ - name: Type.String(), -}, { additionalProperties: SiteEnvironmentConfig }); -export type SiteConfigurationFileType = Static +export const SiteConfigurationFile = Type.Object( + { + name: Type.String(), + }, + { additionalProperties: SiteEnvironmentConfig }, +); +export type SiteConfigurationFileType = Static; export const SiteConfiguration = Type.Object({ id: Type.String(), @@ -41,6 +44,6 @@ export const SiteConfiguration = Type.Object({ mail: SiteMailSettings, elasticProxyUrl: Type.String(), }); -export type SiteConfigurationType = Static +export type SiteConfigurationType = Static; export const SiteConfigurationMap = Type.Record(Type.String(), SiteConfiguration); -export type SiteConfigurationMapType = Static +export type SiteConfigurationMapType = Static; diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 6de66af..777b08e 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -1,18 +1,14 @@ -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 SubscriptionCollectionLanguage = Type.Union([ - Type.Literal('en'), - Type.Literal('fi'), - Type.Literal('sv'), -]); -export type SubscriptionCollectionLanguageType = 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(), @@ -26,9 +22,9 @@ export const SubscriptionCollection = Type.Object({ lang: SubscriptionCollectionLanguage, last_checked: Type.Optional(Type.Number()), expiry_notification_sent: Type.Enum(SubscriptionStatus), - status: Type.Enum(SubscriptionStatus) + status: Type.Enum(SubscriptionStatus), }); -export type SubscriptionCollectionType = Static +export type SubscriptionCollectionType = Static; // MongoDB response when inserting: export const SubscriptionResponse = Type.Object({ @@ -37,7 +33,7 @@ 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({ @@ -46,20 +42,20 @@ export const SubscriptionRequest = Type.Object({ query: Type.String(), search_description: Type.Optional(Type.String()), site_id: Type.String(), - lang: SubscriptionCollectionLanguage + lang: SubscriptionCollectionLanguage, }); -export type SubscriptionRequestType = Static +export type SubscriptionRequestType = Static; // Generic request with SubscriptionId export const SubscriptionGenericPostRequest = Type.Object({ - id: Type.String() + id: Type.String(), }); -export type SubscriptionGenericPostRequestType = Static +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()) + statusMessage: Type.Optional(Type.String()), }); -export type SubscriptionGenericPostResponseType = Static +export type SubscriptionGenericPostResponseType = Static; From b900ab52417a36f37c2a48b4ef3e63ba410b876e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 2 Oct 2025 15:16:58 +0300 Subject: [PATCH 037/228] Biome linter fixes --- .bash_history | 326 ++++++++++++++++++++++++++++ src/app.ts | 2 +- src/bin/hav-populate-email-queue.ts | 23 +- src/bin/hav-send-emails-in-queue.ts | 11 +- src/lib/siteConfigurationLoader.ts | 4 +- src/plugins/atv.ts | 6 +- src/plugins/base64.ts | 6 +- src/plugins/elasticproxy.ts | 6 +- src/plugins/localizedenvvar.ts | 2 +- src/plugins/randhash.ts | 4 +- src/plugins/token.ts | 2 +- src/routes/addSubscription.ts | 2 +- src/routes/confirmSubscription.ts | 4 +- src/routes/deleteSubscription.ts | 2 +- src/routes/healthzAndReadiness.ts | 6 +- src/routes/root.ts | 4 +- 16 files changed, 366 insertions(+), 44 deletions(-) create mode 100644 .bash_history diff --git a/.bash_history b/.bash_history new file mode 100644 index 0000000..abfafa4 --- /dev/null +++ b/.bash_history @@ -0,0 +1,326 @@ +npm run +npm run hav:init-mongodb +npm run hav:init-mongodb +nÃpm run +npm run +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:populate-email-queue +exit +npm run test +npm run test +npm run +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm audit +npm audit fix +exit +npm run +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm audit +npm run hav:migrate-site-id rekry +npm run hav:migrate-site-id rekry +npm run hav:update-schema +npm run hav:update-schema +npm run hav:migrate-site-id rekry +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build +npm run +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +exit +npm run +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:populate-email-queue +curl -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H "Content-Type: application/json" -d '{ + "aggs": { + "field_jobs": { + "sum": { + "field": "field_jobs", + "missing": 1 + } + }, + "total_count": { + "cardinality": { + "field": "field_recruitment_id.keyword" + } + } + }, + "collapse": { + "field": "field_recruitment_id.keyword", + "inner_hits": { + "name": "translations", + "size": 3 + } + }, + "from": 0, + "query": { + "bool": { + "filter": [ + { + "term": { + "entity_type": "node" + } + } + ], + "must": [ + { + "bool": { + "must_not": { + "term": { + "field_promoted": true + } + } + } + }, + { + "bool": { + "should": [ + { + "match_phrase_prefix": { + "field_recruitment_id": "ruotsi" + } + }, + { + "combined_fields": { + "query": "ruotsi", + "fields": [ + "title^2", + "field_organization^1.5", + "field_organization_name", + "field_employment" + ] + } + }, + { + "wildcard": { + "title.keyword": "*ruotsi*" + } + } + ] + } + } + ] + } + }, + "sort": [ + { + "field_publication_starts": { + "order": "desc" + } + }, + "_score" + ], + "size": 30 +}' +curl -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H "Content-Type: application/json" -d '{ + "aggs": { + "field_jobs": { + "sum": { + "field": "field_jobs", + "missing": 1 + } + }, + "total_count": { + "cardinality": { + "field": "field_recruitment_id.keyword" + } + } + }, + "collapse": { + "field": "field_recruitment_id.keyword", + "inner_hits": { + "name": "translations", + "size": 3 + } + }, + "from": 0, + "query": { + "bool": { + "filter": [ + { + "term": { + "entity_type": "node" + } + } + ], + "must": [ + { + "bool": { + "must_not": { + "term": { + "field_promoted": true + } + } + } + }, + { + "bool": { + "should": [ + { + "match_phrase_prefix": { + "field_recruitment_id": "ruotsi" + } + }, + { + "combined_fields": { + "query": "ruotsi", + "fields": [ + "title^2", + "field_organization^1.5", + "field_organization_name", + "field_employment" + ] + } + }, + { + "wildcard": { + "title.keyword": "*ruotsi*" + } + } + ] + } + } + ] + } + }, + "sort": [ + { + "field_publication_starts": { + "order": "desc" + } + }, + "_score" + ], + "size": 30 +}' --ignore-ssl +curl --help +curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' +curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' + +curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' +exit +curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' +npm run +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' +curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run +exit +npm run +npm run hav:populate-email-queue +exit +make +npm run +npm run hav:populate-email-queue && hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +exit +npm run +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +exit +npm run build:ts +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run build:ts +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run hav:populate-email-queue +npm run build:ts +npm run hav:populate-email-queue +npm run build:ts +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +npm run hav:populate-email-queue +npm run hav:send-emails-in-queue +exit +npm run lint:check +npm run lint +git status +npm run build:ts +git add biome.json +git commit . +exit +npm run +npm run hav:populate-email-queue && npm run hav:send-emails-in-queue +npm run +exit diff --git a/src/app.ts b/src/app.ts index b3d60c2..4cb1936 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ +import { join } from 'node:path'; import AutoLoad, { type AutoloadPluginOptions } from '@fastify/autoload'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; import type { FastifyPluginAsync, FastifyServerOptions } from 'fastify'; -import { join } from 'path'; import { Environment } from './types/environment'; export interface AppOptions extends FastifyServerOptions, Partial {} diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 5cbc806..c514009 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -117,9 +117,7 @@ const getNewHitsFromElasticsearch = async ( siteConfig: SiteConfigurationType, ): Promise => { const elasticQuery: string = server.b64decode(subscription.elastic_query); - const lastChecked: number = subscription.last_checked - ? subscription.last_checked - : Math.floor(new Date().getTime() / 1000); + const lastChecked: number = subscription.last_checked ? subscription.last_checked : Math.floor(Date.now() / 1000); try { // Query for new results from ElasticProxy @@ -153,8 +151,8 @@ const getNewHitsFromElasticsearch = async ( * @return {Promise} A Promise that resolves when processing is complete */ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Promise => { - const collection = server.mongo.db!.collection('subscription'); - const queueCollection = server.mongo.db!.collection('queue'); + const collection = server.mongo.db?.collection('subscription'); + const queueCollection = server.mongo.db?.collection('queue'); // List of all enabled subscriptions for this site const result = await collection @@ -240,7 +238,7 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom await queueCollection.insertOne(email); // Set last checked timestamp to this moment - const dateUnixtime: number = Math.floor(new Date().getTime() / 1000); + const dateUnixtime: number = Math.floor(Date.now() / 1000); await collection.updateOne({ _id: subscription._id }, { $set: { last_checked: dateUnixtime } }); @@ -251,9 +249,9 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom /** * Main application function that processes all site configurations. * - * @return {Promise<{}>} A Promise that resolves to an empty object. + * @return {Promise} A Promise that resolves when complete. */ -const app = async (): Promise<{}> => { +const app = async (): Promise => { const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-populate-email-queue', status: 'in_progress', @@ -285,14 +283,13 @@ const app = async (): Promise<{}> => { console.error('Configuration loading error:', error); server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error' }); server.Sentry?.captureException(error); - return {}; + return; } server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok' }); - return {}; }; -server.get('/', async function handleRootRequest(request, reply) { +server.get('/', async function handleRootRequest(_request, _reply) { // Load site configurations const configLoader = SiteConfigurationLoader.getInstance(); await configLoader.loadConfigurations(); @@ -315,7 +312,7 @@ server.get('/', async function handleRootRequest(request, reply) { return app(); }); -server.ready((err) => { +server.ready((_err) => { // eslint-disable-next-line no-console console.log('fastify server ready'); server.inject( @@ -323,7 +320,7 @@ server.ready((err) => { method: 'GET', url: '/', }, - function handleInjectResponse(injectErr, response) { + function handleInjectResponse(_injectErr, response) { if (response) { // eslint-disable-next-line no-console console.log(JSON.parse(response.payload)); diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index 1c54104..88103f3 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -32,7 +32,7 @@ void server.register(atv); // Command line/cron application to send all emails from queue collection const BATCH_SIZE = 100; -const app = async (): Promise<{}> => { +const app = async (): Promise => { const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress' }); if (typeof server.mongo?.db === 'undefined') { @@ -41,7 +41,7 @@ const app = async (): Promise<{}> => { } // Email queue - const queueCollection = server.mongo.db!.collection('queue'); + const queueCollection = server.mongo.db?.collection('queue'); let hasMoreResults = true; @@ -130,15 +130,14 @@ const app = async (): Promise<{}> => { } server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok' }); - return {}; }; -server.get('/', async function handleRootRequest(request, reply) { +server.get('/', async function handleRootRequest(_request, _reply) { // Send all emails from queue return app(); }); -server.ready((err) => { +server.ready((_err) => { // eslint-disable-next-line no-console console.log('fastify server ready'); server.inject( @@ -146,7 +145,7 @@ server.ready((err) => { method: 'GET', url: '/', }, - function handleInjectResponse(injectErr, response) { + function handleInjectResponse(_injectErr, response) { if (response) { // eslint-disable-next-line no-console console.log(JSON.parse(response.payload)); diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts index fc8db35..b274dad 100644 --- a/src/lib/siteConfigurationLoader.ts +++ b/src/lib/siteConfigurationLoader.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import type { SiteConfigurationFileType, SiteConfigurationMapType, diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 9bff913..8241c48 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -4,7 +4,7 @@ import fp from 'fastify-plugin'; import type { AtvDocumentBatchType, AtvDocumentType, AtvResponseType } from '../types/atv'; import type { SubscriptionRequestType } from '../types/subscription'; -export type AtvPluginOptions = {}; +export type AtvPluginOptions = Record; /** * Fetches content by document id from the ATV API. @@ -23,7 +23,7 @@ const atvFetchContentById = async (atvDocumentId: string): Promise { return re.test(String(email).toLowerCase()); }; -export default fp(async (fastify, opts) => { +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); diff --git a/src/plugins/base64.ts b/src/plugins/base64.ts index 67d2723..10e8dbf 100644 --- a/src/plugins/base64.ts +++ b/src/plugins/base64.ts @@ -1,15 +1,15 @@ -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 type 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 default fp(async (fastify, opts) => { +export default fp(async (fastify, _opts) => { fastify.decorate('b64decode', decode); fastify.decorate('b64encode', encode); }); diff --git a/src/plugins/elasticproxy.ts b/src/plugins/elasticproxy.ts index f779bcf..ed3e44f 100644 --- a/src/plugins/elasticproxy.ts +++ b/src/plugins/elasticproxy.ts @@ -1,11 +1,11 @@ +import https from 'node:https'; import axios from 'axios'; import fp from 'fastify-plugin'; -import https from 'https'; import type { ElasticProxyJsonResponseType } from '../types/elasticproxy'; // Query Elastic Proxy -export type ElasticProxyPluginOptions = {}; +export type ElasticProxyPluginOptions = Record; /** * Sends a query to the ElasticSearch proxy. @@ -55,7 +55,7 @@ const queryElasticProxy = async ( } }; -export default fp(async (fastify, opts) => { +export default fp(async (fastify, _opts) => { fastify.decorate('queryElasticProxy', queryElasticProxy); }); diff --git a/src/plugins/localizedenvvar.ts b/src/plugins/localizedenvvar.ts index 9d6d1ce..e8ef5ee 100644 --- a/src/plugins/localizedenvvar.ts +++ b/src/plugins/localizedenvvar.ts @@ -1,7 +1,7 @@ import fp from 'fastify-plugin'; import type { SubscriptionCollectionLanguageType } from '../types/subscription'; -export type localizedEnvVarPluginPluginOptions = {}; +export type localizedEnvVarPluginPluginOptions = Record; export const localizedEnvVar = (envVarBase: string, langCode: SubscriptionCollectionLanguageType): string | undefined => process.env[`${envVarBase}_${langCode.toUpperCase()}`]; diff --git a/src/plugins/randhash.ts b/src/plugins/randhash.ts index 00c7e8d..9f1218a 100644 --- a/src/plugins/randhash.ts +++ b/src/plugins/randhash.ts @@ -2,9 +2,9 @@ import fp from 'fastify-plugin'; // Helper plugin for random hash -export type RandHashPluginOptions = {}; +export type RandHashPluginOptions = Record; -export default fp(async (fastify, opts) => { +export default fp(async (fastify, _opts) => { fastify.decorate('getRandHash', function getRandHash() { return (Math.random() + 1).toString(36).substring(2); }); diff --git a/src/plugins/token.ts b/src/plugins/token.ts index 4850549..6b37512 100644 --- a/src/plugins/token.ts +++ b/src/plugins/token.ts @@ -2,7 +2,7 @@ import fp from 'fastify-plugin'; // Validate token in request headers -export default fp(async (fastify, opts) => { +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') { diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index aa07ca8..b66c93a 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -14,7 +14,7 @@ import { // Add subscription to given query parameters -const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, opts: object): Promise => { +const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { fastify.post<{ Body: SubscriptionRequestType; Reply: SubscriptionResponseType | Generic500ErrorType; diff --git a/src/routes/confirmSubscription.ts b/src/routes/confirmSubscription.ts index ba849e2..93a1b84 100644 --- a/src/routes/confirmSubscription.ts +++ b/src/routes/confirmSubscription.ts @@ -10,7 +10,7 @@ import { // Confirms subscription -const confirmSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, opts: object): Promise => { +const confirmSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { fastify.get<{ Reply: SubscriptionGenericPostResponseType | Generic500ErrorType; }>( @@ -41,7 +41,7 @@ const confirmSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, }); } - await collection!.updateOne({ _id: new ObjectId(id) }, { $set: { status: SubscriptionStatus.ACTIVE } }); + 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, diff --git a/src/routes/deleteSubscription.ts b/src/routes/deleteSubscription.ts index f424730..4c66e32 100644 --- a/src/routes/deleteSubscription.ts +++ b/src/routes/deleteSubscription.ts @@ -6,7 +6,7 @@ import { SubscriptionGenericPostResponse, type SubscriptionGenericPostResponseTy // Deletes subscription -const deleteSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, opts: object): Promise => { +const deleteSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { fastify.delete<{ Reply: SubscriptionGenericPostResponseType | Generic500ErrorType; }>( diff --git a/src/routes/healthzAndReadiness.ts b/src/routes/healthzAndReadiness.ts index accbcf4..d25eb89 100644 --- a/src/routes/healthzAndReadiness.ts +++ b/src/routes/healthzAndReadiness.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; -const healthzAndReadiness: FastifyPluginAsync = async (fastify: FastifyInstance, opts: object): Promise => { +const healthzAndReadiness: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { fastify.get( '/healthz', { @@ -17,7 +17,7 @@ const healthzAndReadiness: FastifyPluginAsync = async (fastify: FastifyInstance, }, }, }, - async (request: FastifyRequest, reply: FastifyReply) => + async (_request: FastifyRequest, reply: FastifyReply) => reply.code(200).send({ statusCode: 200, message: 'OK', @@ -48,7 +48,7 @@ const healthzAndReadiness: FastifyPluginAsync = async (fastify: FastifyInstance, }, }, }, - async (request: FastifyRequest, reply: FastifyReply) => { + async (_request: FastifyRequest, reply: FastifyReply) => { const mongodb = fastify.mongo; try { diff --git a/src/routes/root.ts b/src/routes/root.ts index d8c0906..173027c 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,7 +1,7 @@ import type { FastifyPluginAsync } from 'fastify'; -const root: FastifyPluginAsync = async (fastify, opts): Promise => { - fastify.get('/', async function rootHandler(request, reply) { +const root: FastifyPluginAsync = async (fastify, _opts): Promise => { + fastify.get('/', async function rootHandler(_request, _reply) { return { root: true }; }); }; From 4efada3b3031613b2e4604717fb0fbe4d8db967d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 2 Oct 2025 15:38:39 +0300 Subject: [PATCH 038/228] Fix linter damage --- src/bin/hav-populate-email-queue.ts | 7 ++++++- src/bin/hav-send-emails-in-queue.ts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index c514009..8f75e86 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -154,6 +154,10 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom 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({ @@ -309,7 +313,8 @@ server.get('/', async function handleRootRequest(_request, _reply) { }, Promise.resolve()); // Loop through subscriptions and add new results to email queue - return app(); + await app(); + return { success: true }; }); server.ready((_err) => { diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index 88103f3..0f9f715 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -134,7 +134,8 @@ const app = async (): Promise => { server.get('/', async function handleRootRequest(_request, _reply) { // Send all emails from queue - return app(); + await app(); + return { success: true }; }); server.ready((_err) => { From 73b7f91b776e9c5d1f87bf4f857d051b4816776c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 3 Oct 2025 08:37:54 +0300 Subject: [PATCH 039/228] Biome linter fixes --- src/app.ts | 2 +- src/bin/hav-init-mongodb.ts | 5 ++++- src/bin/hav-migrate-site-id.ts | 5 ++++- src/bin/hav-populate-email-queue.ts | 7 ++++--- src/bin/hav-update-schema.ts | 5 ++++- src/lib/siteConfigurationLoader.ts | 2 +- src/plugins/atv.ts | 4 ++-- 7 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4cb1936..25be57d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,7 +22,7 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise const release = process.env.SENTRY_RELEASE ?? ''; fastify.register(fastifySentry, { dsn: process.env.SENTRY_DSN, - beforeSend: (event: any) => { + beforeSend: (event) => { if (!event?.request?.data) { return event; } diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 0ff9383..402fd93 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -21,7 +21,10 @@ void server.register(mongodb); const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => { try { - const db = server.mongo.db!; + const db = server.mongo.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } // Check if collections exist const collections = await db.listCollections().toArray(); diff --git a/src/bin/hav-migrate-site-id.ts b/src/bin/hav-migrate-site-id.ts index 566f3b8..3e28af6 100644 --- a/src/bin/hav-migrate-site-id.ts +++ b/src/bin/hav-migrate-site-id.ts @@ -22,7 +22,10 @@ const migrateSiteId = async ( options: MigrationOptions, ): Promise<{ success: boolean; updated: number; error?: unknown }> => { try { - const db = server.mongo.db!; + const db = server.mongo.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } const collection = db.collection('subscription'); // Find documents without site_id diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index 8f75e86..e4e824f 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -1,3 +1,4 @@ +import type { ObjectId } from '@fastify/mongodb'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; import dotenv from 'dotenv'; import fastify from 'fastify'; @@ -113,7 +114,7 @@ const checkShouldSendExpiryNotification = ( }; const getNewHitsFromElasticsearch = async ( - subscription: SubscriptionCollectionType & { _id: any }, + subscription: SubscriptionCollectionType & { _id: ObjectId }, siteConfig: SiteConfigurationType, ): Promise => { const elasticQuery: string = server.b64decode(subscription.elastic_query); @@ -128,7 +129,7 @@ const getNewHitsFromElasticsearch = async ( // Filter out new hits: return (elasticResponse?.hits?.hits ?? []) - .filter((hit: any) => { + .filter((hit: { _source?: PartialDrupalNodeType }) => { const publicationStarts = hit?._source?.field_publication_starts; if (!Array.isArray(publicationStarts) || publicationStarts.length === 0) { return false; @@ -206,7 +207,7 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom } const newHits = await getNewHitsFromElasticsearch( - subscription as SubscriptionCollectionType & { _id: any }, + subscription as SubscriptionCollectionType & { _id: ObjectId }, siteConfig, ); diff --git a/src/bin/hav-update-schema.ts b/src/bin/hav-update-schema.ts index 7032d13..0937d4c 100644 --- a/src/bin/hav-update-schema.ts +++ b/src/bin/hav-update-schema.ts @@ -17,7 +17,10 @@ void server.register(mongodb); const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => { try { - const db = server.mongo.db!; + const db = server.mongo.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } const result = await db.command({ collMod: 'subscription', diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts index b274dad..94a3e4f 100644 --- a/src/lib/siteConfigurationLoader.ts +++ b/src/lib/siteConfigurationLoader.ts @@ -57,7 +57,7 @@ export class SiteConfigurationLoader { } // Extract environment-specific config - const envConfig = (rawConfig as any)[environment] as SiteEnvironmentConfigType; + const envConfig = (rawConfig as Record)[environment] as SiteEnvironmentConfigType; if (!envConfig) { throw new Error(`Environment '${environment}' not found in configuration ${filePath}`); } diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 8241c48..36638e7 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -46,7 +46,7 @@ const atvCreateDocumentWithEmail = async (email: string): Promise Date: Mon, 6 Oct 2025 08:49:38 +0300 Subject: [PATCH 040/228] Add lint:check to build:ts --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2c848d..946d730 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "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", - "build:ts": "npm run copy:assets; tsc", + "build:ts": "npm run lint:check && 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\"", From 68acbb2c3f3959b0d6e3275ca329a7205a4050c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Mon, 6 Oct 2025 08:50:07 +0300 Subject: [PATCH 041/228] Oops :) Remove bash history --- .bash_history | 326 -------------------------------------------------- 1 file changed, 326 deletions(-) delete mode 100644 .bash_history diff --git a/.bash_history b/.bash_history deleted file mode 100644 index abfafa4..0000000 --- a/.bash_history +++ /dev/null @@ -1,326 +0,0 @@ -npm run -npm run hav:init-mongodb -npm run hav:init-mongodb -nÃpm run -npm run -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:populate-email-queue -exit -npm run test -npm run test -npm run -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm audit -npm audit fix -exit -npm run -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm audit -npm run hav:migrate-site-id rekry -npm run hav:migrate-site-id rekry -npm run hav:update-schema -npm run hav:update-schema -npm run hav:migrate-site-id rekry -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build -npm run -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -exit -npm run -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:populate-email-queue -curl -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H "Content-Type: application/json" -d '{ - "aggs": { - "field_jobs": { - "sum": { - "field": "field_jobs", - "missing": 1 - } - }, - "total_count": { - "cardinality": { - "field": "field_recruitment_id.keyword" - } - } - }, - "collapse": { - "field": "field_recruitment_id.keyword", - "inner_hits": { - "name": "translations", - "size": 3 - } - }, - "from": 0, - "query": { - "bool": { - "filter": [ - { - "term": { - "entity_type": "node" - } - } - ], - "must": [ - { - "bool": { - "must_not": { - "term": { - "field_promoted": true - } - } - } - }, - { - "bool": { - "should": [ - { - "match_phrase_prefix": { - "field_recruitment_id": "ruotsi" - } - }, - { - "combined_fields": { - "query": "ruotsi", - "fields": [ - "title^2", - "field_organization^1.5", - "field_organization_name", - "field_employment" - ] - } - }, - { - "wildcard": { - "title.keyword": "*ruotsi*" - } - } - ] - } - } - ] - } - }, - "sort": [ - { - "field_publication_starts": { - "order": "desc" - } - }, - "_score" - ], - "size": 30 -}' -curl -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H "Content-Type: application/json" -d '{ - "aggs": { - "field_jobs": { - "sum": { - "field": "field_jobs", - "missing": 1 - } - }, - "total_count": { - "cardinality": { - "field": "field_recruitment_id.keyword" - } - } - }, - "collapse": { - "field": "field_recruitment_id.keyword", - "inner_hits": { - "name": "translations", - "size": 3 - } - }, - "from": 0, - "query": { - "bool": { - "filter": [ - { - "term": { - "entity_type": "node" - } - } - ], - "must": [ - { - "bool": { - "must_not": { - "term": { - "field_promoted": true - } - } - } - }, - { - "bool": { - "should": [ - { - "match_phrase_prefix": { - "field_recruitment_id": "ruotsi" - } - }, - { - "combined_fields": { - "query": "ruotsi", - "fields": [ - "title^2", - "field_organization^1.5", - "field_organization_name", - "field_employment" - ] - } - }, - { - "wildcard": { - "title.keyword": "*ruotsi*" - } - } - ] - } - } - ] - } - }, - "sort": [ - { - "field_publication_starts": { - "order": "desc" - } - }, - "_score" - ], - "size": 30 -}' --ignore-ssl -curl --help -curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' -curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' - -curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' -exit -curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' -npm run -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' -curl -k -X POST "https://elastic-helfi-rekry.docker.so/job_listings/_search" -H 'Content-Type: application/json' -d '{"aggs":{"field_jobs":{"sum":{"field":"field_jobs","missing":1}},"total_count":{"cardinality":{"field":"field_recruitment_id.keyword"}}},"collapse":{"field":"field_recruitment_id.keyword","inner_hits":{"name":"translations","size":3}},"from":0,"query":{"bool":{"filter":[{"term":{"entity_type":"node"}}],"must":[{"bool":{"must_not":{"term":{"field_promoted":true}}}},{"bool":{"should":[{"match_phrase_prefix":{"field_recruitment_id":"ruotsi"}},{"combined_fields":{"query":"ruotsi","fields":["title^2","field_organization^1.5","field_organization_name","field_employment"]}},{"wildcard":{"title.keyword":"*ruotsi*"}}]}}]}},"sort":[{"field_publication_starts":{"order":"desc"}},"_score"],"size":30}' -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run -exit -npm run -npm run hav:populate-email-queue -exit -make -npm run -npm run hav:populate-email-queue && hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -exit -npm run -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -exit -npm run build:ts -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run build:ts -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run hav:populate-email-queue -npm run build:ts -npm run hav:populate-email-queue -npm run build:ts -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -npm run hav:populate-email-queue -npm run hav:send-emails-in-queue -exit -npm run lint:check -npm run lint -git status -npm run build:ts -git add biome.json -git commit . -exit -npm run -npm run hav:populate-email-queue && npm run hav:send-emails-in-queue -npm run -exit From f218ec61ae245e8e7ed7729605fa6d5d3e4df0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 9 Oct 2025 15:32:47 +0300 Subject: [PATCH 042/228] Attempt to fix openshift Dockerfile --- openshift/Dockerfile | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 89e36bf..dda785e 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -1,12 +1,11 @@ FROM registry.access.redhat.com/ubi8/nodejs-20 -ENV npm_config_cache=/app/.npm +ENV npm_config_cache="$HOME/.npm" ENV APP_NAME rekry-hakuvahti -RUN mkdir -p /app/node_modules -RUN mkdir -p /app/logs +RUN mkdir -p "$HOME/node_modules" "$HOME/logs" -WORKDIR /app +WORKDIR "$HOME" COPY package.json . COPY package-lock.json . COPY . . @@ -15,7 +14,7 @@ RUN npm install RUN npm cache clean --force EXPOSE 3000 -RUN chown -R :0 /app && chmod -R g+wx /app +RUN chown -R :0 "$HOME" && chmod -R g+wx "$HOME" USER nobody:0 CMD [ "npm", "run", "start" ] From ad750b90779917868d740fa818f1ed7136ac2956 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Thu, 9 Oct 2025 16:07:36 +0300 Subject: [PATCH 043/228] Refactor hakuvahti dockerfile --- openshift/Dockerfile | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/openshift/Dockerfile b/openshift/Dockerfile index dda785e..325239e 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -1,20 +1,34 @@ -FROM registry.access.redhat.com/ubi8/nodejs-20 +FROM registry.access.redhat.com/ubi9/nodejs-22 as builder -ENV npm_config_cache="$HOME/.npm" -ENV APP_NAME rekry-hakuvahti +ENV npm_config_cache="/tmp/.npm" + +WORKDIR "$HOME" +COPY --chown=default:0 / "$HOME/" + +RUN npm ci + +RUN \ + npm run copy:assets && \ + npx tsc -RUN mkdir -p "$HOME/node_modules" "$HOME/logs" +FROM registry.access.redhat.com/ubi9/nodejs-22 + +ENV npm_config_cache="/tmp/.npm" +ENV APP_NAME rekry-hakuvahti WORKDIR "$HOME" -COPY package.json . -COPY package-lock.json . -COPY . . +COPY package*.json ./ -RUN npm install -RUN npm cache clean --force +RUN npm ci --omit=dev && npm cache clean --force + +COPY --chown=default:0 / "$HOME/" +COPY --from=builder --chown=default:0 $HOME/dist $HOME/dist EXPOSE 3000 -RUN chown -R :0 "$HOME" && chmod -R g+wx "$HOME" +RUN \ + chown -R :0 "$HOME" && \ + chmod -R g+wx "$HOME" + USER nobody:0 -CMD [ "npm", "run", "start" ] +CMD ["fastify", "start", "-l", "info", "dist/app.js"] From 87c55358cb500a25cb3be3381a46efedb6ca5953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 9 Oct 2025 16:10:11 +0300 Subject: [PATCH 044/228] Change paths in cron scripts --- cron/populate.sh | 2 +- cron/queue.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cron/populate.sh b/cron/populate.sh index d0d8b97..212c300 100644 --- a/cron/populate.sh +++ b/cron/populate.sh @@ -1,6 +1,6 @@ #!/bin/sh -cd /app +cd "$HOME" echo "Populating email queue" diff --git a/cron/queue.sh b/cron/queue.sh index d78ab2b..42df302 100644 --- a/cron/queue.sh +++ b/cron/queue.sh @@ -1,6 +1,6 @@ #!/bin/sh -cd /app +cd "$HOME" echo "Sending emails in queue" From 474fc72b4cb40fe595900159a18dbc1dfee66780 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Thu, 9 Oct 2025 16:10:29 +0300 Subject: [PATCH 045/228] Remove chmod since copy should handle it --- openshift/Dockerfile | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 325239e..ad3440d 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -17,17 +17,14 @@ ENV npm_config_cache="/tmp/.npm" ENV APP_NAME rekry-hakuvahti WORKDIR "$HOME" -COPY package*.json ./ +COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --chown=default:0 / "$HOME/" COPY --from=builder --chown=default:0 $HOME/dist $HOME/dist EXPOSE 3000 -RUN \ - chown -R :0 "$HOME" && \ - chmod -R g+wx "$HOME" USER nobody:0 From cdd34e9bb5bec8b05c2a8fca7a67a6252053b49f Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Thu, 9 Oct 2025 16:18:27 +0300 Subject: [PATCH 046/228] Change back to /app --- openshift/Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openshift/Dockerfile b/openshift/Dockerfile index ad3440d..28c62a9 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -2,8 +2,8 @@ FROM registry.access.redhat.com/ubi9/nodejs-22 as builder ENV npm_config_cache="/tmp/.npm" -WORKDIR "$HOME" -COPY --chown=default:0 / "$HOME/" +WORKDIR "/app" +COPY --chown=default:0 / "/app/" RUN npm ci @@ -16,13 +16,13 @@ FROM registry.access.redhat.com/ubi9/nodejs-22 ENV npm_config_cache="/tmp/.npm" ENV APP_NAME rekry-hakuvahti -WORKDIR "$HOME" +WORKDIR "/app" COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force -COPY --chown=default:0 / "$HOME/" -COPY --from=builder --chown=default:0 $HOME/dist $HOME/dist +COPY --chown=default:0 / "/app/" +COPY --from=builder --chown=default:0 /app/dist /app/dist EXPOSE 3000 From e428c2b4f4ccb30d108865f72939bd4680f39eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 8 Oct 2025 14:30:17 +0300 Subject: [PATCH 047/228] Adds --site and --dry-run parameters for populate script --- README.md | 45 ++++++++++++ package-lock.json | 48 +++++++----- package.json | 2 + src/bin/hav-populate-email-queue.ts | 110 ++++++++++++++++++++++++---- 4 files changed, 169 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6957adc..6e3fae2 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,51 @@ Pre-requisities to use Hakuvahti are: ## Configuration +### Email Queue Population Script + +The `hav-populate-email-queue` script checks for new search results and queues notification emails. It supports site-specific processing and dry-run mode for testing. + +**Usage:** + +```bash +# Process all sites +npm run hav:populate-email-queue + +# Process specific site only +npm run hav:populate-email-queue -- --site=rekry + +# Preview what would happen without making changes (dry run) +npm run hav:populate-email-queue -- --dry-run + +# Dry run for specific site +npm run hav:populate-email-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-email-queue", "--", "--site=rekry"] + +# General site - check hourly +- name: populate-general + schedule: "0 * * * *" + command: ["npm", "run", "hav:populate-email-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`). diff --git a/package-lock.json b/package-lock.json index b96c453..733496c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,12 +23,14 @@ "fastify-mailer": "^2.3.1", "fastify-plugin": "^4.0.0", "jsdom": "^24.0.0", + "minimist": "^1.2.8", "nodemailer": "^6.9.9", "sprightly": "^2.0.1" }, "devDependencies": { "@biomejs/biome": "^2.2.4", "@types/jsdom": "^21.1.6", + "@types/minimist": "^1.2.5", "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", @@ -472,6 +474,13 @@ "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", @@ -1308,15 +1317,6 @@ "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", @@ -2173,6 +2173,7 @@ "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" } @@ -2237,9 +2238,10 @@ "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": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", "engines": { "node": ">=6.0.0" } @@ -2353,20 +2355,20 @@ } }, "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": "9.13.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.13.1.tgz", + "integrity": "sha512-Szuj+ViDTjKPQYiKumGmEn3frdl+ZPSdosHyt9SnUevFosOkMY2b7ipxlEctNKPmMD/VibeBI+ZcZCJK+4DPuw==", "license": "MIT", "dependencies": { "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", + "slow-redact": "^0.3.0", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, @@ -2466,9 +2468,9 @@ } }, "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==", + "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", @@ -2801,6 +2803,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slow-redact": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.1.tgz", + "integrity": "sha512-NvFvl1GuLZNW4U046Tfi8b26zXo8aBzgCAS2f7yVJR/fArN93mOqSA99cB9uITm92ajSz01bsu1K7SCVVjIMpQ==", + "license": "MIT" + }, "node_modules/sonic-boom": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.0.tgz", diff --git a/package.json b/package.json index 946d730..996260f 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,14 @@ "fastify-mailer": "^2.3.1", "fastify-plugin": "^4.0.0", "jsdom": "^24.0.0", + "minimist": "^1.2.8", "nodemailer": "^6.9.9", "sprightly": "^2.0.1" }, "devDependencies": { "@biomejs/biome": "^2.2.4", "@types/jsdom": "^21.1.6", + "@types/minimist": "^1.2.5", "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index e4e824f..a2ba899 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -2,6 +2,7 @@ import type { ObjectId } from '@fastify/mongodb'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; import dotenv from 'dotenv'; import fastify from 'fastify'; +import minimist from 'minimist'; import { expiryEmail, newHitsEmail } from '../lib/email'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; @@ -20,6 +21,19 @@ import { dotenv.config(); +// Parse CLI arguments +const argv = minimist(process.argv.slice(2)); +const targetSite: string | undefined = argv.site; +const isDryRun: boolean = argv['dry-run'] === true; + +// Statistics tracking +interface ProcessingStats { + sitesProcessed: number; + subscriptionsChecked: number; + expiryEmailsQueued: number; + newResultsEmailsQueued: number; +} + const server = fastify({}); const release = process.env.SENTRY_RELEASE ?? ''; @@ -149,9 +163,10 @@ const getNewHitsFromElasticsearch = async ( * Processes subscriptions for a specific site configuration. * * @param {SiteConfiguration} siteConfig - The site configuration to process + * @param {ProcessingStats} stats - Statistics object to track processing * @return {Promise} A Promise that resolves when processing is complete */ -const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Promise => { +const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats: ProcessingStats): Promise => { const collection = server.mongo.db?.collection('subscription'); const queueCollection = server.mongo.db?.collection('queue'); @@ -167,6 +182,8 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom }) .toArray(); + stats.subscriptionsChecked += result.length; + // Process subscriptions sequentially to avoid overwhelming the system await result.reduce(async (previousPromise, subscription) => { await previousPromise; @@ -175,7 +192,12 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom // If subscription should expire soon, send an expiration email if (checkShouldSendExpiryNotification(subscription as Partial, siteConfig)) { - await collection.updateOne({ _id: subscription._id }, { $set: { expiry_notification_sent: 1 } }); + if (isDryRun) { + // eslint-disable-next-line no-console + 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 subscriptionValidForDays = siteConfig.subscription.maxAge; const subscriptionExpiresAt = @@ -203,7 +225,10 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom }; // Add email to queue - await queueCollection.insertOne(expiryEmailToQueue); + if (!isDryRun) { + await queueCollection.insertOne(expiryEmailToQueue); + } + stats.expiryEmailsQueued++; } const newHits = await getNewHitsFromElasticsearch( @@ -239,13 +264,20 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType): Prom content: emailContent, }; - // Add email to queue - await queueCollection.insertOne(email); - - // Set last checked timestamp to this moment - const dateUnixtime: number = Math.floor(Date.now() / 1000); + if (isDryRun) { + // eslint-disable-next-line no-console + 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); - await collection.updateOne({ _id: subscription._id }, { $set: { last_checked: dateUnixtime } }); + // 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++; return Promise.resolve(); }, Promise.resolve()); @@ -262,36 +294,82 @@ const app = async (): Promise => { status: 'in_progress', }); + // Initialize statistics + const stats: ProcessingStats = { + sitesProcessed: 0, + subscriptionsChecked: 0, + expiryEmailsQueued: 0, + newResultsEmailsQueued: 0, + }; + try { // eslint-disable-next-line no-console console.log('Environment:', process.env.ENVIRONMENT || 'dev'); + if (isDryRun) { + // eslint-disable-next-line no-console + console.log('\n=== DRY RUN MODE - No changes will be made ===\n'); + } // eslint-disable-next-line no-console console.log('Loading site configurations...'); // Load site configurations const configLoader = SiteConfigurationLoader.getInstance(); await configLoader.loadConfigurations(); - const siteConfigs = configLoader.getConfigurations(); + 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(', '); // eslint-disable-next-line no-console - console.log('Loaded configurations for sites:', Object.keys(siteConfigs)); + console.log(`Processing ${siteConfigsToProcess.length} site(s): ${siteNames}\n`); // Process each site configuration - await Object.entries(siteConfigs).reduce(async (previousPromise, [siteId, siteConfig]) => { + await siteConfigsToProcess.reduce(async (previousPromise, [siteId, siteConfig]) => { await previousPromise; // eslint-disable-next-line no-console console.log(`Processing subscriptions for site: ${siteId}`); - await processSiteSubscriptions(siteConfig); + await processSiteSubscriptions(siteConfig, stats); + stats.sitesProcessed++; return Promise.resolve(); }, Promise.resolve()); + + // Print summary + // eslint-disable-next-line no-console + console.log('\n=== Summary ==='); + // eslint-disable-next-line no-console + console.log(`Sites processed: ${stats.sitesProcessed}`); + // eslint-disable-next-line no-console + console.log(`Subscriptions checked: ${stats.subscriptionsChecked}`); + // eslint-disable-next-line no-console + console.log(`Expiry emails queued: ${stats.expiryEmailsQueued}`); + // eslint-disable-next-line no-console + console.log(`New results emails queued: ${stats.newResultsEmailsQueued}`); + if (isDryRun) { + // eslint-disable-next-line no-console + console.log('\n[DRY RUN] No changes were made to the database'); + } } catch (error) { console.error('Configuration loading error:', error); - server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error' }); - server.Sentry?.captureException(error); + if (!isDryRun) { + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error' }); + server.Sentry?.captureException(error); + } return; } - server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok' }); + if (!isDryRun) { + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok' }); + } }; server.get('/', async function handleRootRequest(_request, _reply) { From 6290040f1b5229bec2c6d2be93477295eb3eca30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 10 Oct 2025 09:05:30 +0300 Subject: [PATCH 048/228] Forward parameters to NPM --- cron/populate.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cron/populate.sh b/cron/populate.sh index 212c300..8fbac99 100644 --- a/cron/populate.sh +++ b/cron/populate.sh @@ -4,5 +4,7 @@ cd "$HOME" echo "Populating email queue" -npm run hav:populate-email-queue +# Forward all command-line arguments to the npm script +# Usage: ./populate.sh --site=rekry --dry-run +npm run hav:populate-email-queue -- "$@" From c67446d5d6ab9b20ff36fa8192b73c312eb5ff7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 15 Oct 2025 08:03:32 +0300 Subject: [PATCH 049/228] Change back to app here too --- cron/populate.sh | 2 +- cron/queue.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cron/populate.sh b/cron/populate.sh index 212c300..d0d8b97 100644 --- a/cron/populate.sh +++ b/cron/populate.sh @@ -1,6 +1,6 @@ #!/bin/sh -cd "$HOME" +cd /app echo "Populating email queue" diff --git a/cron/queue.sh b/cron/queue.sh index 42df302..d78ab2b 100644 --- a/cron/queue.sh +++ b/cron/queue.sh @@ -1,6 +1,6 @@ #!/bin/sh -cd "$HOME" +cd /app echo "Sending emails in queue" From 116566209c0ded06ba1063d9ced3f1de10cef41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 2 Oct 2025 14:06:33 +0300 Subject: [PATCH 050/228] feat: Add optional SMS parameter to subscription API - Add optional 'sms' field to SubscriptionRequest schema - Validate SMS in international E.164 format (e.g., +358451234567) - Store SMS with email in same ATV document for security - Move all validation logic to route layer for cleaner architecture - Remove email validation from ATV plugin (now pure storage layer) SMS is optional and stored encrypted in ATV alongside email. Only the ATV document ID is saved to MongoDB, maintaining data separation. --- src/plugins/atv.ts | 36 +++++++++++++++-------------------- src/routes/addSubscription.ts | 33 ++++++++++++++++++++++++++++++++ src/types/subscription.ts | 1 + 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 36638e7..3b57094 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -35,12 +35,13 @@ const atvFetchContentById = async (atvDocumentId: string): Promise>} the created document */ -const atvCreateDocumentWithEmail = async (email: string): Promise> => { +const atvCreateDocumentWithEmail = async (email: string, sms?: string): Promise> => { try { const timestamp = Math.floor(Date.now() / 1000).toString(); @@ -51,13 +52,14 @@ const atvCreateDocumentWithEmail = async (email: string): Promise = { - 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': 'atvCreateDocumentWithEmail', + 'tos_record_id': timestamp, + 'delete_after': deleteAfter.toISOString().substring(0, 10), + 'content': JSON.stringify({ + 'email': email, + ...(sms && { 'sms': sms }) + }) }; const response: AxiosResponse> = await axios.post( @@ -124,15 +126,12 @@ const requestEmailHook = async (request: FastifyRequestType) => { } // 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. + // and store email and optional SMS 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(); + const sms: string | undefined = body.sms?.trim(); - if (!isValidEmail(email)) { - throw new Error('Invalid email format'); - } - - const atvDocument: Partial = await atvCreateDocumentWithEmail(email); + const atvDocument: Partial = await atvCreateDocumentWithEmail(email, sms); const atvDocumentId: string | undefined = atvDocument.id; if (atvDocumentId) { @@ -146,11 +145,6 @@ const requestEmailHook = async (request: FastifyRequestType) => { } }; -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 @@ -180,7 +174,7 @@ declare module 'fastify' { export interface FastifyInstance { atvQueryEmail(email: string): Promise>; - atvCreateDocumentWithEmail: (email: string) => Promise>; + atvCreateDocumentWithEmail: (email: string, sms?: string) => Promise>; atvGetDocumentBatch: (emails: string[]) => Promise>; } } diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index b66c93a..371875a 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -12,6 +12,18 @@ import { 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 isValidSms = (sms: string): boolean => { + // E.164 international format: + followed by 1-15 digits + const re = /^\+[1-9]\d{1,14}$/; + return re.test(sms); +}; + // Add subscription to given query parameters const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { @@ -54,6 +66,22 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: .send({ error: 'Invalid site_id provided.' }); } + // Validate email (required) + if (!isValidEmail(request.body.email?.trim())) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid email format.' }); + } + + // Validate SMS (optional) + if (request.body.sms && !isValidSms(request.body.sms.trim())) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid SMS format. Use international format (e.g., +358451234567).' }); + } + // Subscription data that goes to collection const subscriptionData: Partial = { ...request.body, @@ -65,6 +93,11 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: status: SubscriptionStatus.INACTIVE, }; + // Remove SMS from request body (it's already stored in ATV document) + if (request.body.sms) { + delete request.body.sms; + } + const response = await collection?.insertOne(subscriptionData); if (!response) { fastify.log.debug(response); diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 777b08e..427a0f9 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -43,6 +43,7 @@ export const SubscriptionRequest = Type.Object({ search_description: Type.Optional(Type.String()), site_id: Type.String(), lang: SubscriptionCollectionLanguage, + sms: Type.Optional(Type.String()) }); export type SubscriptionRequestType = Static; From edb6fa2e43203d73448ee51ad489d21cace84bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 09:39:01 +0300 Subject: [PATCH 051/228] Move validation funcs to controller --- src/plugins/atv.ts | 27 +++++++++++---------- src/routes/addSubscription.ts | 44 ++++++++++++++++++----------------- src/types/subscription.ts | 2 +- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 3b57094..55b7286 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -52,14 +52,14 @@ const atvCreateDocumentWithEmail = async (email: string, sms?: string): Promise< // 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, - ...(sms && { 'sms': sms }) - }) + draft: 'false', + tos_function_id: 'atvCreateDocumentWithEmail', + tos_record_id: timestamp, + delete_after: deleteAfter.toISOString().substring(0, 10), + content: JSON.stringify({ + email: email, + ...(sms && { sms: sms }), + }), }; const response: AxiosResponse> = await axios.post( @@ -105,15 +105,14 @@ const atvGetDocumentBatch = async (emails: string[]): Promise { atvDocumentId, }; } + + // Remove SMS from request body after ATV storage (it shouldn't go to MongoDB) + if (body.sms) { + delete body.sms; + } } catch (error) { console.error('An error occurred:', error); throw new Error('Could not create document to ATV. Cannot subscribe.'); } }; - export default fp(async (fastify, _opts) => { // Hook handler automatically creates ATV document for the email // and sets the returned documentId to atvResponse.email variable diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index 371875a..826e0cc 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -14,7 +14,8 @@ import { // 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,}))$/; + 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()); }; @@ -40,6 +41,25 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: 500: Generic500Error, }, }, + preHandler: async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { + // Validate email and SMS BEFORE ATV document creation + const email = request.body.email?.trim(); + const sms = request.body.sms?.trim(); + + if (!isValidEmail(email)) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid email format.' }); + } + + if (sms && !isValidSms(sms)) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid SMS format. Use international format (e.g., +358451234567).' }); + } + }, }, async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { const mongodb = fastify.mongo; @@ -66,22 +86,6 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: .send({ error: 'Invalid site_id provided.' }); } - // Validate email (required) - if (!isValidEmail(request.body.email?.trim())) { - return reply - .code(400) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Invalid email format.' }); - } - - // Validate SMS (optional) - if (request.body.sms && !isValidSms(request.body.sms.trim())) { - return reply - .code(400) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Invalid SMS format. Use international format (e.g., +358451234567).' }); - } - // Subscription data that goes to collection const subscriptionData: Partial = { ...request.body, @@ -93,10 +97,8 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: status: SubscriptionStatus.INACTIVE, }; - // Remove SMS from request body (it's already stored in ATV document) - if (request.body.sms) { - delete request.body.sms; - } + // SMS is already stored in ATV document, no need to store in MongoDB + // It was removed by the ATV hook after validation const response = await collection?.insertOne(subscriptionData); if (!response) { diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 427a0f9..89193a7 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -43,7 +43,7 @@ export const SubscriptionRequest = Type.Object({ search_description: Type.Optional(Type.String()), site_id: Type.String(), lang: SubscriptionCollectionLanguage, - sms: Type.Optional(Type.String()) + sms: Type.Optional(Type.String()), }); export type SubscriptionRequestType = Static; From bec507776f3aefc235d948446db5ca0c4a8d21d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 11:19:28 +0300 Subject: [PATCH 052/228] Validate SMS format before saving to ATV Update REST example with SMS --- src/routes/addSubscription.ts | 3 ++- test/requests/addSubscription.rest | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index 826e0cc..9f8d39c 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -41,8 +41,9 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: 500: Generic500Error, }, }, - preHandler: async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { + preValidation: async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { // 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(); diff --git a/test/requests/addSubscription.rest b/test/requests/addSubscription.rest index b5485b4..21ba96d 100644 --- a/test/requests/addSubscription.rest +++ b/test/requests/addSubscription.rest @@ -7,6 +7,7 @@ token: test "elastic_query": "eyJhZ2dzIjp7ImZpZWxkX2pvYnMiOnsic3VtIjp7ImZpZWxkIjoiZmllbGRfam9icyIsIm1pc3NpbmciOjF9fSwidG90YWxfY291bnQiOnsiY2FyZGluYWxpdHkiOnsiZmllbGQiOiJmaWVsZF9yZWNydWl0bWVudF9pZC5rZXl3b3JkIn19fSwiY29sbGFwc2UiOnsiZmllbGQiOiJmaWVsZF9yZWNydWl0bWVudF9pZC5rZXl3b3JkIiwiaW5uZXJfaGl0cyI6eyJuYW1lIjoidHJhbnNsYXRpb25zIiwic2l6ZSI6M319LCJmcm9tIjowLCJxdWVyeSI6eyJib29sIjp7ImZpbHRlciI6W3sidGVybSI6eyJlbnRpdHlfdHlwZSI6Im5vZGUifX0seyJ0ZXJtIjp7Il9sYW5ndWFnZSI6ImZpIn19LHsidGVybXMiOnsiZmllbGRfcG9zdGFsX2NvZGUiOlsiMDA4ODAiLCIwMDg5MCIsIjAwOTAwIiwiMDA5MTAiLCIwMDkyMCIsIjAwOTMwIiwiMDA5NDAiLCIwMDk1MCIsIjAwOTYwIiwiMDA5NzAiLCIwMDk4MCIsIjAwOTkwIl19fV0sIm11c3QiOlt7ImJvb2wiOnsibXVzdF9ub3QiOnsidGVybSI6eyJmaWVsZF9wcm9tb3RlZCI6dHJ1ZX19fX0seyJ0ZXJtcyI6eyJ0YXNrX2FyZWFfZXh0ZXJuYWxfaWQiOlsyNThdfX0seyJib29sIjp7InNob3VsZCI6W3sidGVybXMiOnsiZW1wbG95bWVudF9pZCI6WyI5MCIsIjkxIl19fSx7InRlcm1zIjp7ImVtcGxveW1lbnRfdHlwZV9pZCI6WyI5MCIsIjkxIl19fV0sIm1pbmltdW1fc2hvdWxkX21hdGNoIjoxfX1dfX0sInNvcnQiOlt7ImZpZWxkX3B1YmxpY2F0aW9uX3N0YXJ0cyI6eyJvcmRlciI6ImRlc2MifX0sIl9zY29yZSJdLCJzaXplIjozMH0=", "query": "/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja?area_filter=eastern&employment=90&employment=91&language=fi&task_areas=258&page=1", "email": "testi@mailhog.local", + "sms": "+358501234567", "search_description": "Esimerkkihaku", "site_id": "rekry", "lang": "fi" From 835aca71cd59a9b071c46f1ac50e37ead47dcab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 11:37:31 +0300 Subject: [PATCH 053/228] Add SMS collection Update gitignore --- .gitignore | 5 +++++ src/bin/hav-init-mongodb.ts | 30 ++++++++++++++++++++++++++++++ src/types/sms.ts | 16 ++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 src/types/sms.ts 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/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 402fd93..1da72bd 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -31,6 +31,7 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => const existingCollections = collections.map((c) => c.name); let queueResult = null; + let smsQueueResult = null; let subscriptionResult = null; // Email queue collection: stores pending notification emails @@ -62,6 +63,35 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => console.log('Queue collection already exists'); } + // SMS queue collection: stores pending notification SMS messages + if (!existingCollections.includes('smsqueue')) { + smsQueueResult = await db.createCollection('smsqueue', { + validator: { + $jsonSchema: { + bsonType: 'object', + title: 'Hakuvahti SMS queue', + required: ['sms', 'content'], + properties: { + _id: { + bsonType: 'objectId', + }, + sms: { + bsonType: 'string', + }, + content: { + bsonType: 'string', + }, + }, + }, + }, + }); + // eslint-disable-next-line no-console + console.log('SMS queue collection created:', smsQueueResult?.collectionName); + } else { + // eslint-disable-next-line no-console + console.log('SMS queue collection already exists'); + } + // Subscription collection: stores user search criteria and metadata if (!existingCollections.includes('subscription')) { subscriptionResult = await db.createCollection('subscription', { diff --git a/src/types/sms.ts b/src/types/sms.ts new file mode 100644 index 0000000..442753a --- /dev/null +++ b/src/types/sms.ts @@ -0,0 +1,16 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const SmsQueueDocument = Type.Object({ + _id: Type.Optional(Type.String()), + sms: Type.String(), + content: Type.String(), +}); + +export type SmsQueueDocumentType = Static; + +export const SmsQueueInsertDocument = Type.Object({ + sms: Type.String(), + content: Type.String(), +}); + +export type SmsQueueInsertDocumentType = Static; From acc3644dc34ba7747d699c6824586a88419123ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 13:40:20 +0300 Subject: [PATCH 054/228] Add script to send sms Fix typo in readme --- README.md | 2 +- package.json | 3 +- src/bin/hav-send-sms-in-queue.ts | 138 +++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/bin/hav-send-sms-in-queue.ts diff --git a/README.md b/README.md index 6e3fae2..c46908b 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Example configuration structure: ### Environment Selection The system automatically selects the correct environment configuration based on the `ENVIRONMENT` variable: -- Defaults to `dev` if `ENVIRONMENT` is not set +- Defaults to `local` if `ENVIRONMENT` is not set - Use `ENVIRONMENT=production` for production deployment - Sites usually have `local`, `dev`, `staging` and `production` environments diff --git a/package.json b/package.json index 996260f..6e083c3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "hav:migrate-site-id": "node dist/bin/hav-migrate-site-id.js", "hav:update-schema": "node dist/bin/hav-update-schema.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:send-emails-in-queue": "node dist/bin/hav-send-emails-in-queue.js", + "hav:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js" }, "keywords": [], "author": "", diff --git a/src/bin/hav-send-sms-in-queue.ts b/src/bin/hav-send-sms-in-queue.ts new file mode 100644 index 0000000..204d8ea --- /dev/null +++ b/src/bin/hav-send-sms-in-queue.ts @@ -0,0 +1,138 @@ +import { ObjectId } from '@fastify/mongodb'; +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import dotenv from 'dotenv'; +import fastify from 'fastify'; +import atv from '../plugins/atv'; +import mongodb from '../plugins/mongodb'; +import '../plugins/sentry'; +import type { AtvDocumentType } from '../types/atv'; + +dotenv.config(); + +const server = fastify({}); +const release = process.env.SENTRY_RELEASE ?? ''; + +server.register(fastifySentry, { + dsn: process.env.SENTRY_DSN, + environment: process.env.ENVIRONMENT, + release, + setErrorHandler: true, +}); + +// Register only needed plugins +// eslint-disable-next-line no-void +void server.register(mongodb); +// eslint-disable-next-line no-void +void server.register(atv); + +// Command line/cron application to send all SMS from queue collection +const BATCH_SIZE = 100; + +const app = async (): Promise => { + const checkInId = server.Sentry?.captureCheckIn({ + monitorSlug: 'hav-send-sms-in-queue', + status: 'in_progress', + }); + + const db = server.mongo?.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } + + const smsQueueCollection = db.collection('smsqueue'); + let hasMoreResults = true; + + while (hasMoreResults) { + // eslint-disable-next-line no-await-in-loop + const batch = await smsQueueCollection.find({}).limit(BATCH_SIZE).toArray(); + + if (batch.length === 0) { + hasMoreResults = false; + break; + } + + // Collect unique ATV document IDs + const atvIds = [...new Set(batch.map((item) => item.sms))]; + + // Get SMS phone numbers from ATV in batch + // eslint-disable-next-line no-await-in-loop + const atvDocuments: Partial = await server.atvGetDocumentBatch(atvIds); + + // Create map of ATV ID -> phone number + const phoneNumberMap = new Map(); + atvDocuments.forEach((doc) => { + if (doc?.id && doc?.content) { + try { + const content = JSON.parse(doc.content); + if (content.sms) { + phoneNumberMap.set(doc.id, content.sms); + } + } catch (error) { + console.error(`Failed to parse ATV document ${doc.id}:`, error); + } + } + }); + + // Process SMS messages sequentially + // eslint-disable-next-line no-await-in-loop + await batch.reduce(async (previousPromise, smsItem) => { + await previousPromise; + + const atvId = smsItem.sms; + const phoneNumber = phoneNumberMap.get(atvId); + const messageContent = smsItem.content; + + console.info('Processing SMS for ATV ID:', atvId); + + if (phoneNumber) { + // Send SMS here + // phoneNumber: recipient's phone number (e.g., "+358501234567") + // messageContent: SMS message to send + console.log(`Would send SMS to ${phoneNumber}: ${messageContent.substring(0, 50)}...`); + } else { + console.warn(`Phone number not found for ATV ID ${atvId}`); + } + + // Remove from queue regardless of send status + const deleteResult = await smsQueueCollection.deleteOne({ + _id: new ObjectId(smsItem._id), + }); + + if (deleteResult.deletedCount === 0) { + console.error(`Failed to delete SMS queue item ${smsItem._id}`); + } + + return Promise.resolve(); + }, Promise.resolve()); + } + + server.Sentry?.captureCheckIn({ + checkInId, + monitorSlug: 'hav-send-sms-in-queue', + status: 'ok', + }); +}; + +server.get('/', async function handleRootRequest(_request, _reply) { + await app(); + return { success: true }; +}); + +server.ready((_err) => { + // eslint-disable-next-line no-console + console.log('fastify server ready'); + server.inject( + { + method: 'GET', + url: '/', + }, + function handleInjectResponse(_injectErr, response) { + if (response) { + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); + } + + server.close(); + }, + ); +}); From ecbe962618422982ef1350ad870cb08cb569222e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 14:44:42 +0300 Subject: [PATCH 055/228] Add Dialogi SMS plugin and sms queue script. never tested, totally blind coded. test when api available --- .env.dist | 4 ++ README.md | 15 ++++++ src/bin/hav-send-sms-in-queue.ts | 16 ++++-- src/plugins/dialogi.ts | 90 ++++++++++++++++++++++++++++++++ src/types/dialogi.ts | 17 ++++++ 5 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 src/plugins/dialogi.ts create mode 100644 src/types/dialogi.ts diff --git a/.env.dist b/.env.dist index b069acb..4ef436b 100644 --- a/.env.dist +++ b/.env.dist @@ -20,3 +20,7 @@ MAIL_PORT=1025 MAIL_SECURE= MAIL_AUTH_USER= MAIL_AUTH_PASS= + +# Elisa Dialogi SMS service +DIALOGI_API_URL=https://dialogi.elisa.fi/api/v1 +DIALOGI_API_KEY= diff --git a/README.md b/README.md index c46908b..cdbb66b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Pre-requisities to use Hakuvahti are: - 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) + - `npm run hav:send-sms-in-queue` (optional, for SMS notifications - should be run at least once per minute) ## Configuration @@ -222,6 +223,20 @@ The system automatically selects the correct environment configuration based on `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://dialogi.elisa.fi/api/v1`) + +`DIALOGI_API_KEY` Set the API key/bearer token for Dialogi authentication + +**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) + # REST Endpoints: ## Add Subscription diff --git a/src/bin/hav-send-sms-in-queue.ts b/src/bin/hav-send-sms-in-queue.ts index 204d8ea..dba9ba3 100644 --- a/src/bin/hav-send-sms-in-queue.ts +++ b/src/bin/hav-send-sms-in-queue.ts @@ -3,6 +3,7 @@ import fastifySentry from '@immobiliarelabs/fastify-sentry'; import dotenv from 'dotenv'; import fastify from 'fastify'; import atv from '../plugins/atv'; +import dialogi from '../plugins/dialogi'; import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; import type { AtvDocumentType } from '../types/atv'; @@ -24,6 +25,8 @@ server.register(fastifySentry, { void server.register(mongodb); // eslint-disable-next-line no-void void server.register(atv); +// eslint-disable-next-line no-void +void server.register(dialogi); // Command line/cron application to send all SMS from queue collection const BATCH_SIZE = 100; @@ -85,10 +88,15 @@ const app = async (): Promise => { console.info('Processing SMS for ATV ID:', atvId); if (phoneNumber) { - // Send SMS here - // phoneNumber: recipient's phone number (e.g., "+358501234567") - // messageContent: SMS message to send - console.log(`Would send SMS to ${phoneNumber}: ${messageContent.substring(0, 50)}...`); + try { + // Send SMS using Dialogi plugin + await server.dialogi.sendSms(phoneNumber, messageContent); + console.log(`SMS sent successfully to ${phoneNumber}`); + } catch (error) { + // Log error but continue processing queue + server.Sentry?.captureException(error); + console.error(`Failed to send SMS to ${phoneNumber}:`, error); + } } else { console.warn(`Phone number not found for ATV ID ${atvId}`); } diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts new file mode 100644 index 0000000..244d13d --- /dev/null +++ b/src/plugins/dialogi.ts @@ -0,0 +1,90 @@ +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 to - Recipient phone number in E.164 format (e.g., "+358501234567") + * @param message - SMS message content + * @returns Promise with Dialogi API response + */ + sendSms(to: string, message: 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'); + } + + const dialogiClient: DialogiClient = { + async sendSms(to: string, message: string): Promise { + // Check if Dialogi is configured + if (!process.env.DIALOGI_API_URL || !process.env.DIALOGI_API_KEY) { + throw new Error('Dialogi SMS service is not configured. Please set DIALOGI_API_URL and DIALOGI_API_KEY'); + } + + try { + const requestBody: DialogiSmsRequestType = { + to, + message, + }; + + const response: AxiosResponse = await axios.post( + `${process.env.DIALOGI_API_URL}/send`, + requestBody, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.DIALOGI_API_KEY}`, + }, + timeout: 10000, // 10 second timeout + }, + ); + + fastify.log.info({ to, messageId: response.data.id }, 'SMS sent successfully via Dialogi'); + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || error.message; + fastify.log.error( + { + to, + 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({ to, 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/types/dialogi.ts b/src/types/dialogi.ts new file mode 100644 index 0000000..6136c92 --- /dev/null +++ b/src/types/dialogi.ts @@ -0,0 +1,17 @@ +import { type Static, Type } from '@sinclair/typebox'; + +// Request types for Elisa Dialogi SMS API +export const DialogiSmsRequest = Type.Object({ + to: Type.String(), // Phone number in E.164 format + message: Type.String(), // SMS message content +}); + +export type DialogiSmsRequestType = Static; + +// Response types for Elisa Dialogi SMS API +export const DialogiSmsResponse = Type.Object({ + id: Type.Optional(Type.String()), // Message ID from Dialogi + status: Type.Optional(Type.String()), // Status of the SMS +}); + +export type DialogiSmsResponseType = Static; From ab54c10fb8d149b05c8f1f2c342e439ccb8e4296 Mon Sep 17 00:00:00 2001 From: Lundelin Joonas Date: Thu, 16 Oct 2025 14:34:39 +0300 Subject: [PATCH 056/228] Set review database variables by Ansible scripts --- pipelines/helfi-hakuvahti-dev.yml | 49 ++++++++++++++++++++++++ pipelines/helfi-hakuvahti-release.yml | 46 +++++++++++++++++++++++ pipelines/helfi-hakuvahti-review.yml | 54 +++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 pipelines/helfi-hakuvahti-dev.yml create mode 100644 pipelines/helfi-hakuvahti-release.yml create mode 100644 pipelines/helfi-hakuvahti-review.yml diff --git a/pipelines/helfi-hakuvahti-dev.yml b/pipelines/helfi-hakuvahti-dev.yml new file mode 100644 index 0000000..6a366c9 --- /dev/null +++ b/pipelines/helfi-hakuvahti-dev.yml @@ -0,0 +1,49 @@ +# 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: + - '*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 +# 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-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-hakuvahti-pipelines + # The values here will override the values defined in the helfi-hakuvahti-pipelines repository + # buildArgs: + # DEBUG: 1 + # configMap: # pod environment variables + # DEBUG: 1 diff --git a/pipelines/helfi-hakuvahti-release.yml b/pipelines/helfi-hakuvahti-release.yml new file mode 100644 index 0000000..9dc9da3 --- /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: + - helfi-hakuvahti-v* + +# 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/pipelines/helfi-hakuvahti-review.yml b/pipelines/helfi-hakuvahti-review.yml new file mode 100644 index 0000000..3d78184 --- /dev/null +++ b/pipelines/helfi-hakuvahti-review.yml @@ -0,0 +1,54 @@ +# +# Review pipeline. Run build and deploy for Platta dev environments. +# Pipeline runs different tests e.g. unittest and browser tests. +# +# Continuous integration (CI) triggers cause a pipeline to run whenever you push +# an update to the specified branches or you push specified tags. +# only PR trigger pipeline +trigger: none + +# 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: + # PR target branch + branches: + include: + - main + paths: + exclude: + - '*release-please*' + - '*.md' + - '.github/' + - 'pipelines/helfi-hakuvahti-release.yml' + - 'pipelines/helfi-hakuvahti-dev.yml' + - '*compose*' + +# 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-review.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 + # buildArgs: + # DEBUG: 1 + # configMap: # pod environment variables + # DEBUG: 1 From 189b6f4dd563d24378ebcdc0479bcf6558487b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 16 Oct 2025 17:04:41 +0300 Subject: [PATCH 057/228] Add missing sms queue generating in populate script, test script for sending sms and sms templates --- .env.dist | 3 + README.md | 41 +++++++++- cron/queue-sms.sh | 7 ++ package.json | 3 +- src/bin/hav-populate-email-queue.ts | 49 +++++++++++- src/bin/hav-test-sms-sending.ts | 116 ++++++++++++++++++++++++++++ src/lib/email.ts | 15 ++++ src/templates/rekry/sms/sms-en.html | 1 + src/templates/rekry/sms/sms-fi.html | 1 + src/templates/rekry/sms/sms-sv.html | 1 + 10 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 cron/queue-sms.sh create mode 100644 src/bin/hav-test-sms-sending.ts create mode 100644 src/templates/rekry/sms/sms-en.html create mode 100644 src/templates/rekry/sms/sms-fi.html create mode 100644 src/templates/rekry/sms/sms-sv.html diff --git a/.env.dist b/.env.dist index 4ef436b..5fd65b4 100644 --- a/.env.dist +++ b/.env.dist @@ -24,3 +24,6 @@ MAIL_AUTH_PASS= # Elisa Dialogi SMS service DIALOGI_API_URL=https://dialogi.elisa.fi/api/v1 DIALOGI_API_KEY= + +# Testing +TEST_SMS_NUMBER= diff --git a/README.md b/README.md index cdbb66b..5b96740 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,10 @@ 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 @@ -289,14 +293,14 @@ Initialize MongoDB collections. Required before running populate or send command `npm run hav:populate-email-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 @@ -304,6 +308,35 @@ Adds following emails to the email queue: Sends emails in queue that were generated by `hav:populate-email-queue` +### Sends SMS from queue + +`npm run hav:send-sms-in-queue` + +Sends SMS messages in queue that were generated by `hav:populate-email-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. + ### Migration To migrate existing subscriptions to have `site_id` field, run: diff --git a/cron/queue-sms.sh b/cron/queue-sms.sh new file mode 100644 index 0000000..cb16b17 --- /dev/null +++ b/cron/queue-sms.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +cd /app + +echo "Sending SMS in queue" + +npm run hav:send-sms-in-queue diff --git a/package.json b/package.json index 6e083c3..5095c5d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "hav:update-schema": "node dist/bin/hav-update-schema.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:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js" + "hav:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js", + "hav:test-sms-sending": "node dist/bin/hav-test-sms-sending.js" }, "keywords": [], "author": "", diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index a2ba899..1fca6b9 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -4,8 +4,9 @@ import dotenv from 'dotenv'; import fastify from 'fastify'; import minimist from 'minimist'; -import { expiryEmail, newHitsEmail } from '../lib/email'; +import { expiryEmail, newHitsEmail, newHitsSms } from '../lib/email'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import atv from '../plugins/atv'; import base64Plugin from '../plugins/base64'; import elasticproxy from '../plugins/elasticproxy'; import mongodb from '../plugins/mongodb'; @@ -13,6 +14,7 @@ import '../plugins/sentry'; import type { ElasticProxyJsonResponseType, PartialDrupalNodeType } from '../types/elasticproxy'; import type { QueueInsertDocumentType } from '../types/mailer'; import type { SiteConfigurationType } from '../types/siteConfig'; +import type { SmsQueueInsertDocumentType } from '../types/sms'; import { type SubscriptionCollectionLanguageType, type SubscriptionCollectionType, @@ -32,6 +34,7 @@ interface ProcessingStats { subscriptionsChecked: number; expiryEmailsQueued: number; newResultsEmailsQueued: number; + smsQueued: number; } const server = fastify({}); @@ -51,6 +54,8 @@ void server.register(mongodb); void server.register(elasticproxy); // eslint-disable-next-line no-void void server.register(base64Plugin); +// eslint-disable-next-line no-void +void server.register(atv); export const getLocalizedUrl = ( siteConfig: SiteConfigurationType, @@ -169,8 +174,9 @@ const getNewHitsFromElasticsearch = async ( const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats: ProcessingStats): Promise => { const collection = server.mongo.db?.collection('subscription'); const queueCollection = server.mongo.db?.collection('queue'); + const smsQueueCollection = server.mongo.db?.collection('smsqueue'); - if (!collection || !queueCollection) { + if (!collection || !queueCollection || !smsQueueCollection) { throw new Error('MongoDB collections not available'); } @@ -279,6 +285,42 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats } stats.newResultsEmailsQueued++; + // Check if subscription has SMS in ATV and queue if present + try { + const atvDocs = await server.atvGetDocumentBatch([subscription.email]); + if (atvDocs?.[0]?.content) { + const atvContent = JSON.parse(atvDocs[0].content); + + // Only queue SMS if user provided one + if (atvContent.sms) { + const smsContent = await newHitsSms( + subscription.lang, + { + search_description: subscription.search_description, + search_link: subscription.query, + }, + siteConfig, + ); + + const smsToQueue: SmsQueueInsertDocumentType = { + sms: subscription.email, // atvDocumentId + content: smsContent, + }; + + if (isDryRun) { + // eslint-disable-next-line no-console + console.log(`[DRY RUN] Would queue SMS for ${subscription._id}`); + } else { + await smsQueueCollection.insertOne(smsToQueue); + } + stats.smsQueued++; + } + } + } catch (error) { + // Log error but don't break email sending + console.error(`Failed to check/queue SMS for ${subscription._id}:`, error); + } + return Promise.resolve(); }, Promise.resolve()); }; @@ -300,6 +342,7 @@ const app = async (): Promise => { subscriptionsChecked: 0, expiryEmailsQueued: 0, newResultsEmailsQueued: 0, + smsQueued: 0, }; try { @@ -354,6 +397,8 @@ const app = async (): Promise => { console.log(`Expiry emails queued: ${stats.expiryEmailsQueued}`); // eslint-disable-next-line no-console console.log(`New results emails queued: ${stats.newResultsEmailsQueued}`); + // eslint-disable-next-line no-console + console.log(`SMS queued: ${stats.smsQueued}`); if (isDryRun) { // eslint-disable-next-line no-console console.log('\n[DRY RUN] No changes were made to the database'); diff --git a/src/bin/hav-test-sms-sending.ts b/src/bin/hav-test-sms-sending.ts new file mode 100644 index 0000000..1f5afdf --- /dev/null +++ b/src/bin/hav-test-sms-sending.ts @@ -0,0 +1,116 @@ +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import dotenv from 'dotenv'; +import fastify from 'fastify'; +import { newHitsSms } from '../lib/email'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import dialogi from '../plugins/dialogi'; +import '../plugins/sentry'; + +dotenv.config(); + +const server = fastify({}); +const release = process.env.SENTRY_RELEASE ?? ''; + +server.register(fastifySentry, { + dsn: process.env.SENTRY_DSN, + environment: process.env.ENVIRONMENT, + release, + setErrorHandler: true, +}); + +// Register only needed plugins +// eslint-disable-next-line no-void +void server.register(dialogi); + +// Test script to verify SMS sending via Elisa Dialogi API +const app = async (): Promise => { + 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', + search_link: '/fi/avoimet-tyopaikat?search=test', + }, + siteConfig, + ); + + console.log(`Content: ${smsContent}`); + + // Send SMS via Dialogi API + try { + const response = await server.dialogi.sendSms(testPhoneNumber, smsContent); + console.log(`✓ SMS sent successfully! Message ID: ${response.id || 'N/A'}`); + } 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); + } +}; + +server.get('/', async function handleRootRequest(_request, _reply) { + await app(); + return { success: true }; +}); + +server.ready((_err) => { + // eslint-disable-next-line no-console + console.log('fastify server ready'); + server.inject( + { + method: 'GET', + url: '/', + }, + function handleInjectResponse(_injectErr, response) { + if (response) { + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); + } + + server.close(); + }, + ); +}); diff --git a/src/lib/email.ts b/src/lib/email.ts index 8aebd92..71de188 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -68,3 +68,18 @@ export const newHitsEmail = async ( throw error; } }; + +// SMS notification for new search results +export const newHitsSms = async ( + lang: SubscriptionCollectionLanguageType, + data: { + search_description: string; + search_link: string; + }, + siteConfig: SiteConfigurationType, +) => + sprightly(`dist/templates/${siteConfig.mail.templatePath}/sms/sms-${lang}.html`, { + lang, + search_description: data.search_description, + search_link: siteConfig.urls.base + data.search_link, + }); diff --git a/src/templates/rekry/sms/sms-en.html b/src/templates/rekry/sms/sms-en.html new file mode 100644 index 0000000..9a5ca83 --- /dev/null +++ b/src/templates/rekry/sms/sms-en.html @@ -0,0 +1 @@ +Search alert: New results for "{{ search_description }}". View results: {{ search_link }} diff --git a/src/templates/rekry/sms/sms-fi.html b/src/templates/rekry/sms/sms-fi.html new file mode 100644 index 0000000..a47847d --- /dev/null +++ b/src/templates/rekry/sms/sms-fi.html @@ -0,0 +1 @@ +Hakuvahti: Uusia tuloksia haulle "{{ search_description }}". Katso tulokset: {{ search_link }} diff --git a/src/templates/rekry/sms/sms-sv.html b/src/templates/rekry/sms/sms-sv.html new file mode 100644 index 0000000..b1f9af2 --- /dev/null +++ b/src/templates/rekry/sms/sms-sv.html @@ -0,0 +1 @@ +Sökbevakning: Nya resultat för sökningen "{{ search_description }}". Se resultat: {{ search_link }} From 0017e308abe191f15576d19b8dd4c0004cca414f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 17 Oct 2025 09:08:09 +0300 Subject: [PATCH 058/228] Target url was wrong --- src/plugins/dialogi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts index 244d13d..adf3d3a 100644 --- a/src/plugins/dialogi.ts +++ b/src/plugins/dialogi.ts @@ -44,7 +44,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { }; const response: AxiosResponse = await axios.post( - `${process.env.DIALOGI_API_URL}/send`, + process.env.DIALOGI_API_URL, requestBody, { headers: { From f49c41084f8be1723d869a121e1c975bef004051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 22 Oct 2025 08:01:37 +0300 Subject: [PATCH 059/228] Add some missing stuff to dialogi --- README.md | 4 +++- src/plugins/dialogi.ts | 29 ++++++++++++++++++----------- src/types/dialogi.ts | 5 +++-- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5b96740..ab070a3 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,12 @@ The system automatically selects the correct environment configuration based on 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://dialogi.elisa.fi/api/v1`) +`DIALOGI_API_URL` Set the Elisa Dialogi API base URL (for example `https://dialogi.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: diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts index adf3d3a..d253053 100644 --- a/src/plugins/dialogi.ts +++ b/src/plugins/dialogi.ts @@ -13,11 +13,11 @@ import type { DialogiSmsRequestType, DialogiSmsResponseType } from '../types/dia export interface DialogiClient { /** * Send an SMS message - * @param to - Recipient phone number in E.164 format (e.g., "+358501234567") - * @param message - SMS message content + * @param destination - Recipient phone number in E.164 format (e.g., "+358501234567") + * @param text - SMS message content * @returns Promise with Dialogi API response */ - sendSms(to: string, message: string): Promise; + sendSms(destination: string, text: string): Promise; } export default fp(async function dialogiPlugin(fastify: FastifyInstance) { @@ -30,17 +30,24 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { 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(to: string, message: string): Promise { + async sendSms(destination: string, text: string): Promise { // Check if Dialogi is configured - if (!process.env.DIALOGI_API_URL || !process.env.DIALOGI_API_KEY) { - throw new Error('Dialogi SMS service is not configured. Please set DIALOGI_API_URL and DIALOGI_API_KEY'); + 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 = { - to, - message, + sender: process.env.DIALOGI_SENDER, + destination, + text, }; const response: AxiosResponse = await axios.post( @@ -55,7 +62,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { }, ); - fastify.log.info({ to, messageId: response.data.id }, 'SMS sent successfully via Dialogi'); + fastify.log.info({ destination, messageId: response.data.id }, 'SMS sent successfully via Dialogi'); return response.data; } catch (error) { @@ -63,7 +70,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { const errorMessage = error.response?.data?.message || error.message; fastify.log.error( { - to, + destination, error: errorMessage, status: error.response?.status, statusText: error.response?.statusText, @@ -73,7 +80,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { throw new Error(`Dialogi SMS API error: ${errorMessage}`); } - fastify.log.error({ to, error }, 'Unexpected error sending SMS via Dialogi'); + fastify.log.error({ destination, error }, 'Unexpected error sending SMS via Dialogi'); throw error; } }, diff --git a/src/types/dialogi.ts b/src/types/dialogi.ts index 6136c92..aa67fc2 100644 --- a/src/types/dialogi.ts +++ b/src/types/dialogi.ts @@ -2,8 +2,9 @@ import { type Static, Type } from '@sinclair/typebox'; // Request types for Elisa Dialogi SMS API export const DialogiSmsRequest = Type.Object({ - to: Type.String(), // Phone number in E.164 format - message: Type.String(), // SMS message content + 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; From e9af812866f752618e5daf5a354efa87bf2f054a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 22 Oct 2025 13:47:19 +0300 Subject: [PATCH 060/228] add validateElasticQuery to validate queries sent to hakuvahti --- src/plugins/validateElasticQuery.ts | 74 +++++++++++++++++++++++++++++ src/routes/addSubscription.ts | 15 +++++- src/types/error.ts | 6 +++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/plugins/validateElasticQuery.ts diff --git a/src/plugins/validateElasticQuery.ts b/src/plugins/validateElasticQuery.ts new file mode 100644 index 0000000..6098f5a --- /dev/null +++ b/src/plugins/validateElasticQuery.ts @@ -0,0 +1,74 @@ +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 {FastifyRequest} request - the request object + * @return {void} no return value + */ +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}`); + } + + 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 b66c93a..1cee850 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -1,7 +1,7 @@ import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; import { confirmationEmail } from '../lib/email'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; -import { Generic500Error, type Generic500ErrorType } from '../types/error'; +import { Generic400Error, type Generic400ErrorType, Generic500Error, type Generic500ErrorType } from '../types/error'; import type { QueueInsertDocumentType } from '../types/mailer'; import { type SubscriptionCollectionType, @@ -17,7 +17,7 @@ import { const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { fastify.post<{ Body: SubscriptionRequestType; - Reply: SubscriptionResponseType | Generic500ErrorType; + Reply: SubscriptionResponseType | Generic400ErrorType | Generic500ErrorType; }>( '/subscription', { @@ -25,6 +25,7 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: body: SubscriptionRequest, response: { 200: SubscriptionResponse, + 400: Generic400Error, 500: Generic500Error, }, }, @@ -34,6 +35,16 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: const collection = mongodb.db?.collection('subscription'); const hash = fastify.getRandHash(); + // Check if elastic query validation failed + 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'}`, + }); + } + // Replace email in request with ATV hashed email if (!request?.atvResponse?.atvDocumentId) return reply diff --git a/src/types/error.ts b/src/types/error.ts index 4249009..6a55bbc 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -1,5 +1,11 @@ import { type Static, Type } from '@sinclair/typebox'; +export const Generic400Error = Type.Object({ + error: Type.String(), +}); + +export type Generic400ErrorType = Static; + export const Generic500Error = Type.Object({ email: Type.Optional(Type.String()), error: Type.Optional(Type.String()), From 340153d85ea0fc2193479f6380389e1906c11dca Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Wed, 22 Oct 2025 15:42:25 +0300 Subject: [PATCH 061/228] Update helfi-hakuvahti-dev.yml --- pipelines/helfi-hakuvahti-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipelines/helfi-hakuvahti-dev.yml b/pipelines/helfi-hakuvahti-dev.yml index 6a366c9..f2489e5 100644 --- a/pipelines/helfi-hakuvahti-dev.yml +++ b/pipelines/helfi-hakuvahti-dev.yml @@ -4,7 +4,7 @@ trigger: batch: true branches: include: - - main + - dev paths: exclude: - '*release-please*' From f56ba60382d8414eb29572a2614086b49287b128 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Wed, 22 Oct 2025 16:07:52 +0300 Subject: [PATCH 062/228] Use correct CMD in dockerfile --- openshift/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 28c62a9..0f11382 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -28,4 +28,4 @@ EXPOSE 3000 USER nobody:0 -CMD ["fastify", "start", "-l", "info", "dist/app.js"] +CMD ["npx", "fastify", "start", "-l", "info", "dist/app.js"] From e1ea20f4d958c1eeed85753a25f88b323e978763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 22 Oct 2025 17:30:18 +0300 Subject: [PATCH 063/228] Silence fastify default logging for health routes --- src/routes/healthzAndReadiness.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/healthzAndReadiness.ts b/src/routes/healthzAndReadiness.ts index d25eb89..eb92e23 100644 --- a/src/routes/healthzAndReadiness.ts +++ b/src/routes/healthzAndReadiness.ts @@ -4,6 +4,7 @@ const healthzAndReadiness: FastifyPluginAsync = async (fastify: FastifyInstance, fastify.get( '/healthz', { + logLevel: 'silent', schema: { response: { 200: { @@ -27,6 +28,7 @@ const healthzAndReadiness: FastifyPluginAsync = async (fastify: FastifyInstance, fastify.get( '/readiness', { + logLevel: 'silent', schema: { response: { 200: { From f0ac50cfd390a45566d28f972716119b5a6b2a05 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Wed, 22 Oct 2025 17:44:05 +0300 Subject: [PATCH 064/228] Delete pipelines/helfi-hakuvahti-review.yml Review environments don't work with our mongo yet, so let's enable this without review trigger. --- pipelines/helfi-hakuvahti-review.yml | 54 ---------------------------- 1 file changed, 54 deletions(-) delete mode 100644 pipelines/helfi-hakuvahti-review.yml diff --git a/pipelines/helfi-hakuvahti-review.yml b/pipelines/helfi-hakuvahti-review.yml deleted file mode 100644 index 3d78184..0000000 --- a/pipelines/helfi-hakuvahti-review.yml +++ /dev/null @@ -1,54 +0,0 @@ -# -# Review pipeline. Run build and deploy for Platta dev environments. -# Pipeline runs different tests e.g. unittest and browser tests. -# -# Continuous integration (CI) triggers cause a pipeline to run whenever you push -# an update to the specified branches or you push specified tags. -# only PR trigger pipeline -trigger: none - -# 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: - # PR target branch - branches: - include: - - main - paths: - exclude: - - '*release-please*' - - '*.md' - - '.github/' - - 'pipelines/helfi-hakuvahti-release.yml' - - 'pipelines/helfi-hakuvahti-dev.yml' - - '*compose*' - -# 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-review.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 - # buildArgs: - # DEBUG: 1 - # configMap: # pod environment variables - # DEBUG: 1 From b7358e0ffda662cb036e1448d8acaa5f8b8785c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 23 Oct 2025 08:54:48 +0300 Subject: [PATCH 065/228] Remove lint:check from build --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 996260f..5b9c985 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "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", - "build:ts": "npm run lint:check && npm run copy:assets; tsc", + "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\"", From 7155be65f37ffec33cde9e3572c0a0b49229a693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 23 Oct 2025 09:40:17 +0300 Subject: [PATCH 066/228] Fix nodemailer security --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 733496c..859aefd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "fastify-plugin": "^4.0.0", "jsdom": "^24.0.0", "minimist": "^1.2.8", - "nodemailer": "^6.9.9", + "nodemailer": "^7.0.10", "sprightly": "^2.0.1" }, "devDependencies": { @@ -2238,9 +2238,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz", + "integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index 5b9c985..74c467d 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "fastify-plugin": "^4.0.0", "jsdom": "^24.0.0", "minimist": "^1.2.8", - "nodemailer": "^6.9.9", + "nodemailer": "^7.0.10", "sprightly": "^2.0.1" }, "devDependencies": { From 176b0f4920021a3ea351d6d034e779823e96840e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 2 Oct 2025 14:06:33 +0300 Subject: [PATCH 067/228] feat: Add optional SMS parameter to subscription API - Add optional 'sms' field to SubscriptionRequest schema - Validate SMS in international E.164 format (e.g., +358451234567) - Store SMS with email in same ATV document for security - Move all validation logic to route layer for cleaner architecture - Remove email validation from ATV plugin (now pure storage layer) SMS is optional and stored encrypted in ATV alongside email. Only the ATV document ID is saved to MongoDB, maintaining data separation. --- src/plugins/atv.ts | 36 +++++++++++++++-------------------- src/routes/addSubscription.ts | 33 ++++++++++++++++++++++++++++++++ src/types/subscription.ts | 1 + 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 36638e7..3b57094 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -35,12 +35,13 @@ const atvFetchContentById = async (atvDocumentId: string): Promise>} the created document */ -const atvCreateDocumentWithEmail = async (email: string): Promise> => { +const atvCreateDocumentWithEmail = async (email: string, sms?: string): Promise> => { try { const timestamp = Math.floor(Date.now() / 1000).toString(); @@ -51,13 +52,14 @@ const atvCreateDocumentWithEmail = async (email: string): Promise = { - 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': 'atvCreateDocumentWithEmail', + 'tos_record_id': timestamp, + 'delete_after': deleteAfter.toISOString().substring(0, 10), + 'content': JSON.stringify({ + 'email': email, + ...(sms && { 'sms': sms }) + }) }; const response: AxiosResponse> = await axios.post( @@ -124,15 +126,12 @@ const requestEmailHook = async (request: FastifyRequestType) => { } // 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. + // and store email and optional SMS 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(); + const sms: string | undefined = body.sms?.trim(); - if (!isValidEmail(email)) { - throw new Error('Invalid email format'); - } - - const atvDocument: Partial = await atvCreateDocumentWithEmail(email); + const atvDocument: Partial = await atvCreateDocumentWithEmail(email, sms); const atvDocumentId: string | undefined = atvDocument.id; if (atvDocumentId) { @@ -146,11 +145,6 @@ const requestEmailHook = async (request: FastifyRequestType) => { } }; -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 @@ -180,7 +174,7 @@ declare module 'fastify' { export interface FastifyInstance { atvQueryEmail(email: string): Promise>; - atvCreateDocumentWithEmail: (email: string) => Promise>; + atvCreateDocumentWithEmail: (email: string, sms?: string) => Promise>; atvGetDocumentBatch: (emails: string[]) => Promise>; } } diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index b66c93a..371875a 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -12,6 +12,18 @@ import { 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 isValidSms = (sms: string): boolean => { + // E.164 international format: + followed by 1-15 digits + const re = /^\+[1-9]\d{1,14}$/; + return re.test(sms); +}; + // Add subscription to given query parameters const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { @@ -54,6 +66,22 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: .send({ error: 'Invalid site_id provided.' }); } + // Validate email (required) + if (!isValidEmail(request.body.email?.trim())) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid email format.' }); + } + + // Validate SMS (optional) + if (request.body.sms && !isValidSms(request.body.sms.trim())) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid SMS format. Use international format (e.g., +358451234567).' }); + } + // Subscription data that goes to collection const subscriptionData: Partial = { ...request.body, @@ -65,6 +93,11 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: status: SubscriptionStatus.INACTIVE, }; + // Remove SMS from request body (it's already stored in ATV document) + if (request.body.sms) { + delete request.body.sms; + } + const response = await collection?.insertOne(subscriptionData); if (!response) { fastify.log.debug(response); diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 777b08e..427a0f9 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -43,6 +43,7 @@ export const SubscriptionRequest = Type.Object({ search_description: Type.Optional(Type.String()), site_id: Type.String(), lang: SubscriptionCollectionLanguage, + sms: Type.Optional(Type.String()) }); export type SubscriptionRequestType = Static; From 4d3d8e7191cf5de7cbfbd8383459eaf9c1f3c60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 09:39:01 +0300 Subject: [PATCH 068/228] Move validation funcs to controller --- src/plugins/atv.ts | 27 +++++++++++---------- src/routes/addSubscription.ts | 44 ++++++++++++++++++----------------- src/types/subscription.ts | 2 +- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 3b57094..55b7286 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -52,14 +52,14 @@ const atvCreateDocumentWithEmail = async (email: string, sms?: string): Promise< // 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, - ...(sms && { 'sms': sms }) - }) + draft: 'false', + tos_function_id: 'atvCreateDocumentWithEmail', + tos_record_id: timestamp, + delete_after: deleteAfter.toISOString().substring(0, 10), + content: JSON.stringify({ + email: email, + ...(sms && { sms: sms }), + }), }; const response: AxiosResponse> = await axios.post( @@ -105,15 +105,14 @@ const atvGetDocumentBatch = async (emails: string[]): Promise { atvDocumentId, }; } + + // Remove SMS from request body after ATV storage (it shouldn't go to MongoDB) + if (body.sms) { + delete body.sms; + } } catch (error) { console.error('An error occurred:', error); throw new Error('Could not create document to ATV. Cannot subscribe.'); } }; - export default fp(async (fastify, _opts) => { // Hook handler automatically creates ATV document for the email // and sets the returned documentId to atvResponse.email variable diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index 371875a..826e0cc 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -14,7 +14,8 @@ import { // 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,}))$/; + 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()); }; @@ -40,6 +41,25 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: 500: Generic500Error, }, }, + preHandler: async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { + // Validate email and SMS BEFORE ATV document creation + const email = request.body.email?.trim(); + const sms = request.body.sms?.trim(); + + if (!isValidEmail(email)) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid email format.' }); + } + + if (sms && !isValidSms(sms)) { + return reply + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: 'Invalid SMS format. Use international format (e.g., +358451234567).' }); + } + }, }, async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { const mongodb = fastify.mongo; @@ -66,22 +86,6 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: .send({ error: 'Invalid site_id provided.' }); } - // Validate email (required) - if (!isValidEmail(request.body.email?.trim())) { - return reply - .code(400) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Invalid email format.' }); - } - - // Validate SMS (optional) - if (request.body.sms && !isValidSms(request.body.sms.trim())) { - return reply - .code(400) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Invalid SMS format. Use international format (e.g., +358451234567).' }); - } - // Subscription data that goes to collection const subscriptionData: Partial = { ...request.body, @@ -93,10 +97,8 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: status: SubscriptionStatus.INACTIVE, }; - // Remove SMS from request body (it's already stored in ATV document) - if (request.body.sms) { - delete request.body.sms; - } + // SMS is already stored in ATV document, no need to store in MongoDB + // It was removed by the ATV hook after validation const response = await collection?.insertOne(subscriptionData); if (!response) { diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 427a0f9..89193a7 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -43,7 +43,7 @@ export const SubscriptionRequest = Type.Object({ search_description: Type.Optional(Type.String()), site_id: Type.String(), lang: SubscriptionCollectionLanguage, - sms: Type.Optional(Type.String()) + sms: Type.Optional(Type.String()), }); export type SubscriptionRequestType = Static; From 73feef6d42647cadd1ea565dcdf6e576666a29df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 11:19:28 +0300 Subject: [PATCH 069/228] Validate SMS format before saving to ATV Update REST example with SMS --- src/routes/addSubscription.ts | 3 ++- test/requests/addSubscription.rest | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index 826e0cc..9f8d39c 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -41,8 +41,9 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: 500: Generic500Error, }, }, - preHandler: async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { + preValidation: async (request: FastifyRequest<{ Body: SubscriptionRequestType }>, reply: FastifyReply) => { // 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(); diff --git a/test/requests/addSubscription.rest b/test/requests/addSubscription.rest index b5485b4..21ba96d 100644 --- a/test/requests/addSubscription.rest +++ b/test/requests/addSubscription.rest @@ -7,6 +7,7 @@ token: test "elastic_query": "eyJhZ2dzIjp7ImZpZWxkX2pvYnMiOnsic3VtIjp7ImZpZWxkIjoiZmllbGRfam9icyIsIm1pc3NpbmciOjF9fSwidG90YWxfY291bnQiOnsiY2FyZGluYWxpdHkiOnsiZmllbGQiOiJmaWVsZF9yZWNydWl0bWVudF9pZC5rZXl3b3JkIn19fSwiY29sbGFwc2UiOnsiZmllbGQiOiJmaWVsZF9yZWNydWl0bWVudF9pZC5rZXl3b3JkIiwiaW5uZXJfaGl0cyI6eyJuYW1lIjoidHJhbnNsYXRpb25zIiwic2l6ZSI6M319LCJmcm9tIjowLCJxdWVyeSI6eyJib29sIjp7ImZpbHRlciI6W3sidGVybSI6eyJlbnRpdHlfdHlwZSI6Im5vZGUifX0seyJ0ZXJtIjp7Il9sYW5ndWFnZSI6ImZpIn19LHsidGVybXMiOnsiZmllbGRfcG9zdGFsX2NvZGUiOlsiMDA4ODAiLCIwMDg5MCIsIjAwOTAwIiwiMDA5MTAiLCIwMDkyMCIsIjAwOTMwIiwiMDA5NDAiLCIwMDk1MCIsIjAwOTYwIiwiMDA5NzAiLCIwMDk4MCIsIjAwOTkwIl19fV0sIm11c3QiOlt7ImJvb2wiOnsibXVzdF9ub3QiOnsidGVybSI6eyJmaWVsZF9wcm9tb3RlZCI6dHJ1ZX19fX0seyJ0ZXJtcyI6eyJ0YXNrX2FyZWFfZXh0ZXJuYWxfaWQiOlsyNThdfX0seyJib29sIjp7InNob3VsZCI6W3sidGVybXMiOnsiZW1wbG95bWVudF9pZCI6WyI5MCIsIjkxIl19fSx7InRlcm1zIjp7ImVtcGxveW1lbnRfdHlwZV9pZCI6WyI5MCIsIjkxIl19fV0sIm1pbmltdW1fc2hvdWxkX21hdGNoIjoxfX1dfX0sInNvcnQiOlt7ImZpZWxkX3B1YmxpY2F0aW9uX3N0YXJ0cyI6eyJvcmRlciI6ImRlc2MifX0sIl9zY29yZSJdLCJzaXplIjozMH0=", "query": "/fi/avoimet-tyopaikat/etsi-avoimia-tyopaikkoja?area_filter=eastern&employment=90&employment=91&language=fi&task_areas=258&page=1", "email": "testi@mailhog.local", + "sms": "+358501234567", "search_description": "Esimerkkihaku", "site_id": "rekry", "lang": "fi" From e6bed9ad6fe49b9680606a67a6b5116d3b082276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 11:37:31 +0300 Subject: [PATCH 070/228] Add SMS collection Update gitignore --- .gitignore | 5 +++++ src/bin/hav-init-mongodb.ts | 30 ++++++++++++++++++++++++++++++ src/types/sms.ts | 16 ++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 src/types/sms.ts 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/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 402fd93..1da72bd 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -31,6 +31,7 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => const existingCollections = collections.map((c) => c.name); let queueResult = null; + let smsQueueResult = null; let subscriptionResult = null; // Email queue collection: stores pending notification emails @@ -62,6 +63,35 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => console.log('Queue collection already exists'); } + // SMS queue collection: stores pending notification SMS messages + if (!existingCollections.includes('smsqueue')) { + smsQueueResult = await db.createCollection('smsqueue', { + validator: { + $jsonSchema: { + bsonType: 'object', + title: 'Hakuvahti SMS queue', + required: ['sms', 'content'], + properties: { + _id: { + bsonType: 'objectId', + }, + sms: { + bsonType: 'string', + }, + content: { + bsonType: 'string', + }, + }, + }, + }, + }); + // eslint-disable-next-line no-console + console.log('SMS queue collection created:', smsQueueResult?.collectionName); + } else { + // eslint-disable-next-line no-console + console.log('SMS queue collection already exists'); + } + // Subscription collection: stores user search criteria and metadata if (!existingCollections.includes('subscription')) { subscriptionResult = await db.createCollection('subscription', { diff --git a/src/types/sms.ts b/src/types/sms.ts new file mode 100644 index 0000000..442753a --- /dev/null +++ b/src/types/sms.ts @@ -0,0 +1,16 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const SmsQueueDocument = Type.Object({ + _id: Type.Optional(Type.String()), + sms: Type.String(), + content: Type.String(), +}); + +export type SmsQueueDocumentType = Static; + +export const SmsQueueInsertDocument = Type.Object({ + sms: Type.String(), + content: Type.String(), +}); + +export type SmsQueueInsertDocumentType = Static; From 4adec52b0812b4d926a299d2d3c77d49c9e96bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 13:40:20 +0300 Subject: [PATCH 071/228] Add script to send sms Fix typo in readme --- README.md | 2 +- package.json | 3 +- src/bin/hav-send-sms-in-queue.ts | 138 +++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/bin/hav-send-sms-in-queue.ts diff --git a/README.md b/README.md index 6e3fae2..c46908b 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Example configuration structure: ### Environment Selection The system automatically selects the correct environment configuration based on the `ENVIRONMENT` variable: -- Defaults to `dev` if `ENVIRONMENT` is not set +- Defaults to `local` if `ENVIRONMENT` is not set - Use `ENVIRONMENT=production` for production deployment - Sites usually have `local`, `dev`, `staging` and `production` environments diff --git a/package.json b/package.json index 996260f..6e083c3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "hav:migrate-site-id": "node dist/bin/hav-migrate-site-id.js", "hav:update-schema": "node dist/bin/hav-update-schema.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:send-emails-in-queue": "node dist/bin/hav-send-emails-in-queue.js", + "hav:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js" }, "keywords": [], "author": "", diff --git a/src/bin/hav-send-sms-in-queue.ts b/src/bin/hav-send-sms-in-queue.ts new file mode 100644 index 0000000..204d8ea --- /dev/null +++ b/src/bin/hav-send-sms-in-queue.ts @@ -0,0 +1,138 @@ +import { ObjectId } from '@fastify/mongodb'; +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import dotenv from 'dotenv'; +import fastify from 'fastify'; +import atv from '../plugins/atv'; +import mongodb from '../plugins/mongodb'; +import '../plugins/sentry'; +import type { AtvDocumentType } from '../types/atv'; + +dotenv.config(); + +const server = fastify({}); +const release = process.env.SENTRY_RELEASE ?? ''; + +server.register(fastifySentry, { + dsn: process.env.SENTRY_DSN, + environment: process.env.ENVIRONMENT, + release, + setErrorHandler: true, +}); + +// Register only needed plugins +// eslint-disable-next-line no-void +void server.register(mongodb); +// eslint-disable-next-line no-void +void server.register(atv); + +// Command line/cron application to send all SMS from queue collection +const BATCH_SIZE = 100; + +const app = async (): Promise => { + const checkInId = server.Sentry?.captureCheckIn({ + monitorSlug: 'hav-send-sms-in-queue', + status: 'in_progress', + }); + + const db = server.mongo?.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } + + const smsQueueCollection = db.collection('smsqueue'); + let hasMoreResults = true; + + while (hasMoreResults) { + // eslint-disable-next-line no-await-in-loop + const batch = await smsQueueCollection.find({}).limit(BATCH_SIZE).toArray(); + + if (batch.length === 0) { + hasMoreResults = false; + break; + } + + // Collect unique ATV document IDs + const atvIds = [...new Set(batch.map((item) => item.sms))]; + + // Get SMS phone numbers from ATV in batch + // eslint-disable-next-line no-await-in-loop + const atvDocuments: Partial = await server.atvGetDocumentBatch(atvIds); + + // Create map of ATV ID -> phone number + const phoneNumberMap = new Map(); + atvDocuments.forEach((doc) => { + if (doc?.id && doc?.content) { + try { + const content = JSON.parse(doc.content); + if (content.sms) { + phoneNumberMap.set(doc.id, content.sms); + } + } catch (error) { + console.error(`Failed to parse ATV document ${doc.id}:`, error); + } + } + }); + + // Process SMS messages sequentially + // eslint-disable-next-line no-await-in-loop + await batch.reduce(async (previousPromise, smsItem) => { + await previousPromise; + + const atvId = smsItem.sms; + const phoneNumber = phoneNumberMap.get(atvId); + const messageContent = smsItem.content; + + console.info('Processing SMS for ATV ID:', atvId); + + if (phoneNumber) { + // Send SMS here + // phoneNumber: recipient's phone number (e.g., "+358501234567") + // messageContent: SMS message to send + console.log(`Would send SMS to ${phoneNumber}: ${messageContent.substring(0, 50)}...`); + } else { + console.warn(`Phone number not found for ATV ID ${atvId}`); + } + + // Remove from queue regardless of send status + const deleteResult = await smsQueueCollection.deleteOne({ + _id: new ObjectId(smsItem._id), + }); + + if (deleteResult.deletedCount === 0) { + console.error(`Failed to delete SMS queue item ${smsItem._id}`); + } + + return Promise.resolve(); + }, Promise.resolve()); + } + + server.Sentry?.captureCheckIn({ + checkInId, + monitorSlug: 'hav-send-sms-in-queue', + status: 'ok', + }); +}; + +server.get('/', async function handleRootRequest(_request, _reply) { + await app(); + return { success: true }; +}); + +server.ready((_err) => { + // eslint-disable-next-line no-console + console.log('fastify server ready'); + server.inject( + { + method: 'GET', + url: '/', + }, + function handleInjectResponse(_injectErr, response) { + if (response) { + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); + } + + server.close(); + }, + ); +}); From 9df1bae73962783c50bad0acbd85cb270773f62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 7 Oct 2025 14:44:42 +0300 Subject: [PATCH 072/228] Add Dialogi SMS plugin and sms queue script. never tested, totally blind coded. test when api available --- .env.dist | 4 ++ README.md | 15 ++++++ src/bin/hav-send-sms-in-queue.ts | 16 ++++-- src/plugins/dialogi.ts | 90 ++++++++++++++++++++++++++++++++ src/types/dialogi.ts | 17 ++++++ 5 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 src/plugins/dialogi.ts create mode 100644 src/types/dialogi.ts diff --git a/.env.dist b/.env.dist index b069acb..4ef436b 100644 --- a/.env.dist +++ b/.env.dist @@ -20,3 +20,7 @@ MAIL_PORT=1025 MAIL_SECURE= MAIL_AUTH_USER= MAIL_AUTH_PASS= + +# Elisa Dialogi SMS service +DIALOGI_API_URL=https://dialogi.elisa.fi/api/v1 +DIALOGI_API_KEY= diff --git a/README.md b/README.md index c46908b..cdbb66b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Pre-requisities to use Hakuvahti are: - 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) + - `npm run hav:send-sms-in-queue` (optional, for SMS notifications - should be run at least once per minute) ## Configuration @@ -222,6 +223,20 @@ The system automatically selects the correct environment configuration based on `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://dialogi.elisa.fi/api/v1`) + +`DIALOGI_API_KEY` Set the API key/bearer token for Dialogi authentication + +**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) + # REST Endpoints: ## Add Subscription diff --git a/src/bin/hav-send-sms-in-queue.ts b/src/bin/hav-send-sms-in-queue.ts index 204d8ea..dba9ba3 100644 --- a/src/bin/hav-send-sms-in-queue.ts +++ b/src/bin/hav-send-sms-in-queue.ts @@ -3,6 +3,7 @@ import fastifySentry from '@immobiliarelabs/fastify-sentry'; import dotenv from 'dotenv'; import fastify from 'fastify'; import atv from '../plugins/atv'; +import dialogi from '../plugins/dialogi'; import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; import type { AtvDocumentType } from '../types/atv'; @@ -24,6 +25,8 @@ server.register(fastifySentry, { void server.register(mongodb); // eslint-disable-next-line no-void void server.register(atv); +// eslint-disable-next-line no-void +void server.register(dialogi); // Command line/cron application to send all SMS from queue collection const BATCH_SIZE = 100; @@ -85,10 +88,15 @@ const app = async (): Promise => { console.info('Processing SMS for ATV ID:', atvId); if (phoneNumber) { - // Send SMS here - // phoneNumber: recipient's phone number (e.g., "+358501234567") - // messageContent: SMS message to send - console.log(`Would send SMS to ${phoneNumber}: ${messageContent.substring(0, 50)}...`); + try { + // Send SMS using Dialogi plugin + await server.dialogi.sendSms(phoneNumber, messageContent); + console.log(`SMS sent successfully to ${phoneNumber}`); + } catch (error) { + // Log error but continue processing queue + server.Sentry?.captureException(error); + console.error(`Failed to send SMS to ${phoneNumber}:`, error); + } } else { console.warn(`Phone number not found for ATV ID ${atvId}`); } diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts new file mode 100644 index 0000000..244d13d --- /dev/null +++ b/src/plugins/dialogi.ts @@ -0,0 +1,90 @@ +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 to - Recipient phone number in E.164 format (e.g., "+358501234567") + * @param message - SMS message content + * @returns Promise with Dialogi API response + */ + sendSms(to: string, message: 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'); + } + + const dialogiClient: DialogiClient = { + async sendSms(to: string, message: string): Promise { + // Check if Dialogi is configured + if (!process.env.DIALOGI_API_URL || !process.env.DIALOGI_API_KEY) { + throw new Error('Dialogi SMS service is not configured. Please set DIALOGI_API_URL and DIALOGI_API_KEY'); + } + + try { + const requestBody: DialogiSmsRequestType = { + to, + message, + }; + + const response: AxiosResponse = await axios.post( + `${process.env.DIALOGI_API_URL}/send`, + requestBody, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.DIALOGI_API_KEY}`, + }, + timeout: 10000, // 10 second timeout + }, + ); + + fastify.log.info({ to, messageId: response.data.id }, 'SMS sent successfully via Dialogi'); + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || error.message; + fastify.log.error( + { + to, + 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({ to, 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/types/dialogi.ts b/src/types/dialogi.ts new file mode 100644 index 0000000..6136c92 --- /dev/null +++ b/src/types/dialogi.ts @@ -0,0 +1,17 @@ +import { type Static, Type } from '@sinclair/typebox'; + +// Request types for Elisa Dialogi SMS API +export const DialogiSmsRequest = Type.Object({ + to: Type.String(), // Phone number in E.164 format + message: Type.String(), // SMS message content +}); + +export type DialogiSmsRequestType = Static; + +// Response types for Elisa Dialogi SMS API +export const DialogiSmsResponse = Type.Object({ + id: Type.Optional(Type.String()), // Message ID from Dialogi + status: Type.Optional(Type.String()), // Status of the SMS +}); + +export type DialogiSmsResponseType = Static; From db2187bedc76b6f889e57d0389e6f9ffa9b1e23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 16 Oct 2025 17:04:41 +0300 Subject: [PATCH 073/228] Add missing sms queue generating in populate script, test script for sending sms and sms templates --- .env.dist | 3 + README.md | 41 +++++++++- cron/queue-sms.sh | 7 ++ package.json | 3 +- src/bin/hav-populate-email-queue.ts | 49 +++++++++++- src/bin/hav-test-sms-sending.ts | 116 ++++++++++++++++++++++++++++ src/lib/email.ts | 15 ++++ src/templates/rekry/sms/sms-en.html | 1 + src/templates/rekry/sms/sms-fi.html | 1 + src/templates/rekry/sms/sms-sv.html | 1 + 10 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 cron/queue-sms.sh create mode 100644 src/bin/hav-test-sms-sending.ts create mode 100644 src/templates/rekry/sms/sms-en.html create mode 100644 src/templates/rekry/sms/sms-fi.html create mode 100644 src/templates/rekry/sms/sms-sv.html diff --git a/.env.dist b/.env.dist index 4ef436b..5fd65b4 100644 --- a/.env.dist +++ b/.env.dist @@ -24,3 +24,6 @@ MAIL_AUTH_PASS= # Elisa Dialogi SMS service DIALOGI_API_URL=https://dialogi.elisa.fi/api/v1 DIALOGI_API_KEY= + +# Testing +TEST_SMS_NUMBER= diff --git a/README.md b/README.md index cdbb66b..5b96740 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,10 @@ 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 @@ -289,14 +293,14 @@ Initialize MongoDB collections. Required before running populate or send command `npm run hav:populate-email-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 @@ -304,6 +308,35 @@ Adds following emails to the email queue: Sends emails in queue that were generated by `hav:populate-email-queue` +### Sends SMS from queue + +`npm run hav:send-sms-in-queue` + +Sends SMS messages in queue that were generated by `hav:populate-email-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. + ### Migration To migrate existing subscriptions to have `site_id` field, run: diff --git a/cron/queue-sms.sh b/cron/queue-sms.sh new file mode 100644 index 0000000..cb16b17 --- /dev/null +++ b/cron/queue-sms.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +cd /app + +echo "Sending SMS in queue" + +npm run hav:send-sms-in-queue diff --git a/package.json b/package.json index 6e083c3..5095c5d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "hav:update-schema": "node dist/bin/hav-update-schema.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:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js" + "hav:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js", + "hav:test-sms-sending": "node dist/bin/hav-test-sms-sending.js" }, "keywords": [], "author": "", diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-email-queue.ts index a2ba899..1fca6b9 100644 --- a/src/bin/hav-populate-email-queue.ts +++ b/src/bin/hav-populate-email-queue.ts @@ -4,8 +4,9 @@ import dotenv from 'dotenv'; import fastify from 'fastify'; import minimist from 'minimist'; -import { expiryEmail, newHitsEmail } from '../lib/email'; +import { expiryEmail, newHitsEmail, newHitsSms } from '../lib/email'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import atv from '../plugins/atv'; import base64Plugin from '../plugins/base64'; import elasticproxy from '../plugins/elasticproxy'; import mongodb from '../plugins/mongodb'; @@ -13,6 +14,7 @@ import '../plugins/sentry'; import type { ElasticProxyJsonResponseType, PartialDrupalNodeType } from '../types/elasticproxy'; import type { QueueInsertDocumentType } from '../types/mailer'; import type { SiteConfigurationType } from '../types/siteConfig'; +import type { SmsQueueInsertDocumentType } from '../types/sms'; import { type SubscriptionCollectionLanguageType, type SubscriptionCollectionType, @@ -32,6 +34,7 @@ interface ProcessingStats { subscriptionsChecked: number; expiryEmailsQueued: number; newResultsEmailsQueued: number; + smsQueued: number; } const server = fastify({}); @@ -51,6 +54,8 @@ void server.register(mongodb); void server.register(elasticproxy); // eslint-disable-next-line no-void void server.register(base64Plugin); +// eslint-disable-next-line no-void +void server.register(atv); export const getLocalizedUrl = ( siteConfig: SiteConfigurationType, @@ -169,8 +174,9 @@ const getNewHitsFromElasticsearch = async ( const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats: ProcessingStats): Promise => { const collection = server.mongo.db?.collection('subscription'); const queueCollection = server.mongo.db?.collection('queue'); + const smsQueueCollection = server.mongo.db?.collection('smsqueue'); - if (!collection || !queueCollection) { + if (!collection || !queueCollection || !smsQueueCollection) { throw new Error('MongoDB collections not available'); } @@ -279,6 +285,42 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats } stats.newResultsEmailsQueued++; + // Check if subscription has SMS in ATV and queue if present + try { + const atvDocs = await server.atvGetDocumentBatch([subscription.email]); + if (atvDocs?.[0]?.content) { + const atvContent = JSON.parse(atvDocs[0].content); + + // Only queue SMS if user provided one + if (atvContent.sms) { + const smsContent = await newHitsSms( + subscription.lang, + { + search_description: subscription.search_description, + search_link: subscription.query, + }, + siteConfig, + ); + + const smsToQueue: SmsQueueInsertDocumentType = { + sms: subscription.email, // atvDocumentId + content: smsContent, + }; + + if (isDryRun) { + // eslint-disable-next-line no-console + console.log(`[DRY RUN] Would queue SMS for ${subscription._id}`); + } else { + await smsQueueCollection.insertOne(smsToQueue); + } + stats.smsQueued++; + } + } + } catch (error) { + // Log error but don't break email sending + console.error(`Failed to check/queue SMS for ${subscription._id}:`, error); + } + return Promise.resolve(); }, Promise.resolve()); }; @@ -300,6 +342,7 @@ const app = async (): Promise => { subscriptionsChecked: 0, expiryEmailsQueued: 0, newResultsEmailsQueued: 0, + smsQueued: 0, }; try { @@ -354,6 +397,8 @@ const app = async (): Promise => { console.log(`Expiry emails queued: ${stats.expiryEmailsQueued}`); // eslint-disable-next-line no-console console.log(`New results emails queued: ${stats.newResultsEmailsQueued}`); + // eslint-disable-next-line no-console + console.log(`SMS queued: ${stats.smsQueued}`); if (isDryRun) { // eslint-disable-next-line no-console console.log('\n[DRY RUN] No changes were made to the database'); diff --git a/src/bin/hav-test-sms-sending.ts b/src/bin/hav-test-sms-sending.ts new file mode 100644 index 0000000..1f5afdf --- /dev/null +++ b/src/bin/hav-test-sms-sending.ts @@ -0,0 +1,116 @@ +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import dotenv from 'dotenv'; +import fastify from 'fastify'; +import { newHitsSms } from '../lib/email'; +import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; +import dialogi from '../plugins/dialogi'; +import '../plugins/sentry'; + +dotenv.config(); + +const server = fastify({}); +const release = process.env.SENTRY_RELEASE ?? ''; + +server.register(fastifySentry, { + dsn: process.env.SENTRY_DSN, + environment: process.env.ENVIRONMENT, + release, + setErrorHandler: true, +}); + +// Register only needed plugins +// eslint-disable-next-line no-void +void server.register(dialogi); + +// Test script to verify SMS sending via Elisa Dialogi API +const app = async (): Promise => { + 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', + search_link: '/fi/avoimet-tyopaikat?search=test', + }, + siteConfig, + ); + + console.log(`Content: ${smsContent}`); + + // Send SMS via Dialogi API + try { + const response = await server.dialogi.sendSms(testPhoneNumber, smsContent); + console.log(`✓ SMS sent successfully! Message ID: ${response.id || 'N/A'}`); + } 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); + } +}; + +server.get('/', async function handleRootRequest(_request, _reply) { + await app(); + return { success: true }; +}); + +server.ready((_err) => { + // eslint-disable-next-line no-console + console.log('fastify server ready'); + server.inject( + { + method: 'GET', + url: '/', + }, + function handleInjectResponse(_injectErr, response) { + if (response) { + // eslint-disable-next-line no-console + console.log(JSON.parse(response.payload)); + } + + server.close(); + }, + ); +}); diff --git a/src/lib/email.ts b/src/lib/email.ts index 8aebd92..71de188 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -68,3 +68,18 @@ export const newHitsEmail = async ( throw error; } }; + +// SMS notification for new search results +export const newHitsSms = async ( + lang: SubscriptionCollectionLanguageType, + data: { + search_description: string; + search_link: string; + }, + siteConfig: SiteConfigurationType, +) => + sprightly(`dist/templates/${siteConfig.mail.templatePath}/sms/sms-${lang}.html`, { + lang, + search_description: data.search_description, + search_link: siteConfig.urls.base + data.search_link, + }); diff --git a/src/templates/rekry/sms/sms-en.html b/src/templates/rekry/sms/sms-en.html new file mode 100644 index 0000000..9a5ca83 --- /dev/null +++ b/src/templates/rekry/sms/sms-en.html @@ -0,0 +1 @@ +Search alert: New results for "{{ search_description }}". View results: {{ search_link }} diff --git a/src/templates/rekry/sms/sms-fi.html b/src/templates/rekry/sms/sms-fi.html new file mode 100644 index 0000000..a47847d --- /dev/null +++ b/src/templates/rekry/sms/sms-fi.html @@ -0,0 +1 @@ +Hakuvahti: Uusia tuloksia haulle "{{ search_description }}". Katso tulokset: {{ search_link }} diff --git a/src/templates/rekry/sms/sms-sv.html b/src/templates/rekry/sms/sms-sv.html new file mode 100644 index 0000000..b1f9af2 --- /dev/null +++ b/src/templates/rekry/sms/sms-sv.html @@ -0,0 +1 @@ +Sökbevakning: Nya resultat för sökningen "{{ search_description }}". Se resultat: {{ search_link }} From e29744d957bd3b2446dbeb448bd4c84bec65470e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 17 Oct 2025 09:08:09 +0300 Subject: [PATCH 074/228] Target url was wrong --- src/plugins/dialogi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts index 244d13d..adf3d3a 100644 --- a/src/plugins/dialogi.ts +++ b/src/plugins/dialogi.ts @@ -44,7 +44,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { }; const response: AxiosResponse = await axios.post( - `${process.env.DIALOGI_API_URL}/send`, + process.env.DIALOGI_API_URL, requestBody, { headers: { From 6fd09a554805f9ff3bc0c542e345fa1dcaa21ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 22 Oct 2025 08:01:37 +0300 Subject: [PATCH 075/228] Add some missing stuff to dialogi --- README.md | 4 +++- src/plugins/dialogi.ts | 29 ++++++++++++++++++----------- src/types/dialogi.ts | 5 +++-- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5b96740..ab070a3 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,12 @@ The system automatically selects the correct environment configuration based on 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://dialogi.elisa.fi/api/v1`) +`DIALOGI_API_URL` Set the Elisa Dialogi API base URL (for example `https://dialogi.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: diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts index adf3d3a..d253053 100644 --- a/src/plugins/dialogi.ts +++ b/src/plugins/dialogi.ts @@ -13,11 +13,11 @@ import type { DialogiSmsRequestType, DialogiSmsResponseType } from '../types/dia export interface DialogiClient { /** * Send an SMS message - * @param to - Recipient phone number in E.164 format (e.g., "+358501234567") - * @param message - SMS message content + * @param destination - Recipient phone number in E.164 format (e.g., "+358501234567") + * @param text - SMS message content * @returns Promise with Dialogi API response */ - sendSms(to: string, message: string): Promise; + sendSms(destination: string, text: string): Promise; } export default fp(async function dialogiPlugin(fastify: FastifyInstance) { @@ -30,17 +30,24 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { 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(to: string, message: string): Promise { + async sendSms(destination: string, text: string): Promise { // Check if Dialogi is configured - if (!process.env.DIALOGI_API_URL || !process.env.DIALOGI_API_KEY) { - throw new Error('Dialogi SMS service is not configured. Please set DIALOGI_API_URL and DIALOGI_API_KEY'); + 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 = { - to, - message, + sender: process.env.DIALOGI_SENDER, + destination, + text, }; const response: AxiosResponse = await axios.post( @@ -55,7 +62,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { }, ); - fastify.log.info({ to, messageId: response.data.id }, 'SMS sent successfully via Dialogi'); + fastify.log.info({ destination, messageId: response.data.id }, 'SMS sent successfully via Dialogi'); return response.data; } catch (error) { @@ -63,7 +70,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { const errorMessage = error.response?.data?.message || error.message; fastify.log.error( { - to, + destination, error: errorMessage, status: error.response?.status, statusText: error.response?.statusText, @@ -73,7 +80,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { throw new Error(`Dialogi SMS API error: ${errorMessage}`); } - fastify.log.error({ to, error }, 'Unexpected error sending SMS via Dialogi'); + fastify.log.error({ destination, error }, 'Unexpected error sending SMS via Dialogi'); throw error; } }, diff --git a/src/types/dialogi.ts b/src/types/dialogi.ts index 6136c92..aa67fc2 100644 --- a/src/types/dialogi.ts +++ b/src/types/dialogi.ts @@ -2,8 +2,9 @@ import { type Static, Type } from '@sinclair/typebox'; // Request types for Elisa Dialogi SMS API export const DialogiSmsRequest = Type.Object({ - to: Type.String(), // Phone number in E.164 format - message: Type.String(), // SMS message content + 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; From c42173b0fc4178d8a01ded90fd6afa538e3025f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 24 Oct 2025 09:07:19 +0300 Subject: [PATCH 076/228] Return object type according to documentation, hope its correct --- src/types/dialogi.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/types/dialogi.ts b/src/types/dialogi.ts index aa67fc2..3f7f061 100644 --- a/src/types/dialogi.ts +++ b/src/types/dialogi.ts @@ -11,8 +11,21 @@ export type DialogiSmsRequestType = Static; // Response types for Elisa Dialogi SMS API export const DialogiSmsResponse = Type.Object({ - id: Type.Optional(Type.String()), // Message ID from Dialogi - status: Type.Optional(Type.String()), // Status of the SMS + 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; From 6c1ea8fa0c3e5e53b75997d9c9f7c4cb007ee7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 24 Oct 2025 09:07:47 +0300 Subject: [PATCH 077/228] Add dummy server for sms testing --- .env.dist | 1 + README.md | 30 ++++++++++ package.json | 3 +- src/bin/hav-dialogi-test-server.ts | 88 ++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/bin/hav-dialogi-test-server.ts diff --git a/.env.dist b/.env.dist index 5fd65b4..9f1e0e8 100644 --- a/.env.dist +++ b/.env.dist @@ -24,6 +24,7 @@ MAIL_AUTH_PASS= # Elisa Dialogi SMS service DIALOGI_API_URL=https://dialogi.elisa.fi/api/v1 DIALOGI_API_KEY= +DIALOGI_SENDER= # Testing TEST_SMS_NUMBER= diff --git a/README.md b/README.md index ab070a3..0ba6eaf 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,36 @@ 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 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: diff --git a/package.json b/package.json index 5095c5d..9730295 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "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:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js", - "hav:test-sms-sending": "node dist/bin/hav-test-sms-sending.js" + "hav:test-sms-sending": "node dist/bin/hav-test-sms-sending.js", + "hav:run-dialogi-test-server": "npm run build:ts && node dist/bin/hav-dialogi-test-server.js" }, "keywords": [], "author": "", 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(); From c59a290b6af4054e09bd1ad6988c94a9cd0fb46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 24 Oct 2025 09:08:48 +0300 Subject: [PATCH 078/228] log sms message id --- src/bin/hav-test-sms-sending.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bin/hav-test-sms-sending.ts b/src/bin/hav-test-sms-sending.ts index 1f5afdf..d02cfa0 100644 --- a/src/bin/hav-test-sms-sending.ts +++ b/src/bin/hav-test-sms-sending.ts @@ -73,7 +73,12 @@ const app = async (): Promise => { // Send SMS via Dialogi API try { const response = await server.dialogi.sendSms(testPhoneNumber, smsContent); - console.log(`✓ SMS sent successfully! Message ID: ${response.id || 'N/A'}`); + // 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; From aab92f7f08056cb934352985e449a8dad0f7a4a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 24 Oct 2025 09:10:25 +0300 Subject: [PATCH 079/228] Log messageid but not destination --- src/plugins/dialogi.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts index d253053..99f9c42 100644 --- a/src/plugins/dialogi.ts +++ b/src/plugins/dialogi.ts @@ -62,7 +62,13 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { }, ); - fastify.log.info({ destination, messageId: response.data.id }, 'SMS sent successfully via Dialogi'); + // 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) { @@ -80,7 +86,7 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { throw new Error(`Dialogi SMS API error: ${errorMessage}`); } - fastify.log.error({ destination, error }, 'Unexpected error sending SMS via Dialogi'); + fastify.log.error({ error }, 'Unexpected error sending SMS via Dialogi'); throw error; } }, From 43668ff7b1f5fed332cfdad6e1cb7502c005cf7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Fri, 24 Oct 2025 10:29:05 +0300 Subject: [PATCH 080/228] Remove some logging --- src/bin/hav-send-sms-in-queue.ts | 4 ++-- src/plugins/dialogi.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bin/hav-send-sms-in-queue.ts b/src/bin/hav-send-sms-in-queue.ts index dba9ba3..9cf2361 100644 --- a/src/bin/hav-send-sms-in-queue.ts +++ b/src/bin/hav-send-sms-in-queue.ts @@ -91,11 +91,11 @@ const app = async (): Promise => { try { // Send SMS using Dialogi plugin await server.dialogi.sendSms(phoneNumber, messageContent); - console.log(`SMS sent successfully to ${phoneNumber}`); + console.log(`SMS sent successfully for ATV ID: ${atvId}`); } catch (error) { // Log error but continue processing queue server.Sentry?.captureException(error); - console.error(`Failed to send SMS to ${phoneNumber}:`, error); + console.error(`Failed to send SMS for ATV ID ${atvId}:`, error); } } else { console.warn(`Phone number not found for ATV ID ${atvId}`); diff --git a/src/plugins/dialogi.ts b/src/plugins/dialogi.ts index 99f9c42..cd9e7bd 100644 --- a/src/plugins/dialogi.ts +++ b/src/plugins/dialogi.ts @@ -76,7 +76,6 @@ export default fp(async function dialogiPlugin(fastify: FastifyInstance) { const errorMessage = error.response?.data?.message || error.message; fastify.log.error( { - destination, error: errorMessage, status: error.response?.status, statusText: error.response?.statusText, From d5ba8d3c799cd7ddad98dd1a1e65a3e96c388a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Mon, 27 Oct 2025 16:01:09 +0200 Subject: [PATCH 081/228] Add a fix script for bug UHF-12396 --- src/bin/hav-fix-employment-arrays.ts | 139 +++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/bin/hav-fix-employment-arrays.ts diff --git a/src/bin/hav-fix-employment-arrays.ts b/src/bin/hav-fix-employment-arrays.ts new file mode 100644 index 0000000..03c2069 --- /dev/null +++ b/src/bin/hav-fix-employment-arrays.ts @@ -0,0 +1,139 @@ +import dotenv from 'dotenv'; +import fastify from 'fastify'; +import mongodb from '../plugins/mongodb'; + +dotenv.config(); + +const server = fastify({}); +void server.register(mongodb); + +const DRY_RUN = process.argv.includes('--dry-run'); + +const FIELDS_TO_FIX = ['employment_id', 'employment_type_id', 'task_area_external_id']; + +const fixCommaSeparatedArrays = (obj: any, path = ''): { fixed: any; modified: boolean; changedFields: string[] } => { + const changedFields: string[] = []; + let modified = false; + + const fixed = JSON.parse(JSON.stringify(obj), (key, value) => { + const currentPath = path ? `${path}.${key}` : key; + + if (FIELDS_TO_FIX.includes(key) && Array.isArray(value) && value.length > 0) { + const needsFix = value.some((v) => typeof v === 'string' && v.includes(',')); + + if (needsFix) { + modified = true; + changedFields.push(currentPath); + return value.flatMap((v) => + typeof v === 'string' && v.includes(',') ? v.split(',').map((s) => s.trim()) : v, + ); + } + } + return value; + }); + + return { fixed, modified, changedFields }; +}; + +const app = async (): Promise => { + const db = server.mongo?.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } + + const subscriptionsCollection = db.collection('subscriptions'); + + console.log('Fixing comma-separated arrays in elastic queries'); + console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`); + console.log(`Fields: ${FIELDS_TO_FIX.join(', ')}\n`); + + const subscriptions = await subscriptionsCollection.find({}).toArray(); + console.log(`Found ${subscriptions.length} subscriptions\n`); + + let fixedCount = 0; + let skippedCount = 0; + let errorCount = 0; + const fixes: Array<{ id: string; fields: string[] }> = []; + + for (const subscription of subscriptions) { + try { + const originalQuery = subscription.elastic_query; + const decoded = Buffer.from(originalQuery, 'base64').toString('utf-8'); + const queryObj = JSON.parse(decoded); + + const { fixed: fixedQuery, modified, changedFields } = fixCommaSeparatedArrays(queryObj); + + if (modified) { + const fixedStr = JSON.stringify(fixedQuery); + const newEncoded = Buffer.from(fixedStr).toString('base64'); + + console.log(`${DRY_RUN ? '[DRY] ' : ''}Fixed ${subscription._id} (${subscription.email || 'N/A'})`); + console.log(` Fields: ${changedFields.join(', ')}`); + console.log(` Before: ${decoded.substring(0, 120)}...`); + console.log(` After: ${fixedStr.substring(0, 120)}...\n`); + + fixes.push({ + id: subscription._id.toString(), + fields: changedFields, + }); + + if (!DRY_RUN) { + await subscriptionsCollection.updateOne({ _id: subscription._id }, { $set: { elastic_query: newEncoded } }); + } + + fixedCount++; + } else { + skippedCount++; + } + } catch (error) { + console.error(`Error processing ${subscription._id}:`, error); + errorCount++; + } + } + + console.log('\nSummary:'); + console.log(` ${DRY_RUN ? 'Would fix' : 'Fixed'}: ${fixedCount}`); + console.log(` Skipped: ${skippedCount}`); + console.log(` Errors: ${errorCount}`); + console.log(` Total: ${subscriptions.length}`); + + if (fixes.length > 0) { + console.log('\nFields fixed:'); + const fieldCounts = new Map(); + fixes.forEach(({ fields }) => { + fields.forEach((field) => { + fieldCounts.set(field, (fieldCounts.get(field) || 0) + 1); + }); + }); + + fieldCounts.forEach((count, field) => { + console.log(` ${field}: ${count}`); + }); + } + + if (DRY_RUN && fixedCount > 0) { + console.log('\nRun without --dry-run to apply changes'); + } +}; + +server.get('/', async function handleRootRequest(_request, _reply) { + await app(); + return { success: true }; +}); + +server.ready((_err) => { + console.log('fastify server ready'); + server.inject( + { + method: 'GET', + url: '/', + }, + function handleInjectResponse(_injectErr, response) { + if (response) { + console.log(JSON.parse(response.payload)); + } + + server.close(); + }, + ); +}); From 3e8a3ddce748caa3956842ba2bbde571a79c79c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 28 Oct 2025 13:45:25 +0200 Subject: [PATCH 082/228] Remove lint from build, node mailer update was not committed for some reason --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9730295..9bd128e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "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", - "build:ts": "npm run lint:check && npm run copy:assets; tsc", + "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\"", @@ -45,7 +45,7 @@ "fastify-plugin": "^4.0.0", "jsdom": "^24.0.0", "minimist": "^1.2.8", - "nodemailer": "^6.9.9", + "nodemailer": "^7.0.10", "sprightly": "^2.0.1" }, "devDependencies": { From 2e6b783c2c5e6b5597b983584e5700fe7bf22580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 29 Oct 2025 09:41:14 +0200 Subject: [PATCH 083/228] Update dialog api url to be correct --- .env.dist | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.dist b/.env.dist index 9f1e0e8..f7b197a 100644 --- a/.env.dist +++ b/.env.dist @@ -22,7 +22,7 @@ MAIL_AUTH_USER= MAIL_AUTH_PASS= # Elisa Dialogi SMS service -DIALOGI_API_URL=https://dialogi.elisa.fi/api/v1 +DIALOGI_API_URL=https://viestipalvelu-api.elisa.fi/api/v1 DIALOGI_API_KEY= DIALOGI_SENDER= diff --git a/README.md b/README.md index 0ba6eaf..fa78f7c 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The system automatically selects the correct environment configuration based on 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://dialogi.elisa.fi/api/v1/`) +`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 From 8cb1c8312e0f2f4f9f432751ff3a9e1a1dc495b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 29 Oct 2025 09:42:52 +0200 Subject: [PATCH 084/228] Cron bash scripts not needed anymore --- cron/populate.sh | 10 ---------- cron/queue-sms.sh | 7 ------- cron/queue.sh | 7 ------- 3 files changed, 24 deletions(-) delete mode 100644 cron/populate.sh delete mode 100644 cron/queue-sms.sh delete mode 100644 cron/queue.sh diff --git a/cron/populate.sh b/cron/populate.sh deleted file mode 100644 index 622adf2..0000000 --- a/cron/populate.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -cd /app - -echo "Populating email queue" - -# Forward all command-line arguments to the npm script -# Usage: ./populate.sh --site=rekry --dry-run -npm run hav:populate-email-queue -- "$@" - diff --git a/cron/queue-sms.sh b/cron/queue-sms.sh deleted file mode 100644 index cb16b17..0000000 --- a/cron/queue-sms.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -cd /app - -echo "Sending SMS in queue" - -npm run hav:send-sms-in-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 From 1256b12ae74789c77cabed89b80caa4d17fab432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 29 Oct 2025 12:44:34 +0200 Subject: [PATCH 085/228] Rename populate script --- README.md | 26 +++++++++---------- ...e-email-queue.ts => hav-populate-queue.ts} | 0 2 files changed, 13 insertions(+), 13 deletions(-) rename src/bin/{hav-populate-email-queue.ts => hav-populate-queue.ts} (100%) diff --git a/README.md b/README.md index fa78f7c..ac3a376 100644 --- a/README.md +++ b/README.md @@ -60,30 +60,30 @@ Pre-requisities to use Hakuvahti are: - `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:populate-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) - `npm run hav:send-sms-in-queue` (optional, for SMS notifications - should be run at least once per minute) ## Configuration -### Email Queue Population Script +### Queue Population Script -The `hav-populate-email-queue` script checks for new search results and queues notification emails. It supports site-specific processing and dry-run mode for testing. +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-email-queue +npm run hav:populate-queue # Process specific site only -npm run hav:populate-email-queue -- --site=rekry +npm run hav:populate-queue -- --site=rekry # Preview what would happen without making changes (dry run) -npm run hav:populate-email-queue -- --dry-run +npm run hav:populate-queue -- --dry-run # Dry run for specific site -npm run hav:populate-email-queue -- --site=rekry --dry-run +npm run hav:populate-queue -- --site=rekry --dry-run ``` **CLI Parameters:** @@ -96,12 +96,12 @@ npm run hav:populate-email-queue -- --site=rekry --dry-run # Rekry site - check at 6 AM daily - name: populate-rekry schedule: "0 6 * * *" - command: ["npm", "run", "hav:populate-email-queue", "--", "--site=rekry"] + command: ["npm", "run", "hav:populate-queue", "--", "--site=rekry"] # General site - check hourly - name: populate-general schedule: "0 * * * *" - command: ["npm", "run", "hav:populate-email-queue", "--", "--site=general"] + command: ["npm", "run", "hav:populate-queue", "--", "--site=general"] # Queue processor runs every minute (processes all sites) - name: send-emails @@ -267,7 +267,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 @@ -293,7 +293,7 @@ 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 and SMS queues. @@ -308,13 +308,13 @@ Adds following notifications to queues: `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-email-queue`. Only processes subscriptions that have SMS stored in ATV. +Sends SMS messages in queue that were generated by `hav:populate-queue`. Only processes subscriptions that have SMS stored in ATV. ### Test SMS Sending diff --git a/src/bin/hav-populate-email-queue.ts b/src/bin/hav-populate-queue.ts similarity index 100% rename from src/bin/hav-populate-email-queue.ts rename to src/bin/hav-populate-queue.ts From d675ca39890a32e90f02fad45cb149d5c79ce0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 29 Oct 2025 12:46:29 +0200 Subject: [PATCH 086/228] Rename populate script, implement limit of results in email, quick feature request by Aleksi --- conf/rekry.json | 12 ++++++++---- documentation/testing.md | 4 ++-- package.json | 2 +- src/bin/hav-populate-queue.ts | 12 ++++++++---- src/types/siteConfig.ts | 1 + 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/conf/rekry.json b/conf/rekry.json index 7fd5a22..52463fe 100644 --- a/conf/rekry.json +++ b/conf/rekry.json @@ -13,7 +13,8 @@ "expiryNotificationDays": 3 }, "mail": { - "templatePath": "rekry" + "templatePath": "rekry", + "maxHitsInEmail": 10 }, "elasticProxyUrl": "https://elastic-helfi-rekry.docker.so/job_listings" }, @@ -30,7 +31,8 @@ "expiryNotificationDays": 3 }, "mail": { - "templatePath": "rekry" + "templatePath": "rekry", + "maxHitsInEmail": 10 }, "elasticProxyUrl": "https://rekry-elastic-proxy-test.agw.arodevtest.hel.fi/job_listings" }, @@ -47,7 +49,8 @@ "expiryNotificationDays": 3 }, "mail": { - "templatePath": "rekry" + "templatePath": "rekry", + "maxHitsInEmail": 10 }, "elasticProxyUrl": "https://rekry-elastic-proxy-staging.api.hel.ninja/job_listings" }, @@ -64,7 +67,8 @@ "expiryNotificationDays": 3 }, "mail": { - "templatePath": "rekry" + "templatePath": "rekry", + "maxHitsInEmail": 10 }, "elasticProxyUrl": "https://rekry-elastic-proxy.api.hel.ninja/job_listings" } diff --git a/documentation/testing.md b/documentation/testing.md index 8192a39..917674e 100644 --- a/documentation/testing.md +++ b/documentation/testing.md @@ -62,7 +62,7 @@ make shell **Action:** In the Hakuvahti shell, run: ```bash -npm run hav:populate-email-queue +npm run hav:populate-queue npm run hav:send-emails-in-queue ``` @@ -112,7 +112,7 @@ drush cr **Action:** In the Hakuvahti shell (access via `make shell` if needed), run: ```bash -npm run hav:populate-email-queue +npm run hav:populate-queue npm run hav:send-emails-in-queue ``` diff --git a/package.json b/package.json index 9bd128e..d93fe29 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "hav:init-mongodb": "node dist/bin/hav-init-mongodb.js", "hav:migrate-site-id": "node dist/bin/hav-migrate-site-id.js", "hav:update-schema": "node dist/bin/hav-update-schema.js", - "hav:populate-email-queue": "node dist/bin/hav-populate-email-queue.js", + "hav:populate-queue": "node dist/bin/hav-populate-queue.js", "hav:send-emails-in-queue": "node dist/bin/hav-send-emails-in-queue.js", "hav:send-sms-in-queue": "node dist/bin/hav-send-sms-in-queue.js", "hav:test-sms-sending": "node dist/bin/hav-test-sms-sending.js", diff --git a/src/bin/hav-populate-queue.ts b/src/bin/hav-populate-queue.ts index 1fca6b9..d759c0c 100644 --- a/src/bin/hav-populate-queue.ts +++ b/src/bin/hav-populate-queue.ts @@ -247,6 +247,10 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats 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); @@ -260,7 +264,7 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats search_description: subscription.search_description, search_link: subscription.query, remove_link: `${localizedBaseUrl}/hakuvahti/unsubscribe?subscription=${subscription._id}&hash=${subscription.hash}`, - hits: newHits, + hits: hitsForEmail, }, siteConfig, ); @@ -332,7 +336,7 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats */ const app = async (): Promise => { const checkInId = server.Sentry?.captureCheckIn({ - monitorSlug: 'hav-populate-email-queue', + monitorSlug: 'hav-populate-queue', status: 'in_progress', }); @@ -406,14 +410,14 @@ const app = async (): Promise => { } catch (error) { console.error('Configuration loading error:', error); if (!isDryRun) { - server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'error' }); + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-queue', status: 'error' }); server.Sentry?.captureException(error); } return; } if (!isDryRun) { - server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-email-queue', status: 'ok' }); + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-populate-queue', status: 'ok' }); } }; diff --git a/src/types/siteConfig.ts b/src/types/siteConfig.ts index 553381b..9b9d351 100644 --- a/src/types/siteConfig.ts +++ b/src/types/siteConfig.ts @@ -17,6 +17,7 @@ export type SiteSubscriptionSettingsType = Static; From f5d7c4380e136f7349449e6f132a8180b9c75752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Wed, 29 Oct 2025 14:43:47 +0200 Subject: [PATCH 087/228] Get rid of ATV call in populate loop by adding a simple has_sms flag to the subscription document. --- src/bin/hav-populate-queue.ts | 58 ++++++++++++++++------------------- src/plugins/atv.ts | 1 + src/routes/addSubscription.ts | 1 + src/types/atv.ts | 1 + src/types/subscription.ts | 1 + 5 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/bin/hav-populate-queue.ts b/src/bin/hav-populate-queue.ts index d759c0c..7ce19ba 100644 --- a/src/bin/hav-populate-queue.ts +++ b/src/bin/hav-populate-queue.ts @@ -289,40 +289,34 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats } stats.newResultsEmailsQueued++; - // Check if subscription has SMS in ATV and queue if present - try { - const atvDocs = await server.atvGetDocumentBatch([subscription.email]); - if (atvDocs?.[0]?.content) { - const atvContent = JSON.parse(atvDocs[0].content); - - // Only queue SMS if user provided one - if (atvContent.sms) { - const smsContent = await newHitsSms( - subscription.lang, - { - search_description: subscription.search_description, - search_link: subscription.query, - }, - siteConfig, - ); - - const smsToQueue: SmsQueueInsertDocumentType = { - sms: subscription.email, // atvDocumentId - content: smsContent, - }; - - if (isDryRun) { - // eslint-disable-next-line no-console - console.log(`[DRY RUN] Would queue SMS for ${subscription._id}`); - } else { - await smsQueueCollection.insertOne(smsToQueue); - } - stats.smsQueued++; + // Queue SMS if subscription has SMS flag + if (subscription.has_sms) { + try { + const smsContent = await newHitsSms( + subscription.lang, + { + search_description: subscription.search_description, + search_link: subscription.query, + }, + siteConfig, + ); + + const smsToQueue: SmsQueueInsertDocumentType = { + sms: subscription.email, // atvDocumentId + content: smsContent, + }; + + if (isDryRun) { + // eslint-disable-next-line no-console + console.log(`[DRY RUN] Would queue SMS for ${subscription._id}`); + } else { + await smsQueueCollection.insertOne(smsToQueue); } + stats.smsQueued++; + } catch (error) { + // Log error but don't break email sending + console.error(`Error queueing SMS for subscription ${subscription._id}:`, error); } - } catch (error) { - // Log error but don't break email sending - console.error(`Failed to check/queue SMS for ${subscription._id}:`, error); } return Promise.resolve(); diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 55b7286..1d45cd8 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -136,6 +136,7 @@ const requestEmailHook = async (request: FastifyRequestType) => { if (atvDocumentId) { request.atvResponse = { atvDocumentId, + hasSms: !!sms, }; } diff --git a/src/routes/addSubscription.ts b/src/routes/addSubscription.ts index 9f8d39c..cc9120f 100644 --- a/src/routes/addSubscription.ts +++ b/src/routes/addSubscription.ts @@ -96,6 +96,7 @@ const subscription: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: last_checked: Math.floor(Date.now() / 1000), expiry_notification_sent: SubscriptionStatus.INACTIVE, status: SubscriptionStatus.INACTIVE, + has_sms: !!request.atvResponse?.hasSms, }; // SMS is already stored in ATV document, no need to store in MongoDB diff --git a/src/types/atv.ts b/src/types/atv.ts index 31df38b..9051e53 100644 --- a/src/types/atv.ts +++ b/src/types/atv.ts @@ -2,6 +2,7 @@ import { type Static, Type } from '@sinclair/typebox'; export const AtvResponse = Type.Object({ atvDocumentId: Type.String(), + hasSms: Type.Optional(Type.Boolean()), }); export type AtvResponseType = Static; diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 89193a7..7f164d5 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -23,6 +23,7 @@ export const SubscriptionCollection = Type.Object({ last_checked: Type.Optional(Type.Number()), expiry_notification_sent: Type.Enum(SubscriptionStatus), status: Type.Enum(SubscriptionStatus), + has_sms: Type.Optional(Type.Boolean()), }); export type SubscriptionCollectionType = Static; From 5eabfc4da0c227dd8a96de148821dcd89ddd3792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Mon, 3 Nov 2025 08:44:11 +0200 Subject: [PATCH 088/228] Implement subscription status --- src/routes/subscriptionStatus.ts | 66 ++++++++++++++++++++++++++++++++ src/types/subscription.ts | 6 +++ 2 files changed, 72 insertions(+) create mode 100644 src/routes/subscriptionStatus.ts diff --git a/src/routes/subscriptionStatus.ts b/src/routes/subscriptionStatus.ts new file mode 100644 index 0000000..2aa441d --- /dev/null +++ b/src/routes/subscriptionStatus.ts @@ -0,0 +1,66 @@ +import { ObjectId } from '@fastify/mongodb'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { Generic500Error, type Generic500ErrorType } from '../types/error'; + +import { + SubscriptionStatusResponse, + type SubscriptionStatusResponseType, + SubscriptionStatus, +} from '../types/subscription'; + +// Checks subscription status + +const subscriptionStatus: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { + fastify.get<{ + Reply: SubscriptionStatusResponseType | Generic500ErrorType; + }>( + '/subscription/status/:id/:hash', + { + schema: { + response: { + 200: SubscriptionStatusResponse, + 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 }; + + const subscription = await collection?.findOne({ + _id: new ObjectId(id), + hash, + }); + + if (!subscription) { + return reply.code(404).send({ + statusCode: 404, + 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/types/subscription.ts b/src/types/subscription.ts index 7f164d5..bab1dfb 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -61,3 +61,9 @@ export const SubscriptionGenericPostResponse = Type.Object({ statusMessage: Type.Optional(Type.String()), }); export type SubscriptionGenericPostResponseType = Static; + +// Response for subscription status check +export const SubscriptionStatusResponse = Type.Object({ + subscriptionStatus: Type.Union([Type.Literal('active'), Type.Literal('inactive'), Type.Literal('disabled')]), +}); +export type SubscriptionStatusResponseType = Static; From cc6d408ca3353e0f2d189018523cfe8408c723b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 4 Nov 2025 13:45:09 +0200 Subject: [PATCH 089/228] Implement hakuvahti renewe --- src/routes/renewSubscription.ts | 112 ++++++++++++++++++++++++++++++++ src/types/subscription.ts | 12 ++++ 2 files changed, 124 insertions(+) create mode 100644 src/routes/renewSubscription.ts diff --git a/src/routes/renewSubscription.ts b/src/routes/renewSubscription.ts new file mode 100644 index 0000000..2f9e3cb --- /dev/null +++ b/src/routes/renewSubscription.ts @@ -0,0 +1,112 @@ +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 updateFields: Record = { + created: new Date(), + modified: new Date(), + expiry_notification_sent: SubscriptionStatus.INACTIVE, + }; + + // Only set first_created if it doesn't exist yet (for multiple renewals) + if (!subscription.first_created) { + updateFields.first_created = subscription.created; + } + + // 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/types/subscription.ts b/src/types/subscription.ts index 7f164d5..132c535 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -7,6 +7,18 @@ export enum SubscriptionStatus { } export const SubscriptionStatusType = Type.Enum(SubscriptionStatus); +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; From 0b1185dc77d3bf16ba772f5669cd0322d3a53248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 4 Nov 2025 13:46:03 +0200 Subject: [PATCH 090/228] Update sonarcube --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a976d8e..0eeaf0a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,6 @@ jobs: fetch-depth: 0 - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v5 + uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From 0136103ad9d43498a80a60df7c141cea681f2342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 4 Nov 2025 14:25:00 +0200 Subject: [PATCH 091/228] Update ATV document with renew --- src/plugins/atv.ts | 62 +++++++++++++++++++++++++++++++++ src/routes/renewSubscription.ts | 17 +++++++++ 2 files changed, 79 insertions(+) diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 1d45cd8..4dec9aa 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -81,6 +81,59 @@ const atvCreateDocumentWithEmail = async (email: string, sms?: string): Promise< } }; +/** + * Updates the delete_after timestamp for an ATV document. + * Fetches the existing document first to preserve all content and required fields. + * + * @param {string} atvDocumentId - The id of the ATV document to update + * @param {number} maxAge - The number of days until deletion (defaults to SUBSCRIPTION_MAX_AGE env var or 90) + * @return {Promise>} The updated document + */ +const atvUpdateDocumentDeleteAfter = async ( + atvDocumentId: string, + maxAge?: number, +): Promise> => { + try { + // 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 = new Date(); + const daysUntilDeletion: number = maxAge || Number(process.env.SUBSCRIPTION_MAX_AGE) || 90; + deleteAfter.setDate(deleteAfter.getDate() + daysUntilDeletion); + + // Update with full document + modified delete_after + const updateObject: Partial = { + ...existingDocResponse.data, + 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: unknown) { + console.error(error); + + throw new Error('Failed to update ATV document. See error log.'); + } +}; + /** * Retrieves a batch of documents for the given emails. * @@ -169,6 +222,14 @@ export default fp(async (fastify, _opts) => { 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) { + return atvUpdateDocumentDeleteAfter(atvDocumentId, maxAge); + }, + ); }); declare module 'fastify' { @@ -180,5 +241,6 @@ declare module 'fastify' { atvQueryEmail(email: string): Promise>; atvCreateDocumentWithEmail: (email: string, sms?: string) => Promise>; atvGetDocumentBatch: (emails: string[]) => Promise>; + atvUpdateDocumentDeleteAfter: (atvDocumentId: string, maxAge?: number) => Promise>; } } diff --git a/src/routes/renewSubscription.ts b/src/routes/renewSubscription.ts index 2f9e3cb..aec7e31 100644 --- a/src/routes/renewSubscription.ts +++ b/src/routes/renewSubscription.ts @@ -91,6 +91,23 @@ const renewSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, _ 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); + } 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 }); From c2b2dad9afe5f6f8a147ace22fe1703f199cd2e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Mon, 10 Nov 2025 15:28:34 +0200 Subject: [PATCH 092/228] Okay, sending ALL fields to ATV makes the API fail with very descriptive 400 Bad Request. Fixed by sending only writable fields --- src/plugins/atv.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 4dec9aa..8c5b60d 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -109,9 +109,13 @@ const atvUpdateDocumentDeleteAfter = async ( const daysUntilDeletion: number = maxAge || Number(process.env.SUBSCRIPTION_MAX_AGE) || 90; deleteAfter.setDate(deleteAfter.getDate() + daysUntilDeletion); - // Update with full document + modified delete_after + const existingDoc = existingDocResponse.data; + const updateObject: Partial = { - ...existingDocResponse.data, + 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), }; From 6085e055027e53a29d7b9513edab4d17bb9c4810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 11 Nov 2025 11:13:22 +0200 Subject: [PATCH 093/228] Biome formatting --- src/routes/renewSubscription.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/routes/renewSubscription.ts b/src/routes/renewSubscription.ts index aec7e31..27dd28d 100644 --- a/src/routes/renewSubscription.ts +++ b/src/routes/renewSubscription.ts @@ -114,14 +114,11 @@ const renewSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, _ // 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(), - }); + return reply.code(200).header('Content-Type', 'application/json; charset=utf-8').send({ + statusCode: 200, + statusMessage: 'Subscription renewed successfully.', + expiryDate: newExpiryDate.toISOString(), + }); }, ); }; From 6e0fde2d7958e415a74760d28f19db21dbf23802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 11 Nov 2025 11:14:12 +0200 Subject: [PATCH 094/228] Fix failing test, we use local now instead of dev, also elasticProxyUrl is required parameter --- test/lib/siteConfigurationLoader.test.ts | 70 ++++++++++++++++++------ 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/test/lib/siteConfigurationLoader.test.ts b/test/lib/siteConfigurationLoader.test.ts index 3f667b1..d848cea 100644 --- a/test/lib/siteConfigurationLoader.test.ts +++ b/test/lib/siteConfigurationLoader.test.ts @@ -7,6 +7,23 @@ import { SiteConfigurationLoader } from '../../src/lib/siteConfigurationLoader' const mockRekryConfig = { name: 'rekry', + 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 + }, + mail: { + templatePath: 'rekry' + }, + elasticProxyUrl: 'http://localhost:9200' + }, dev: { urls: { base: 'https://helfi-rekry.docker.so', @@ -21,7 +38,8 @@ const mockRekryConfig = { }, mail: { templatePath: 'rekry' - } + }, + elasticProxyUrl: 'http://localhost:9200' }, prod: { urls: { @@ -37,12 +55,30 @@ const mockRekryConfig = { }, mail: { templatePath: 'rekry' - } + }, + elasticProxyUrl: 'http://localhost:9200' } } const mockAnotherConfig = { name: 'another-site', + local: { + urls: { + base: 'https://another.docker.so', + en: 'https://another.docker.so/en', + fi: 'https://another.docker.so/fi', + sv: 'https://another.docker.so/sv' + }, + subscription: { + maxAge: 60, + unconfirmedMaxAge: 3, + expiryNotificationDays: 2 + }, + mail: { + templatePath: 'another' + }, + elasticProxyUrl: 'http://localhost:9200' + }, dev: { urls: { base: 'https://another.docker.so', @@ -57,7 +93,8 @@ const mockAnotherConfig = { }, mail: { templatePath: 'another' - } + }, + elasticProxyUrl: 'http://localhost:9200' }, prod: { urls: { @@ -73,7 +110,8 @@ const mockAnotherConfig = { }, mail: { templatePath: 'another' - } + }, + elasticProxyUrl: 'http://localhost:9200' } } @@ -125,7 +163,7 @@ test('SiteConfigurationLoader', async (t) => { ;(SiteConfigurationLoader as any).instance = undefined // Reset environment to default - process.env.ENVIRONMENT = 'dev' + process.env.ENVIRONMENT = 'local' // Ensure clean test files exist const confDir = path.join(tempDir, 'conf') @@ -158,7 +196,7 @@ test('SiteConfigurationLoader', async (t) => { }) await t.test('loadConfigurations loads config files successfully', async () => { - process.env.ENVIRONMENT = 'dev' + process.env.ENVIRONMENT = 'local' const loader = SiteConfigurationLoader.getInstance() await loader.loadConfigurations() @@ -180,8 +218,8 @@ test('SiteConfigurationLoader', async (t) => { assert.strictEqual(rekryConfig?.urls.base, 'https://hel.fi') }) - await t.test('loadConfigurations defaults to dev environment', async () => { - delete process.env.ENVIRONMENT + await t.test('loadConfigurations uses local environment', async () => { + process.env.ENVIRONMENT = 'local' const loader = SiteConfigurationLoader.getInstance() await loader.loadConfigurations() @@ -191,7 +229,7 @@ test('SiteConfigurationLoader', async (t) => { }) await t.test('getConfiguration returns specific site config', async () => { - process.env.ENVIRONMENT = 'dev' + process.env.ENVIRONMENT = 'local' const loader = SiteConfigurationLoader.getInstance() await loader.loadConfigurations() @@ -205,7 +243,7 @@ test('SiteConfigurationLoader', async (t) => { }) await t.test('getConfiguration returns undefined for non-existent site', async () => { - process.env.ENVIRONMENT = 'dev' + process.env.ENVIRONMENT = 'local' const loader = SiteConfigurationLoader.getInstance() await loader.loadConfigurations() @@ -215,7 +253,7 @@ test('SiteConfigurationLoader', async (t) => { }) await t.test('getSiteIds returns array of site IDs', async () => { - process.env.ENVIRONMENT = 'dev' + process.env.ENVIRONMENT = 'local' const loader = SiteConfigurationLoader.getInstance() await loader.loadConfigurations() @@ -319,15 +357,15 @@ test('SiteConfigurationLoader', async (t) => { fs.unlinkSync(path.join(confDir, file)) } - // Reset to dev environment for this test - process.env.ENVIRONMENT = 'dev' + // Reset to local environment for this test + process.env.ENVIRONMENT = 'local' // Write config without required properties fs.writeFileSync(path.join(confDir, 'missing-props.json'), JSON.stringify({ name: 'test', - dev: { + local: { urls: { base: 'test' } - // Missing subscription and mail properties + // Missing subscription, mail, and elasticProxyUrl properties } })) @@ -340,7 +378,7 @@ test('SiteConfigurationLoader', async (t) => { }) await t.test('prevents multiple loadConfigurations calls', async () => { - process.env.ENVIRONMENT = 'dev' + process.env.ENVIRONMENT = 'local' // Clean up first to ensure we have clean test files const confDir = path.join(tempDir, 'conf') From 7c4c8edec32927c5ec8d3aeba433e995266bb653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 11 Nov 2025 11:14:34 +0200 Subject: [PATCH 095/228] Biome wants formatting --- src/bin/hav-fix-employment-arrays.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/bin/hav-fix-employment-arrays.ts b/src/bin/hav-fix-employment-arrays.ts index 03c2069..3cde10c 100644 --- a/src/bin/hav-fix-employment-arrays.ts +++ b/src/bin/hav-fix-employment-arrays.ts @@ -24,9 +24,7 @@ const fixCommaSeparatedArrays = (obj: any, path = ''): { fixed: any; modified: b if (needsFix) { modified = true; changedFields.push(currentPath); - return value.flatMap((v) => - typeof v === 'string' && v.includes(',') ? v.split(',').map((s) => s.trim()) : v, - ); + return value.flatMap((v) => (typeof v === 'string' && v.includes(',') ? v.split(',').map((s) => s.trim()) : v)); } } return value; From 21b6d3e513d6f27c72924f6f5a48210eeb2bc6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Tue, 11 Nov 2025 11:18:35 +0200 Subject: [PATCH 096/228] Add test for renewSubscription --- test/routes/renewSubscription.test.ts | 105 ++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 test/routes/renewSubscription.test.ts diff --git a/test/routes/renewSubscription.test.ts b/test/routes/renewSubscription.test.ts new file mode 100644 index 0000000..3eda27f --- /dev/null +++ b/test/routes/renewSubscription.test.ts @@ -0,0 +1,105 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert'; +import { build } from '../helper'; +import { ObjectId } from '@fastify/mongodb'; +import * as path from 'path'; + +process.env.ENVIRONMENT = 'local'; +process.env.SENTRY_DSN = 'https://test@sentry.io/test'; +process.env.SITE_CONFIGURATION_PATH = path.join(__dirname, '../../conf'); + +test('renewSubscription - invalid subscription ID', async (t) => { + const app = await build(t); + + const subscriptionId = new ObjectId(); + const hash = 'invalidhash'; + + const res = await app.inject({ + method: 'GET', + url: `/subscription/renew/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 404); + const body = JSON.parse(res.payload); + assert.strictEqual(body.statusCode, 404); + assert.strictEqual(body.statusMessage, 'Subscription not found.'); +}); + +test('renewSubscription - route is registered and responds', async (t) => { + const app = await build(t); + + const subscriptionId = new ObjectId(); + const hash = 'testhash'; + + const res = await app.inject({ + method: 'GET', + url: `/subscription/renew/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.ok(res.statusCode !== undefined, 'Should get a response'); + assert.ok([200, 400, 404, 500].includes(res.statusCode), 'Should return a valid HTTP status code'); +}); + +test('renewSubscription - successfully renews old subscription with real MongoDB', async (t) => { + const app = await build(t); + + if (!app.mongo?.db) { + console.log('Skipping test - MongoDB not available'); + return; + } + + // Mock ATV update to always succeed + app.atvUpdateDocumentDeleteAfter = async (atvDocId: string, maxAge?: number) => { + return { + id: atvDocId, + delete_after: new Date(Date.now() + (maxAge || 90) * 24 * 60 * 60 * 1000).toISOString().substring(0, 10), + }; + }; + + // Create a subscription that's old enough to renew (87 days ago) + const oldDate = new Date(Date.now() - 87 * 24 * 60 * 60 * 1000); + const hash = 'test-renewal-hash-' + Date.now(); + + const testSubscription = { + hash, + status: 1, + created: oldDate, + modified: oldDate, + email: 'test-atv-doc-id', + site_id: 'rekry', + expiry_notification_sent: 0, + elastic_query: 'test', + query: '/test', + search_description: 'Test subscription for renewal', + lang: 'fi', + }; + + const collection = app.mongo.db.collection('subscription'); + const insertResult = await collection.insertOne(testSubscription); + const subscriptionId = insertResult.insertedId; + + t.after(async () => { + await collection.deleteOne({ _id: subscriptionId }); + }); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/renew/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 200); + const body = JSON.parse(res.payload); + assert.strictEqual(body.statusCode, 200); + assert.strictEqual(body.statusMessage, 'Subscription renewed successfully.'); + assert.ok(body.expiryDate, 'Should return new expiry date'); + + const updated = await collection.findOne({ _id: subscriptionId }); + assert.ok(updated, 'Subscription should exist'); + assert.ok(updated.created.getTime() > oldDate.getTime(), 'Created date should be updated'); + assert.ok(updated.first_created, 'first_created should be set'); + assert.strictEqual(updated.first_created.getTime(), oldDate.getTime(), 'Original date should be archived'); + assert.strictEqual(updated.expiry_notification_sent, 0, 'Expiry notification should be reset'); +}); From b723100e4598c8dc3c4eeb789961d69abcb9697b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Thu, 13 Nov 2025 14:49:55 +0200 Subject: [PATCH 097/228] Hotfix, remove duplicate type. No idea how this got through --- src/types/subscription.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 351b5d8..132c535 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -73,9 +73,3 @@ export const SubscriptionGenericPostResponse = Type.Object({ statusMessage: Type.Optional(Type.String()), }); export type SubscriptionGenericPostResponseType = Static; - -// Response for subscription status check -export const SubscriptionStatusResponse = Type.Object({ - subscriptionStatus: Type.Union([Type.Literal('active'), Type.Literal('inactive'), Type.Literal('disabled')]), -}); -export type SubscriptionStatusResponseType = Static; From 88912267548c777a3fdad6c600c2fb07519cc458 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 12:45:24 +0200 Subject: [PATCH 098/228] Update node version to match the one deployed to openshift --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index 9de2256..deed13c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/iron +lts/jod From eae0df26b7f06092d12a650b9eb19963a45b5f14 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 12:49:41 +0200 Subject: [PATCH 099/228] Move c8 to devDependencies C8 seems to be related to coverate calculations: https://www.npmjs.com/package/c8 --- package-lock.json | 89 ++++++++++++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 859aefd..c41ad3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@immobiliarelabs/fastify-sentry": "^8.0.1", "@sinclair/typebox": "^0.32.9", "axios": "^1.6.7", - "c8": "^9.1.0", "dotenv": "^16.3.1", "fastify": "^4.0.0", "fastify-cli": "^6.0.1", @@ -34,6 +33,7 @@ "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", + "c8": "^9.1.0", "concurrently": "^8.2.2", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", @@ -56,7 +56,9 @@ "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==" + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" }, "node_modules/@biomejs/biome": { "version": "2.2.5", @@ -317,6 +319,8 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -325,6 +329,7 @@ "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" } @@ -332,7 +337,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", @@ -461,7 +467,8 @@ "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==" + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true }, "node_modules/@types/jsdom": { "version": "21.1.6", @@ -610,6 +617,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" } @@ -768,6 +776,8 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==", + "dev": true, + "license": "ISC", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@istanbuljs/schema": "^0.1.3", @@ -792,6 +802,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -807,6 +818,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -821,6 +833,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -835,6 +848,7 @@ "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==", + "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -849,6 +863,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "engines": { "node": ">=8" } @@ -922,6 +937,7 @@ "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", @@ -976,7 +992,9 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" }, "node_modules/concurrently": { "version": "8.2.2", @@ -1008,7 +1026,8 @@ "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==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/cookie": { "version": "0.7.2", @@ -1029,6 +1048,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1183,7 +1203,8 @@ "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", @@ -1253,6 +1274,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, "engines": { "node": ">=6" } @@ -1488,11 +1510,13 @@ } }, "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==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -1571,6 +1595,7 @@ "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.*" } @@ -1734,7 +1759,8 @@ "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==" + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true }, "node_modules/http-errors": { "version": "2.0.0", @@ -1864,6 +1890,7 @@ "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" } @@ -1907,7 +1934,9 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" }, "node_modules/isobject": { "version": "3.0.1", @@ -1921,6 +1950,7 @@ "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==", + "dev": true, "engines": { "node": ">=8" } @@ -1929,6 +1959,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -1942,6 +1973,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -1953,6 +1985,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -2088,6 +2121,7 @@ "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==", + "dev": true, "dependencies": { "semver": "^7.5.3" }, @@ -2331,6 +2365,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2339,6 +2375,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2619,6 +2657,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" } @@ -2768,6 +2807,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -2779,6 +2820,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2796,6 +2839,8 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -2870,6 +2915,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", @@ -2883,6 +2929,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" }, @@ -2925,6 +2972,8 @@ "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==", + "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -2938,6 +2987,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2948,6 +2998,9 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2967,6 +3020,8 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3170,6 +3225,7 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -3183,6 +3239,7 @@ "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3258,6 +3315,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -3272,6 +3331,7 @@ "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", @@ -3327,6 +3387,7 @@ "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" } @@ -3340,6 +3401,7 @@ "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", @@ -3374,6 +3436,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index d93fe29..eb27115 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@immobiliarelabs/fastify-sentry": "^8.0.1", "@sinclair/typebox": "^0.32.9", "axios": "^1.6.7", - "c8": "^9.1.0", "dotenv": "^16.3.1", "fastify": "^4.0.0", "fastify-cli": "^6.0.1", @@ -55,6 +54,7 @@ "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", + "c8": "^9.1.0", "concurrently": "^8.2.2", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", From 5d5ea9c8c6ba52af9960010ec9e494496cbcc541 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 12:52:26 +0200 Subject: [PATCH 100/228] Remove redundant npm run copy:assets calls from defined commands --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index eb27115..a493640 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ }, "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", + "start": "npm run build:ts && npm run hav:init-mongodb && 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": "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", "info": "fastify print-routes ./routes/root.ts", "lint": "biome check --write src/", From 25438e11604077a7ccb9a466eed33a0ffd181edc Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 12:55:42 +0200 Subject: [PATCH 101/228] Run tests on CI --- .github/workflows/test.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0eeaf0a..99584ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,17 @@ jobs: with: fetch-depth: 0 + - name: Setup Node.js environment + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v6 env: From 5b03c488be9f030070d560abd6bb6145039e1e61 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 13:24:09 +0200 Subject: [PATCH 102/228] Remove useless override of make up --- Dockerfile | 5 ++++- tools/make/project/build.mk | 6 ------ 2 files changed, 4 insertions(+), 7 deletions(-) delete mode 100644 tools/make/project/build.mk diff --git a/Dockerfile b/Dockerfile index 0f9a9ac..5d10279 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ -FROM registry.access.redhat.com/ubi9/nodejs-20 +# This docker image is used for local development. +# The production environment runs image generated +# with openshift/Dockerfile. +FROM registry.access.redhat.com/ubi9/nodejs-22 ENV npm_config_cache="$HOME/.npm" ENV APP_NAME rekry-hakuvahti diff --git a/tools/make/project/build.mk b/tools/make/project/build.mk deleted file mode 100644 index c7e32c2..0000000 --- a/tools/make/project/build.mk +++ /dev/null @@ -1,6 +0,0 @@ -PHONY += up -up: ## Build and launch the environment - $(call step,Build the container(s)...\n) - $(call docker_compose,build) - $(call step,Start up the container(s)...\n) - $(call docker_compose,up --wait --remove-orphans) From dedf27c67fbb6a263334cbcc144426f5e4176556 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 14:33:25 +0200 Subject: [PATCH 103/228] Simplify tools --- .dockerignore | 1 - .github/workflows/test.yml | 3 + Dockerfile | 16 --- README.md | 92 ++++---------- compose.yaml | 14 ++- crontab | 10 -- documentation/dialogi-server.md | 37 ++++++ entrypoint.sh | 7 -- openshift/Dockerfile | 17 +++ package.json | 7 +- src/routes/subscriptionStatus.ts | 2 +- test/plugins/example.test.ts | 12 -- tools/make/Makefile | 5 - tools/make/ansible.mk | 35 ------ tools/make/common.mk | 18 +-- tools/make/composer.mk | 32 ----- tools/make/drupal.mk | 204 ------------------------------- tools/make/include.mk | 57 +-------- tools/make/javascript.mk | 44 ------- tools/make/kubectl.mk | 56 --------- tools/make/lagoon.mk | 38 ------ tools/make/project/node.mk | 43 +++++++ tools/make/project/shell.mk | 4 +- tools/make/qa.mk | 73 ----------- tools/make/symfony.mk | 78 ------------ 25 files changed, 146 insertions(+), 759 deletions(-) delete mode 100644 Dockerfile delete mode 100644 crontab create mode 100644 documentation/dialogi-server.md delete mode 100755 entrypoint.sh delete mode 100644 test/plugins/example.test.ts delete mode 100644 tools/make/ansible.mk delete mode 100644 tools/make/composer.mk delete mode 100644 tools/make/drupal.mk delete mode 100644 tools/make/javascript.mk delete mode 100644 tools/make/kubectl.mk delete mode 100644 tools/make/lagoon.mk create mode 100644 tools/make/project/node.mk delete mode 100644 tools/make/qa.mk delete mode 100644 tools/make/symfony.mk diff --git a/.dockerignore b/.dockerignore index d15df29..373bfc5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules # npm-debug.log -# Dockerfile # .dockerignore .git # .gitignore diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 99584ce..28be3c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint + run: npm run lint + - name: Run tests run: npm run test diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 5d10279..0000000 --- a/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -# This docker image is used for local development. -# The production environment runs image generated -# with openshift/Dockerfile. -FROM registry.access.redhat.com/ubi9/nodejs-22 - -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 ac3a376..beb7423 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ 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 site configuration. @@ -31,38 +31,34 @@ Pre-requisities to use Hakuvahti are: 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. + - ElasticProxy (default to local rekry elasticsearch), + - `ATC_API_KEY` (Hakuvahti will trigger an error if ATV cannot be reached) - Configure site-specific settings in `conf/` directory (see Configuration section below) -- `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 +Start the local environment with: -- `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. -- Configure site-specific settings in `conf/` directory (see Configuration section below) -- 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-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) - - `npm run hav:send-sms-in-queue` (optional, for SMS notifications - should be run at least once per minute) +```bash +make fresh +``` + +Hakuvahti should be availabe at `http://localhost:3000`. + +Get a shell inside the container: + +```bash +make down +``` + +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 @@ -251,7 +247,7 @@ For SMS notifications to work: Adds new Hakuvahti subscription: -``` +```json { "elastic_query": "", "search_description": "", @@ -339,40 +335,6 @@ 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 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` +### Mock server -`npm run hav:update-schema` +See [dialogi-server.md](./documentation/dialogi-server.md). \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 3207d09..2d8ad76 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,13 +9,14 @@ services: - hav-internal - helfi-hakuvahti-network - nodejs: + app: user: root build: context: . - dockerfile: Dockerfile + dockerfile: openshift/Dockerfile + target: development volumes: - - .:/opt/app-root/src:delegated + - .:/app:delegated ports: - "3000:3000" depends_on: @@ -27,6 +28,13 @@ services: - "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" networks: hav-internal: 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/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 0f11382..e73be5a 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -11,6 +11,23 @@ RUN \ npm run copy:assets && \ npx tsc +# Development image + +FROM registry.access.redhat.com/ubi9/nodejs-22 as development + +ENV npm_config_cache="$HOME/.npm" +ENV APP_NAME rekry-hakuvahti + +WORKDIR /app + +RUN mkdir -p "$HOME/node_modules" "$HOME/logs" + +EXPOSE 3000 + +CMD ["npm", "run", "dev"] + +# Production image + FROM registry.access.redhat.com/ubi9/nodejs-22 ENV npm_config_cache="/tmp/.npm" diff --git a/package.json b/package.json index a493640..e66fd85 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,11 @@ }, "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 hav:init-mongodb && fastify start -l info dist/app.js", + "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 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/", diff --git a/src/routes/subscriptionStatus.ts b/src/routes/subscriptionStatus.ts index 2aa441d..8863afa 100644 --- a/src/routes/subscriptionStatus.ts +++ b/src/routes/subscriptionStatus.ts @@ -3,9 +3,9 @@ import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest import { Generic500Error, type Generic500ErrorType } from '../types/error'; import { + SubscriptionStatus, SubscriptionStatusResponse, type SubscriptionStatusResponseType, - SubscriptionStatus, } from '../types/subscription'; // Checks subscription status diff --git a/test/plugins/example.test.ts b/test/plugins/example.test.ts deleted file mode 100644 index ffbaf8a..0000000 --- a/test/plugins/example.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -// import { test } from 'node:test' -// import * as assert from 'node:assert' -// -// import Fastify from 'fastify' -// -// test('something is loaded', async (t) => { -// const fastify = Fastify() -// void fastify.register(something) -// await fastify.ready() -// -// assert.equal(fastify.something(), 'hugs') -// }) diff --git a/tools/make/Makefile b/tools/make/Makefile index fa00088..8ee616a 100644 --- a/tools/make/Makefile +++ b/tools/make/Makefile @@ -31,11 +31,6 @@ debug: ## Show debug information $(call dbg,RUN_ON,${RUN_ON}) $(call dbg,Composer on host,$(call has,composer)) $(call dbg,COMPOSER_BIN,$(shell command -v composer || echo no)) - $(call dbg,COMPOSER_JSON_EXISTS,${COMPOSER_JSON_EXISTS}) - $(call dbg,IS_DRUPAL,${IS_DRUPAL}) - $(call dbg,IS_SYMFONY,${IS_SYMFONY}) - $(call dbg,LAGOON,${LAGOON}) - $(call dbg,SYSTEM,${SYSTEM}) $(call dbg,WEBROOT,${WEBROOT}) $(call dbg,UNAME_S,${UNAME_S}) ifeq ($(RUN_ON),docker) diff --git a/tools/make/ansible.mk b/tools/make/ansible.mk deleted file mode 100644 index 132f84c..0000000 --- a/tools/make/ansible.mk +++ /dev/null @@ -1,35 +0,0 @@ -ANSIBLE_ROLES_PATH ?= ansible/roles -ANSIBLE_CHECK_ROLE ?= geerlingguy.docker -ANSIBLE_PLAYBOOK ?= ansible-playbook -ANSIBLE_PROVISION ?= ansible/provision.yml -ANSIBLE_REQUIREMENTS ?= ansible/requirements.yml -ANSIBLE_FLAGS ?= - -PHONY += provision -provision: $(ANSIBLE_ROLES_PATH)/$(ANSIBLE_CHECK_ROLE) ## Make provisioning - $(call step,Ansible: Make dry run on provisioning...\n) - @$(ANSIBLE_PLAYBOOK) $(ANSIBLE_PROVISION) $(ANSIBLE_FLAGS) - -PHONY += provision-% -provision-%: $(ANSIBLE_ROLES_PATH)/$(ANSIBLE_CHECK_ROLE) ## Make provisioning by tag - $(call step,Ansible: Make provisioning by tag "$*"...\n) - @$(ANSIBLE_PLAYBOOK) $(ANSIBLE_PROVISION) --tags="$*" $(ANSIBLE_FLAGS) - -PHONY += provision-dry-run -provision-dry-run: $(ANSIBLE_ROLES_PATH)/$(ANSIBLE_CHECK_ROLE) ## Make dry run on provisioning - $(call step,Ansible: Make dry run on provisioning...\n) - @$(ANSIBLE_PLAYBOOK) $(ANSIBLE_PROVISION) $(ANSIBLE_FLAGS) --check - -PHONY += ansible-install-roles -ansible-install-roles: ## Install Ansible roles - $(call step,Ansible: Install Ansible roles...\n) - @ansible-galaxy install -r $(ANSIBLE_REQUIREMENTS) -p $(ANSIBLE_ROLES_PATH) - -PHONY += ansible-update-roles -ansible-update-roles: ## Update Ansible roles - $(call step,Ansible: Update Ansible roles...\n) - @ansible-galaxy remove --roles-path=$(ANSIBLE_ROLES_PATH) $(shell find $(ANSIBLE_ROLES_PATH) -mindepth 1 -maxdepth 1 -type d -exec basename {} \;) || true - @ansible-galaxy install --force-with-deps --role-file=$(ANSIBLE_REQUIREMENTS) --roles-path=$(ANSIBLE_ROLES_PATH) - -$(ANSIBLE_ROLES_PATH)/$(ANSIBLE_CHECK_ROLE): - @$(MAKE) ansible-install-roles diff --git a/tools/make/common.mk b/tools/make/common.mk index 0ede445..87f868e 100644 --- a/tools/make/common.mk +++ b/tools/make/common.mk @@ -45,11 +45,6 @@ clean: ## Cleanup @rm -rf vendor @git clean -fdx $(foreach item,$(CLEAN_EXCLUDE),-e $(item)) -PHONY += self-update -self-update: ## Self-update makefiles from druidfi/tools - $(call step,Update makefiles from druidfi/tools\n) - @bash -c "$$(curl -fsSL $(UPDATE_SCRIPT_URL))" - PHONY += shell-% shell-%: OPTS = $(INSTANCE_$*_OPTS) shell-%: USER = $(INSTANCE_$*_USER) @@ -61,15 +56,4 @@ shell-%: ## Login to remote instance PHONY += sync sync: ## Sync data from other environments $(call group_step,Sync:$(NO_COLOR) $(SYNC_TARGETS)) - @$(MAKE) $(SYNC_TARGETS) ENV=$(ENV) - -PHONY += gh-download-dump -gh-download-dump: GH_FLAGS += $(if $(GH_ARTIFACT),-n $(GH_ARTIFACT),-n latest-dump) -gh-download-dump: GH_FLAGS += $(if $(GH_REPO),-R $(GH_REPO),) -gh-download-dump: ## Download database dump from repository artifacts - $(call step,Download database dump from repository artifacts\n) -ifeq ($(DUMP_SQL_EXISTS),no) - $(call run,gh run download $(strip $(GH_FLAGS)),Downloaded $(DUMP_SQL_FILENAME),Failed) -else - @echo "There is already $(DUMP_SQL_FILENAME)" -endif + @$(MAKE) $(SYNC_TARGETS) ENV=$(ENV) \ No newline at end of file diff --git a/tools/make/composer.mk b/tools/make/composer.mk deleted file mode 100644 index 4dd2195..0000000 --- a/tools/make/composer.mk +++ /dev/null @@ -1,32 +0,0 @@ -BUILD_TARGETS += composer-install -COMPOSER_PROD_FLAGS := --no-dev --optimize-autoloader --prefer-dist - -PHONY += composer-info -composer-info: ## Composer info - $(call step,Do Composer info...\n) - $(call composer,info) - -PHONY += composer-update -composer-update: ## Update Composer packages - $(call step,Do Composer update...\n) - $(call composer,update) - -PHONY += composer-install -composer-install: ## Install Composer packages - $(call step,Do Composer install...\n) - $(call composer,install$(if $(filter production,$(ENV)), $(COMPOSER_PROD_FLAGS),)) - -PHONY += composer-outdated -composer-outdated: ## Show outdated Composer packages - $(call step,Show outdated Composer packages...\n) - $(call composer,outdated --direct) - -ifeq ($(RUN_ON),docker) -define composer - $(call docker_compose_exec,composer --ansi$(if $(filter $(COMPOSER_JSON_PATH),.),, --working-dir=$(COMPOSER_JSON_PATH)) $(1)) -endef -else -define composer - @composer --ansi$(if $(filter $(COMPOSER_JSON_PATH),.),, --working-dir=$(COMPOSER_JSON_PATH)) $(1) -endef -endif diff --git a/tools/make/drupal.mk b/tools/make/drupal.mk deleted file mode 100644 index 5f81548..0000000 --- a/tools/make/drupal.mk +++ /dev/null @@ -1,204 +0,0 @@ -BUILD_TARGETS += drupal-create-folders -DRUPAL_CONF_EXISTS := $(shell test -f conf/cmi/core.extension.yml && echo yes || echo no) -DRUPAL_FRESH_TARGETS := up build sync post-install -DRUPAL_NEW_TARGETS := up build drush-si drush-uli -DRUPAL_POST_INSTALL_TARGETS := drush-deploy -CLEAN_EXCLUDE += $(WEBROOT)/sites/default/files -DRUPAL_DISABLE_MODULES ?= no -DRUPAL_ENABLE_MODULES ?= no -DRUPAL_PROFILE ?= minimal -DRUPAL_SYNC_FILES ?= yes -DRUPAL_SYNC_SOURCE ?= main -DRUSH_RSYNC_MODE ?= Pakzu -DRUSH_RSYNC_OPTS ?= -- --omit-dir-times --no-perms --no-group --no-owner --chmod=ugo=rwX -DRUSH_RSYNC_EXCLUDE ?= css:ctools:js:php:tmp:tmp_php -SYNC_TARGETS += drush-sync -CS_EXTS := inc,php,module,install,profile,theme -CS_STANDARD_PATHS := vendor/drupal/coder/coder_sniffer,vendor/slevomat/coding-standard -CS_STANDARDS := Drupal,DrupalPractice -LINT_PATHS_JS += ./$(WEBROOT)/modules/custom/*/js -LINT_PATHS_JS += ./$(WEBROOT)/themes/custom/*/js -LINT_PATHS_PHP += drush -LINT_PATHS_PHP += $(WEBROOT)/modules/custom -LINT_PATHS_PHP += $(WEBROOT)/themes/custom -LINT_PHP_TARGETS += lint-drupal -FIX_TARGETS += fix-drupal -DRUPAL_CREATE_FOLDERS := $(WEBROOT)/sites/default/files/private -DRUPAL_CREATE_FOLDERS += $(WEBROOT)/sites/default/files/translations - -ifeq ($(GH_DUMP_ARTIFACT),yes) - DRUPAL_FRESH_TARGETS := gh-download-dump $(DRUPAL_FRESH_TARGETS) -endif - -ifneq ($(DRUPAL_DISABLE_MODULES),no) - SYNC_TARGETS += drush-disable-modules -endif - -ifneq ($(DRUPAL_ENABLE_MODULES),no) - DRUPAL_POST_INSTALL_TARGETS += drush-enable-modules -endif - -PHONY += drupal-create-folders -drupal-create-folders: - $(call step,Create folders for Drupal...\n) - $(call docker_compose_exec,mkdir -v -p $(DRUPAL_CREATE_FOLDERS)) - -PHONY += drupal-update -drupal-update: ## Update Drupal core with Composer - $(call step,Update Drupal core with Composer...\n) - $(call composer,update -W "drupal/core-*") - -PHONY += drush-cex -drush-cex: ## Export configuration - $(call step,Export configuration...\n) - $(call drush,cex -y) - -PHONY += drush-cim -drush-cim: ## Import configuration - $(call step,Import configuration...\n) - $(call drush,cim -y) - -PHONY += drush-cr -drush-cr: ## Clear caches - $(call step,Clearing caches...\n) - $(call drush,cr) - -PHONY += drush-status -drush-status: ## Show Drupal status information - $(call drush,status) - -PHONY += drush-uli -drush-uli: DRUPAL_UID ?= -drush-uli: DRUPAL_DESTINATION ?= admin/reports/status -drush-uli: ## Get login link - $(call step,Login to your site with:\n) - $(call drush,uli$(if $(DRUPAL_UID), --uid=$(DRUPAL_UID),) $(DRUPAL_DESTINATION)) - -PHONY += drush-uli-% -drush-uli-%: ## Get login link for provided uid - $(call step,Login to your site as user $* with:\n) - $(call drush,uli --uid=$*) - -PHONY += drush-si -ifeq ($(DRUPAL_CONF_EXISTS),yes) - drush-si: DRUSH_SI := -y --existing-config -else - drush-si: DRUSH_SI := -y $(DRUPAL_PROFILE) -endif -drush-si: ## Site install - $(call step,Do Drush site:install...\n) - $(call drush,si ${DRUSH_SI}) - -PHONY += drush-deploy -drush-deploy: ## Run Drush deploy - $(call step,Run Drush deploy...\n) - $(call drush,deploy) - -PHONY += drush-updb -drush-updb: ## Run database updates - $(call step,Run database updates...\n) - $(call drush,updb -y) - -PHONY += drush-reset-local -drush-reset-local: ## Reset local configuration (cim, cr, updb, cr) - $(call step,Reset local configuration...\n) - $(call drush,cim -y) - $(call drush,cr) - $(call drush,updb -y --no-cache-clear) - $(call drush,cr) - -PHONY += fresh -fresh: ## Build fresh development environment and sync - @$(MAKE) $(DRUPAL_FRESH_TARGETS) - -PHONY += new -new: ## Create a new empty Drupal installation from configuration - @$(MAKE) $(DRUPAL_NEW_TARGETS) - -PHONY += post-install -post-install: ## Run post-install Drush actions - @$(MAKE) $(DRUPAL_POST_INSTALL_TARGETS) drush-uli - -PHONY += drush-disable-modules -drush-disable-modules: ## Disable Drupal modules - $(call step,Disable Drupal modules...\n) -ifneq ($(DRUPAL_DISABLE_MODULES),no) - $(call drush,pmu -y $(subst ",,$(DRUPAL_DISABLE_MODULES))) -else - $(call sub_step,No modules to disable) -endif - -PHONY += drush-enable-modules -drush-enable-modules: ## Enable Drupal modules - $(call step,Enable Drupal modules...\n) -ifneq ($(DRUPAL_ENABLE_MODULES),no) - $(call drush,en -y $(subst ",,$(DRUPAL_ENABLE_MODULES))) -else - $(call sub_step,No modules to enable) -endif - -PHONY += drush-sync -drush-sync: drush-sync-db drush-sync-files ## Sync database and files - -PHONY += drush-sync-db -drush-sync-db: ## Sync database - $(call drush,sql-drop --quiet -y) -ifeq ($(DUMP_SQL_EXISTS),yes) - $(call step,Import local SQL dump...) - $(call drush,sql-query --file=${DOCKER_PROJECT_ROOT}/$(DUMP_SQL_FILENAME) && echo 'SQL dump imported') -else - $(call step,Sync database from @$(DRUPAL_SYNC_SOURCE)...) - $(call drush,sql-sync -y --structure-tables-key=common @$(DRUPAL_SYNC_SOURCE) @self) -endif - -PHONY += drush-sync-files -drush-sync-files: ## Sync files -ifeq ($(DRUPAL_SYNC_FILES),yes) - $(call step,Sync files from @$(DRUPAL_SYNC_SOURCE)...) - $(call drush,-y rsync --exclude-paths=$(DRUSH_RSYNC_EXCLUDE) --mode=$(DRUSH_RSYNC_MODE) @$(DRUPAL_SYNC_SOURCE):%files @self:%files $(DRUSH_RSYNC_OPTS)) -endif - -PHONY += drush-create-dump -drush-create-dump: FLAGS := --structure-tables-key=common --extra-dump=--no-tablespaces -drush-create-dump: ## Create database dump to dump.sql - $(call drush,sql-dump $(FLAGS) --result-file=${DOCKER_PROJECT_ROOT}/$(DUMP_SQL_FILENAME)) - -PHONY += drush-download-dump -drush-download-dump: ## Download database dump to dump.sql - $(call drush,@$(DRUPAL_SYNC_SOURCE) sql-dump --structure-tables-key=common > ${DOCKER_PROJECT_ROOT}/$(DUMP_SQL_FILENAME)) - -PHONY += open-db-gui -open-db-gui: ## Open database with GUI tool - $(eval DB_SERVICE ?= db) - $(eval DB_NAME ?= drupal) - $(eval DB_USER ?= drupal) - $(eval DB_PASS ?= drupal) - @open mysql://$(DB_USER):$(DB_PASS)@$(shell docker compose port $(DB_SERVICE) 3306 | grep -v ::)/$(DB_NAME) - -PHONY += fix-drupal -fix-drupal: PATHS := $(subst $(space),,$(LINT_PATHS_PHP)) -fix-drupal: ## Fix Drupal code style - $(call step,Fix Drupal code style with phpcbf...\n) - $(call cs,phpcbf,$(PATHS)) - -PHONY += lint-drupal -lint-drupal: PATHS := $(subst $(space),,$(LINT_PATHS_PHP)) -lint-drupal: ## Lint Drupal code style - $(call step,Lint Drupal code style with phpcs...\n) - $(call cs,phpcs,$(PATHS)) - -PHONY += mmfix -mmfix: MODULE := MISSING_MODULE -mmfix: - $(call step,Remove missing module '$(MODULE)'\n) - $(call drush,sql-query \"DELETE FROM key_value WHERE collection='system.schema' AND name='$(MODULE)';\",Module was removed) - -ifeq ($(RUN_ON),docker) -define drush - $(call docker_compose_exec,drush $(1),$(2)) -endef -else -define drush - @drush $(1) -endef -endif diff --git a/tools/make/include.mk b/tools/make/include.mk index de30c41..d54152d 100644 --- a/tools/make/include.mk +++ b/tools/make/include.mk @@ -2,59 +2,4 @@ include $(DRUIDFI_TOOLS_MAKE_DIR)common.mk ifeq ($(call has,docker),yes) include $(DRUIDFI_TOOLS_MAKE_DIR)docker.mk -endif - -include $(DRUIDFI_TOOLS_MAKE_DIR)qa.mk - -# -# Apps -# - -IS_DRUPAL ?= $(shell test -f $(WEBROOT)/sites/default/settings.php && echo yes || echo no) -IS_SYMFONY ?= $(shell test -f config/bundles.php && echo yes || echo no) - -ifeq ($(IS_DRUPAL),yes) -include $(DRUIDFI_TOOLS_MAKE_DIR)drupal.mk -endif - -ifeq ($(IS_SYMFONY),yes) -include $(DRUIDFI_TOOLS_MAKE_DIR)symfony.mk -endif - -# -# Other tools -# - -HAS_ANSIBLE ?= $(shell test -d ansible && echo yes || echo no) - -ifeq ($(HAS_ANSIBLE),yes) -include $(DRUIDFI_TOOLS_MAKE_DIR)ansible.mk -endif - -# -# Hosting systems -# - -LAGOON := $(shell test -f .lagoon.yml && echo yes || echo no) - -ifeq ($(LAGOON),yes) - SYSTEM := LAGOON -else - SYSTEM := WHOKNOWS -endif - -ifeq ($(SYSTEM),LAGOON) -include $(DRUIDFI_TOOLS_MAKE_DIR)lagoon.mk -endif - -COMPOSER_JSON_EXISTS := $(shell test -f $(COMPOSER_JSON_PATH)/composer.json && echo yes || echo no) - -ifeq ($(COMPOSER_JSON_EXISTS),yes) -include $(DRUIDFI_TOOLS_MAKE_DIR)composer.mk -endif - -PACKAGE_JSON_EXISTS := $(shell test -f $(PACKAGE_JSON_PATH)/package.json && echo yes || echo no) - -ifeq ($(PACKAGE_JSON_EXISTS),yes) -include $(DRUIDFI_TOOLS_MAKE_DIR)javascript.mk -endif +endif \ No newline at end of file diff --git a/tools/make/javascript.mk b/tools/make/javascript.mk deleted file mode 100644 index 9b8c2fc..0000000 --- a/tools/make/javascript.mk +++ /dev/null @@ -1,44 +0,0 @@ -BUILD_TARGETS += js-install -JS_PACKAGE_MANAGER ?= yarn -JS_PACKAGE_MANAGER_CWD_FLAG_NPM ?= --prefix -JS_PACKAGE_MANAGER_CWD_FLAG_YARN ?= --cwd -INSTALLED_NODE_VERSION := $(shell command -v node > /dev/null && node --version | cut -c2-3 || echo no) -NVM_SH := $(HOME)/.nvm/nvm.sh -NVM := $(shell test -f "$(NVM_SH)" && echo yes || echo no) -NODE_BIN := $(shell command -v node || echo no) -NPM_BIN := $(shell command -v npm || echo no) -YARN_BIN := $(shell command -v yarn || echo no) -NODE_VERSION ?= 16 - -PHONY += js-install -js-install: ## Install JS packages -ifeq ($(JS_PACKAGE_MANAGER),yarn) - $(call node_run,install --frozen-lockfile) -else - $(call node_run,install --no-audit --no-fund --engine-strict true) -endif - -PHONY += js-outdated -js-outdated: ## Show outdated JS packages - $(call step,Show outdated JS packages with $(JS_PACKAGE_MANAGER)...) - $(call node_run,outdated) - -ifeq ($(NVM),no) -define node_run - $(call error,$(NVM_REQUIRED)) -endef -else -define node_run - $(call step,Run '$(JS_PACKAGE_MANAGER) $(1)' with Node $(NODE_VERSION)...\n) - @. $(NVM_SH) && (nvm which $(NODE_VERSION) > /dev/null 2>&1 || nvm install $(NODE_VERSION)) && \ - nvm exec $(NODE_VERSION) $(JS_PACKAGE_MANAGER) $(if $(filter $(JS_PACKAGE_MANAGER),yarn),$(JS_PACKAGE_MANAGER_CWD_FLAG_YARN),$(JS_PACKAGE_MANAGER_CWD_FLAG_NPM)) $(PACKAGE_JSON_PATH) $(1) -endef -endif - -define NVM_REQUIRED - - -🚫 NVM is required to run $(JS_PACKAGE_MANAGER) commands and control Node versions! - - -endef diff --git a/tools/make/kubectl.mk b/tools/make/kubectl.mk deleted file mode 100644 index 60f5003..0000000 --- a/tools/make/kubectl.mk +++ /dev/null @@ -1,56 +0,0 @@ -KUBECTL_BIN := $(shell command -v kubectl || echo no) -KUBECTL_NAMESPACE ?= foobar-namespace -KUBECTL_SHELL ?= sh -KUBECTL_EXEC_FLAGS ?= -n $(KUBECTL_NAMESPACE) -c $(KUBECTL_CONTAINER) -KUBECTL_POD_SELECTOR ?= appName=foobar-app -KUBECTL_WORKDIR ?= /app - -PHONY += kubectl-sync-db -kubectl-sync-db: ## Sync database from Kubernetes - $(call drush,sql-drop --quiet -y) -ifeq ($(DUMP_SQL_EXISTS),no) - $(eval POD := $(call kubectl_get_pod)) - $(call step,Get database dump from $(POD)...\n) - $(KUBECTL_BIN) exec $(KUBECTL_EXEC_FLAGS) $(POD) -- drush sql-dump --structure-tables-key=common --extra-dump=--no-tablespaces --result-file=/tmp/$(DUMP_SQL_FILENAME) --gzip - $(KUBECTL_BIN) cp $(KUBECTL_EXEC_FLAGS) --retries=-1 $(POD):/tmp/$(DUMP_SQL_FILENAME).gz ./$(DUMP_SQL_FILENAME).gz - $(KUBECTL_BIN) exec $(KUBECTL_EXEC_FLAGS) $(POD) -- rm -f /tmp/$(DUMP_SQL_FILENAME).gz - @gzip -d $(DUMP_SQL_FILENAME).gz -endif - $(call step,Import local SQL dump...\n) - $(call drush,sql-query --file=${DOCKER_PROJECT_ROOT}/$(DUMP_SQL_FILENAME)) - -PHONY += kubectl-sync-files-tar -kubectl-sync-files-tar: ## Sync files from Kubernetes using tar - $(call step,Copy files from remote...\n) - $(eval POD := $(call kubectl_get_pod)) - $(KUBECTL_BIN) exec $(KUBECTL_EXEC_FLAGS) $(POD) -- tar cf - $(SYNC_FILES_EXCLUDE) $(SYNC_FILES_PATH) | tar xfv - -C . - -PHONY += kubectl-rsync-files -kubectl-rsync-files: FLAGS := -aurP --blocking-io -kubectl-rsync-files: REMOTE_PATH := $(KUBECTL_WORKDIR)/$(SYNC_FILES_PATH)/ -kubectl-rsync-files: LOCAL_PATH := ./$(SYNC_FILES_PATH)/ -kubectl-rsync-files: ## Sync files from Kubernetes using rsync - $(call step,Sync files from remote...\n) - $(eval POD := $(call kubectl_get_pod)) - rsync $(FLAGS) $(SYNC_FILES_EXCLUDE) --rsync-path=$(REMOTE_PATH) -e '$(KUBECTL_BIN) exec -i $(KUBECTL_EXEC_FLAGS) $(POD) -- env ' rsync: $(LOCAL_PATH) - -PHONY += kubectl-shell -kubectl-shell: ## Open shell to Pod in Kubernetes - $(eval POD := $(call kubectl_get_pod)) - $(KUBECTL_BIN) exec $(KUBECTL_EXEC_FLAGS) -ti $(POD) -- $(KUBECTL_SHELL) - -define kubectl_exec - $(KUBECTL_BIN) exec $(KUBECTL_EXEC_FLAGS) $(1) -- $(KUBECTL_SHELL) -c '$(2)' -endef - -define kubectl_exec_to_file - $(KUBECTL_BIN) exec $(KUBECTL_EXEC_FLAGS) $(1) -- $(KUBECTL_SHELL) -c '$(2)' > $(3) -endef - -define kubectl_cp - $(KUBECTL_BIN) cp $(KUBECTL_EXEC_FLAGS) $(1) $(2) -endef - -define kubectl_get_pod - $(shell $(KUBECTL_BIN) get pods -n $(KUBECTL_NAMESPACE) --selector=$(KUBECTL_POD_SELECTOR) --template '{{range .items}}{{ if not .metadata.deletionTimestamp }}{{.metadata.name}}{{"\n"}}{{end}}{{end}}') -endef diff --git a/tools/make/lagoon.mk b/tools/make/lagoon.mk deleted file mode 100644 index e6baeba..0000000 --- a/tools/make/lagoon.mk +++ /dev/null @@ -1,38 +0,0 @@ -CLI_SERVICE := cli -CLI_SHELL := bash -DB_SERVICE := mariadb - -INSTANCE_prod_USER ?= project-name-branch -INSTANCE_prod_HOST ?= ssh.lagoon.amazeeio.cloud -INSTANCE_prod_OPTS ?= $(SSH_OPTS) -p 32222 -t -INSTANCE_test_USER ?= project-name-branch -INSTANCE_test_HOST ?= $(INSTANCE_prod_HOST) -INSTANCE_test_OPTS ?= $(INSTANCE_prod_OPTS) - -ifeq ($(MAKECMDGOALS),set-lagoon-secrets) -include .env.local.lagoon -endif - -PHONY += lagoon-env -lagoon-env: ## Print Lagoon env variables - $(call docker_compose_exec,printenv | grep LAGOON_) - -PHONY += deploy-lagoon-% -deploy-lagoon-%: ## Deploy lagoon branch - $(call step,Deploy Lagoon branch $*...\n) - @lagoon -p $(LAGOON_PROJECT) deploy branch -b $* - -PHONY += set-lagoon-secrets-% -set-lagoon-secrets-%: ## Set Lagoon secrets - $(call step,Set Lagoon secrets on $*...\n) - @$(foreach secret,$(LAGOON_SECRETS),$(call set_lagoon_secret,$(secret),$*)) - -PHONY += list-lagoon-vars-% -list-lagoon-vars-%: ## List variables from Lagoon - $(call step,List variables from Lagoon on $*...\n) - @lagoon -p $(LAGOON_PROJECT) list v --reveal -e $* - -define set_lagoon_secret -printf "Setting secret on ${2}: %s = %s \n" "${1}" "${${1}}"; -lagoon -p $(LAGOON_PROJECT) a v -N "${1}" -V "${${1}}" -S runtime -e ${2} --force || true; -endef diff --git a/tools/make/project/node.mk b/tools/make/project/node.mk new file mode 100644 index 0000000..bd50e9a --- /dev/null +++ b/tools/make/project/node.mk @@ -0,0 +1,43 @@ +NODE_FRESH_TARGETS := up post-install +NODE_POST_INSTALL_TARGETS := npm-install hav-init-db + +PHONY += fresh +fresh: ## Build fresh development environment and sync + @$(MAKE) $(NODE_FRESH_TARGETS) + +PHONY += post-install +post-install: ## Run post-install actions + @$(MAKE) $(NODE_POST_INSTALL_TARGETS) + +PHONY += npm-install +npm-install: ## Run npm install + $(call step,Run npm install...\n) + $(call npm,install) + +PHONY += hav-init-db +hav-init-db: ## Run database updates + $(call step,Run database updates...\n) + $(call npm,run hav:init-mongodb) + +PHONY += lint +lint: + $(call npm,run lint:check) + +PHONY += fix +fix: + $(call npm,run lint) + +PHONY += test +test: ## Run tests + $(call npm,run test) + + +ifeq ($(RUN_ON),docker) +define npm + $(call docker_compose_exec,npm $(1),$(2)) +endef +else +define npm + @npm $(1) +endef +endif diff --git a/tools/make/project/shell.mk b/tools/make/project/shell.mk index b7a1eda..c3dbe7e 100644 --- a/tools/make/project/shell.mk +++ b/tools/make/project/shell.mk @@ -1,2 +1,2 @@ -CLI_SERVICE := nodejs -CLI_SHELL := /bin/sh +CLI_SERVICE := app +CLI_SHELL := /usr/bin/bash diff --git a/tools/make/qa.mk b/tools/make/qa.mk deleted file mode 100644 index f4988fb..0000000 --- a/tools/make/qa.mk +++ /dev/null @@ -1,73 +0,0 @@ -TEST_TARGETS += test-phpunit -FIX_TARGETS := -LINT_PHP_TARGETS := -CS_INSTALLED := $(shell test -f $(COMPOSER_JSON_PATH)/vendor/bin/phpcs && echo yes || echo no) -CS_CONF_EXISTS := $(shell test -f phpcs.xml.dist && echo yes || echo no) -TESTSUITES ?= unit,kernel,functional - -PHONY += fix -fix: ## Fix code style - $(call step,Fix code...) - $(call sub_step,Following targets will be run: $(FIX_TARGETS)) - @$(MAKE) $(FIX_TARGETS) - -PHONY += lint -lint: lint-php lint-js ## Check code style - -PHONY += lint-js -lint-js: DOCKER_NODE_IMG ?= node:$(NODE_VERSION)-alpine -lint-js: WD := /app -lint-js: ## Check code style for JS files - $(call step,Install linters...) - @docker run --rm -v "$(CURDIR)":$(WD):cached -w $(WD) $(DOCKER_NODE_IMG) yarn --cwd $(WEBROOT)/core install - $(call step,Check code style for JS files: $(DRUPAL_LINT_PATHS)) - @docker run --rm -v "$(CURDIR)":$(WD):cached -w $(WD) $(DOCKER_NODE_IMG) \ - $(WEBROOT)/core/node_modules/eslint/bin/eslint.js --color --ignore-pattern '**/vendor/*' \ - --c ./$(WEBROOT)/core/.eslintrc.json --global nav,moment,responsiveNav:true $(LINT_PATHS_JS) - -PHONY += lint-php -lint-php: ## Check code style for PHP files - $(call step,Check code style for PHP files...) - $(call sub_step,Following targets will be run: $(LINT_PHP_TARGETS)) - @$(MAKE) $(LINT_PHP_TARGETS) - $(call test_result,lint-php,"[OK]") - -PHONY += test -test: ## Run tests - $(call group_step,Run test targets:${NO_COLOR} $(TEST_TARGETS)\n) - @$(MAKE) $(TEST_TARGETS) - $(call step,Tests completed.) - -PHONY += test-phpunit -test-phpunit: ## Run PHPUnit tests - $(call step,Run PHPUnit tests...) -ifeq ($(CI),true) - vendor/bin/phpunit -c phpunit.xml.dist --testsuite $(TESTSUITES) -else - $(call docker_compose_exec,${DOCKER_PROJECT_ROOT}/vendor/bin/phpunit -c $(DOCKER_PROJECT_ROOT)/phpunit.xml.dist \ - --testsuite $(TESTSUITES)) -endif - $(call test_result,test-phpunit,"[OK]") - -PHONY += test-phpunit-locally -test-phpunit-locally: - @SIMPLETEST_BASE_URL=https://$(DRUPAL_HOSTNAME) SIMPLETEST_DB=mysql://$(DB_URL) \ - vendor/bin/phpunit -c $(CURDIR)/phpunit.xml.dist --testsuite $(TESTSUITES) - -define test_result - @echo "\n${YELLOW}${1}:${NO_COLOR} ${GREEN}${2}${NO_COLOR}" -endef - -ifeq ($(CS_INSTALLED)-$(CS_CONF_EXISTS),yes-yes) -define cs -$(call docker_compose_exec,vendor/bin/$(1)) -endef -else ifeq ($(CS_INSTALLED)-$(CS_CONF_EXISTS),yes-no) -define cs -$(call docker_compose_exec,vendor/bin/$(1) --standard=$(CS_STANDARDS) --extensions=$(CS_EXTS) --ignore=node_modules $(2)) -endef -else -define cs -$(call warn,CodeSniffer is not installed!) -endef -endif diff --git a/tools/make/symfony.mk b/tools/make/symfony.mk deleted file mode 100644 index 7367207..0000000 --- a/tools/make/symfony.mk +++ /dev/null @@ -1,78 +0,0 @@ -SF_FRESH_TARGETS := up build sf-cw sf-about sf-open -FIX_TARGETS += fix-symfony -LINT_PHP_TARGETS += lint-symfony -CS_FIXER_INSTALLED := $(shell test -f $(COMPOSER_JSON_PATH)/vendor/bin/php-cs-fixer && echo yes || echo no) - -PHONY += encore-dev -encore-dev: ## Do Encore development build - $(call step,Do Encore development build...) - $(call node_run,dev) - -PHONY += encore-watch -encore-watch: ## Run Encore watch - $(call step,Do Encore watch...) - $(call node_run,watch) - -PHONY += sf-about -sf-about: ## Displays information about the current project - $(call sf_console,about) - -PHONY += sf-cc -sf-cc: ## Clear Symfony caches - $(call step,Clear Symfony caches...) - $(call sf_console,cache:clear) - -PHONY += sf-cw -sf-cw: ## Warm Symfony caches - $(call step,Warm Symfony caches...) - $(call sf_console,cache:warmup) - -PHONY += sf-db-init -sf-db-init: ## Setup database schema and load fixtures - $(call step,Setup database schema...) - $(call sf_console,doctrine:schema:update --force) - $(call sf_console,doctrine:fixtures:load -n) - -PHONY += sf-open -sf-open: ## Warm Symfony caches - $(call step,See your Symfony application with:\n) - $(call output,https://$(APP_HOST)) - -PHONY += sf-update -sf-update: ## Update Symfony packages with Composer - $(call step,Update Symfony packages with Composer...\n) - $(call composer,update -W "doctrine/*" "symfony/*" "twig/*" --no-scripts) - -PHONY += fresh -fresh: ## Build fresh development environment - @$(MAKE) $(SF_FRESH_TARGETS) - -PHONY += fix-symfony -fix-symfony: ## Fix Symfony code style - $(call step,Fix Symfony code style...\n) - $(call cs_symfony,fix --ansi src) - -PHONY += lint-symfony -lint-symfony: ## Lint Symfony code style - $(call step,Lint Symfony code style...\n) - $(call cs_symfony,fix --dry-run --diff --ansi --verbose src) - -ifeq ($(RUN_ON),docker) -define sf_console - $(call docker_compose_exec,bin/console $(1)) -endef -else -define sf_console - @bin/console $(1) -endef -endif - -ifeq ($(CS_FIXER_INSTALLED),yes) -define cs_symfony -$(call docker_compose_exec,vendor/bin/php-cs-fixer $(1)) -endef -else -define cs_symfony -$(call warn,PHP CS Fixer is not installed!) -endef -endif From 6c71f85e174bda6c38557c5ff618a7c846f46742 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 14:39:10 +0200 Subject: [PATCH 104/228] Add github reporter to lint check --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28be3c6..5d71681 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: run: npm ci - name: Lint - run: npm run lint + run: npx biome ci src --reporter=github --colors=off - name: Run tests run: npm run test From b76ce7df166f0413ee28ceffa8887fe72895e9a7 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 14:56:31 +0200 Subject: [PATCH 105/228] Simplify testing --- .github/workflows/test.yml | 13 +++++++++++-- compose.yaml | 6 ++++++ openshift/Dockerfile | 3 +++ package.json | 2 +- test/tsconfig.json | 8 -------- tools/make/project/node.mk | 10 +++++----- 6 files changed, 26 insertions(+), 16 deletions(-) delete mode 100644 test/tsconfig.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d71681..4e8e46b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,14 @@ jobs: tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + repository: druidfi/stonehenge + + - name: Install and start Stonehenge + run: make up + + - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -23,7 +30,9 @@ jobs: run: npx biome ci src --reporter=github --colors=off - name: Run tests - run: npm run test + run: | + cp .env.dist .env + make fresh test - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v6 diff --git a/compose.yaml b/compose.yaml index 2d8ad76..a4f8178 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,6 +17,9 @@ services: target: development volumes: - .:/app:delegated + - node_modules:/app/node_modules + - type: tmpfs + target: /app/dist ports: - "3000:3000" depends_on: @@ -36,6 +39,9 @@ services: - "traefik.http.services.hakuvahti-app.loadbalancer.server.port=3000" - "traefik.docker.network=stonehenge-network" +volumes: + node_modules: + networks: hav-internal: internal: true diff --git a/openshift/Dockerfile b/openshift/Dockerfile index e73be5a..3faa842 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -22,6 +22,9 @@ WORKDIR /app RUN mkdir -p "$HOME/node_modules" "$HOME/logs" +COPY --chown=default:0 package*.json ./ +RUN npm install + EXPOSE 3000 CMD ["npm", "run", "dev"] diff --git a/package.json b/package.json index e66fd85..894a423 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && tsc -p test/tsconfig.json && c8 node --test -r ts-node/register test/**/*.ts", + "test": "npm run build:ts && c8 node --test -r ts-node/register test/**/*.ts", "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", diff --git a/test/tsconfig.json b/test/tsconfig.json deleted file mode 100644 index 384d171..0000000 --- a/test/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "baseUrl": ".", - "noEmit": true - }, - "include": ["../src/**/*.ts", "**/*.ts"] -} diff --git a/tools/make/project/node.mk b/tools/make/project/node.mk index bd50e9a..3d2afa2 100644 --- a/tools/make/project/node.mk +++ b/tools/make/project/node.mk @@ -1,5 +1,5 @@ NODE_FRESH_TARGETS := up post-install -NODE_POST_INSTALL_TARGETS := npm-install hav-init-db +NODE_POST_INSTALL_TARGETS := hav-build hav-init-db PHONY += fresh fresh: ## Build fresh development environment and sync @@ -9,10 +9,10 @@ PHONY += post-install post-install: ## Run post-install actions @$(MAKE) $(NODE_POST_INSTALL_TARGETS) -PHONY += npm-install -npm-install: ## Run npm install - $(call step,Run npm install...\n) - $(call npm,install) +PHONY += hav-build +hav-build: ## Compile typescript + $(call step,Run tsc...\n) + $(call npm,run build:ts) PHONY += hav-init-db hav-init-db: ## Run database updates From 27e112606b91d15df2bd3b21920de35982354eb1 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 16:41:31 +0200 Subject: [PATCH 106/228] Simplify docker compose network --- .dockerignore | 2 +- README.md | 4 ++-- compose.yaml | 13 ++++++------- openshift/Dockerfile | 6 ++---- test/requests/addSubscription.rest | 2 +- test/requests/confirmSubscription.rest | 2 +- test/requests/deleteSubscription.rest | 2 +- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.dockerignore b/.dockerignore index 373bfc5..fb438e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,4 @@ node_modules # npm-debug.log # .dockerignore .git -# .gitignore +# .gitignore \ No newline at end of file diff --git a/README.md b/README.md index beb7423..5e7cc07 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Start the local environment with: make fresh ``` -Hakuvahti should be availabe at `http://localhost:3000`. +Hakuvahti should be availabe at `https://hakuvahti.docker.so`. Get a shell inside the container: @@ -176,7 +176,7 @@ The system automatically selects the correct environment configuration based on ### 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) diff --git a/compose.yaml b/compose.yaml index a4f8178..89039d0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,8 +6,7 @@ services: ports: - "27017:27017" networks: - - hav-internal - - helfi-hakuvahti-network + - internal app: user: root @@ -25,8 +24,8 @@ services: 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" @@ -43,7 +42,7 @@ volumes: node_modules: networks: - hav-internal: + internal: internal: true - helfi-hakuvahti-network: - driver: bridge + stonehenge-network: + external: true diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 3faa842..22c05a9 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -1,4 +1,4 @@ -FROM registry.access.redhat.com/ubi9/nodejs-22 as builder +FROM registry.access.redhat.com/ubi9/nodejs-22 AS builder ENV npm_config_cache="/tmp/.npm" @@ -13,15 +13,13 @@ RUN \ # Development image -FROM registry.access.redhat.com/ubi9/nodejs-22 as development +FROM registry.access.redhat.com/ubi9/nodejs-22 AS development ENV npm_config_cache="$HOME/.npm" ENV APP_NAME rekry-hakuvahti WORKDIR /app -RUN mkdir -p "$HOME/node_modules" "$HOME/logs" - COPY --chown=default:0 package*.json ./ RUN npm install diff --git a/test/requests/addSubscription.rest b/test/requests/addSubscription.rest index 21ba96d..e968584 100644 --- a/test/requests/addSubscription.rest +++ b/test/requests/addSubscription.rest @@ -1,5 +1,5 @@ ### Create new subsciption -POST http://127.0.0.1:3000/subscription HTTP/1.1 +POST https://hakuvahti.docker.so/subscription HTTP/1.1 Content-Type: application/json token: test diff --git a/test/requests/confirmSubscription.rest b/test/requests/confirmSubscription.rest index 5b83563..7c2ee18 100644 --- a/test/requests/confirmSubscription.rest +++ b/test/requests/confirmSubscription.rest @@ -1,5 +1,5 @@ ### Create new subsciption -GET http://127.0.0.1:3000/subscription/confirm/65dd80fdbc9129dc77dc4aee/x19vbpajdd HTTP/1.1 +GET https://hakuvahti.docker.so/subscription/confirm/65dd80fdbc9129dc77dc4aee/x19vbpajdd HTTP/1.1 Content-Type: application/json token: test diff --git a/test/requests/deleteSubscription.rest b/test/requests/deleteSubscription.rest index b7b7651..aaa6953 100644 --- a/test/requests/deleteSubscription.rest +++ b/test/requests/deleteSubscription.rest @@ -1,4 +1,4 @@ ### Create new subsciption -DELETE http://127.0.0.1:3000/subscription/delete/65dd85b92261f33ccf3449c8/oyub6hmv79 HTTP/1.1 +DELETE https://hakuvahti.docker.so/subscription/delete/65dd85b92261f33ccf3449c8/oyub6hmv79 HTTP/1.1 Content-Type: application/json token: test From e902f96b6577d29e1074d0cae79e8c7f875ba489 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 16:50:50 +0200 Subject: [PATCH 107/228] Debug tests --- .github/workflows/test.yml | 15 ++++----------- compose.yaml | 1 + openshift/Dockerfile | 2 +- package.json | 2 +- sonar-project.properties | 1 + test/helper.ts | 2 +- tools/make/project/node.mk | 6 +++++- 7 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e8e46b..4d2ace1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,21 +18,14 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js environment - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - - - name: Install dependencies - run: npm ci + - name: Start hakuvahti + run: make fresh - name: Lint - run: npx biome ci src --reporter=github --colors=off + run: docker compose exec app bash -c "npx biome ci src --reporter=github --colors=off" - name: Run tests - run: | - cp .env.dist .env - make fresh test + run: make test - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v6 diff --git a/compose.yaml b/compose.yaml index 89039d0..49e654d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,7 @@ services: context: . dockerfile: openshift/Dockerfile target: development + hostname: hakuvahti volumes: - .:/app:delegated - node_modules:/app/node_modules diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 22c05a9..b100df9 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -21,7 +21,7 @@ ENV APP_NAME rekry-hakuvahti WORKDIR /app COPY --chown=default:0 package*.json ./ -RUN npm install +RUN npm ci EXPOSE 3000 diff --git a/package.json b/package.json index 894a423..94aa693 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && c8 node --test -r ts-node/register test/**/*.ts", + "test": "npm run build:ts && node --test -r ts-node/register test/**/*.ts", "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", diff --git a/sonar-project.properties b/sonar-project.properties index ea0bc49..da78ddc 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,6 @@ sonar.projectKey=City-of-Helsinki_helfi-hakuvahti sonar.organization=city-of-helsinki +sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.inclusions=**/*.ts,Dockerfile,openshift/Dockerfile sonar.exclusions=test/**/* sonar.test.inclusions=test/**/*.ts diff --git a/test/helper.ts b/test/helper.ts index 7045177..493de89 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,5 +1,5 @@ // This file contains code that we reuse between our tests. -const helper = require('fastify-cli/helper.js') +import helper from 'fastify-cli/helper.js'; import * as path from 'path' import * as test from 'node:test' diff --git a/tools/make/project/node.mk b/tools/make/project/node.mk index 3d2afa2..849e0e9 100644 --- a/tools/make/project/node.mk +++ b/tools/make/project/node.mk @@ -1,5 +1,5 @@ NODE_FRESH_TARGETS := up post-install -NODE_POST_INSTALL_TARGETS := hav-build hav-init-db +NODE_POST_INSTALL_TARGETS := dotenv hav-build hav-init-db PHONY += fresh fresh: ## Build fresh development environment and sync @@ -9,6 +9,10 @@ PHONY += post-install post-install: ## Run post-install actions @$(MAKE) $(NODE_POST_INSTALL_TARGETS) +PHONY += dotenv +dotenv: ## Ensure dotenv exists + $(call docker_compose_exec,cp -n .env.dist .env) + PHONY += hav-build hav-build: ## Compile typescript $(call step,Run tsc...\n) From 1b7af8140bba3c0b387637af9e70002af0ac33c6 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 18:36:46 +0200 Subject: [PATCH 108/228] Update c8 --- package-lock.json | 336 +++++++++++++++++++++++++++++++++++++--------- package.json | 4 +- 2 files changed, 276 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index c41ad3a..e821be3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", - "c8": "^9.1.0", + "c8": "^10.1.3", "concurrently": "^8.2.2", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", @@ -54,11 +54,14 @@ } }, "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==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@biomejs/biome": { "version": "2.2.5", @@ -315,6 +318,109 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -366,6 +472,17 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@sentry-internal/tracing": { "version": "7.109.0", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.109.0.tgz", @@ -773,20 +890,20 @@ } }, "node_modules/c8": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", - "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", "dev": true, "license": "ISC", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", + "@bcoe/v8-coverage": "^1.0.1", "@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", + "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -795,7 +912,15 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=14.14.0" + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } } }, "node_modules/c8/node_modules/find-up": { @@ -989,13 +1114,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==", - "dev": true, - "license": "MIT" - }, "node_modules/concurrently": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", @@ -1200,6 +1318,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1994,6 +2119,22 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2212,6 +2353,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mongodb": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.3.0.tgz", @@ -2342,6 +2493,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -2361,16 +2519,6 @@ "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==", - "dev": true, - "license": "MIT", - "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", @@ -2381,6 +2529,30 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2925,6 +3097,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "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, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2937,6 +3125,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2969,64 +3171,55 @@ "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==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^9.0.4" }, "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "node": ">=18" } }, "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==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "license": "ISC", "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" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "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==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/thread-stream": { @@ -3344,6 +3537,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "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, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 94aa693..0aa136c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && node --test -r ts-node/register test/**/*.ts", + "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test -r ts-node/register test/**/*.ts", "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", @@ -53,7 +53,7 @@ "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", "@types/tap": "^15.0.5", - "c8": "^9.1.0", + "c8": "^10.1.3", "concurrently": "^8.2.2", "fastify-tsconfig": "^2.0.0", "ts-node": "^10.4.0", From b70d4a1782fdadc1e419b78836d786ace69fd6a2 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 19:10:19 +0200 Subject: [PATCH 109/228] Try test-force-exit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0aa136c..0e5530a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test -r ts-node/register test/**/*.ts", + "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --test-force-exit -r ts-node/register test/**/*.ts", "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", From 1f39f05dad3d53abff0c5f0d73d9a295ca414b65 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 19:13:06 +0200 Subject: [PATCH 110/228] Add timeout --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e5530a..ef4beae 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --test-force-exit -r ts-node/register test/**/*.ts", + "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --test-force-exit --test-timeout=10000 -r ts-node/register test/**/*.ts", "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", From c7ad331ad106b41e1ff39af1f206ccd333862473 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 19:20:37 +0200 Subject: [PATCH 111/228] Try this --- package.json | 2 +- src/app.ts | 22 ++++++++++------------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index ef4beae..0e5530a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --test-force-exit --test-timeout=10000 -r ts-node/register test/**/*.ts", + "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --test-force-exit -r ts-node/register test/**/*.ts", "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", diff --git a/src/app.ts b/src/app.ts index 25be57d..c94d6c1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -43,18 +43,16 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise 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$/, - }), - ]); + 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; From 46c82e5150f38a1ce232e745c4a6554240e524d8 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 19:51:16 +0200 Subject: [PATCH 112/228] Add --ignore-scripts --- openshift/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openshift/Dockerfile b/openshift/Dockerfile index b100df9..c1c9855 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -21,7 +21,7 @@ ENV APP_NAME rekry-hakuvahti WORKDIR /app COPY --chown=default:0 package*.json ./ -RUN npm ci +RUN npm ci --ignore-scripts EXPOSE 3000 From f023f55d9a5ea65510cf205c344a963bdc20f5ad Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 19:54:43 +0200 Subject: [PATCH 113/228] Cleanup --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index da78ddc..dd38586 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ sonar.projectKey=City-of-Helsinki_helfi-hakuvahti sonar.organization=city-of-helsinki sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.inclusions=**/*.ts,Dockerfile,openshift/Dockerfile +sonar.inclusions=**/*.ts,openshift/Dockerfile sonar.exclusions=test/**/* sonar.test.inclusions=test/**/*.ts From f07fab9368fddfa6197b4ad23d639eaf629f8a50 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Fri, 14 Nov 2025 20:00:03 +0200 Subject: [PATCH 114/228] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5e7cc07..e436e68 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Hakuvahti should be availabe at `https://hakuvahti.docker.so`. Get a shell inside the container: ```bash -make down +make shell ``` The local environment does not run cron scripts automatically. Run scripts manually when testing, see [`package.json`](./package.json) for available commands. From a75b16e9616b5fe13609373bb31a5f2c3638329d Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 17 Nov 2025 15:26:42 +0200 Subject: [PATCH 115/228] Resolve sonarcloud issues --- src/bin/hav-migrate-site-id.ts | 2 +- src/bin/hav-send-emails-in-queue.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin/hav-migrate-site-id.ts b/src/bin/hav-migrate-site-id.ts index 3e28af6..4843b7c 100644 --- a/src/bin/hav-migrate-site-id.ts +++ b/src/bin/hav-migrate-site-id.ts @@ -88,7 +88,7 @@ const migrateSiteId = async ( // CLI argument parsing const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); -const batchSize = parseInt(args.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1] || '100', 10); +const batchSize = Number.parseInt(args.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1] || '100', 10); // Get site_id from first argument (required) const siteId = args.find((arg) => !arg.startsWith('--')); diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index 0f9f715..1b09df4 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -121,7 +121,7 @@ const app = async (): Promise => { if (deleteResult.deletedCount === 0) { console.error(`Could not delete email document with id ${email._id} from queue`); - throw Error('Deleting email from queue failed.'); + throw new Error('Deleting email from queue failed.'); } return Promise.resolve(); From e3e5ce1d30d12c5f28b0a23a381af85e63607847 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 17 Nov 2025 15:33:35 +0200 Subject: [PATCH 116/228] Add editorconfig --- .editorconfig | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .editorconfig 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 From 32c3f734e21ef37b9b1cea71cf8735c714e44c81 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 17 Nov 2025 18:34:55 +0200 Subject: [PATCH 117/228] Remove legacy pipeline files --- pipelines/hakuvahti-production.yml | 37 ----------------------- pipelines/hakuvahti-staging.yml | 47 ------------------------------ pipelines/hakuvahti-test.yml | 47 ------------------------------ 3 files changed, 131 deletions(-) delete mode 100644 pipelines/hakuvahti-production.yml delete mode 100644 pipelines/hakuvahti-staging.yml delete mode 100644 pipelines/hakuvahti-test.yml diff --git a/pipelines/hakuvahti-production.yml b/pipelines/hakuvahti-production.yml deleted file mode 100644 index da67476..0000000 --- a/pipelines/hakuvahti-production.yml +++ /dev/null @@ -1,37 +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: none - -# 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-rekry-pipelines - type: git - # Azure DevOps project/repository - name: helfi-rekry/helfi-rekry-pipelines - -extends: - # Filename in Azure DevOps Repository - template: components/hakuvahti/pipelines/hakuvahti-production.yml@helfi-rekry-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 - # buildArgs: - # DEBUG: 1 - # configMap: # pod environment variables - # DEBUG: 1 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 }} From 05e3921410e8f5ba53a0527ba9fe4e89c7c84dc7 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 17 Nov 2025 18:51:59 +0200 Subject: [PATCH 118/228] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e436e68..bd2cf2e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Pre-requisities to use Hakuvahti are: - Copy `.env.dist` as `.env` and configure: - ElasticProxy (default to local rekry elasticsearch), - - `ATC_API_KEY` (Hakuvahti will trigger an error if ATV cannot be reached) + - `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: From 4f68de22a1991daccc57f9396cb8fd53ebab9f4d Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 17 Nov 2025 18:32:52 +0200 Subject: [PATCH 119/228] Remove console script boilerplate code --- src/bin/hav-fix-employment-arrays.ts | 161 ++++++++----------- src/bin/hav-init-mongodb.ts | 44 +----- src/bin/hav-migrate-site-id.ts | 91 +++++------ src/bin/hav-populate-queue.ts | 131 +++++++--------- src/bin/hav-send-emails-in-queue.ts | 227 +++++++++++---------------- src/bin/hav-send-sms-in-queue.ts | 208 ++++++++++-------------- src/bin/hav-test-sms-sending.ts | 176 +++++++++------------ src/bin/hav-update-schema.ts | 40 +---- src/lib/command.ts | 57 +++++++ 9 files changed, 484 insertions(+), 651 deletions(-) create mode 100644 src/lib/command.ts diff --git a/src/bin/hav-fix-employment-arrays.ts b/src/bin/hav-fix-employment-arrays.ts index 3cde10c..abf5df4 100644 --- a/src/bin/hav-fix-employment-arrays.ts +++ b/src/bin/hav-fix-employment-arrays.ts @@ -1,12 +1,6 @@ -import dotenv from 'dotenv'; -import fastify from 'fastify'; +import command from '../lib/command'; import mongodb from '../plugins/mongodb'; -dotenv.config(); - -const server = fastify({}); -void server.register(mongodb); - const DRY_RUN = process.argv.includes('--dry-run'); const FIELDS_TO_FIX = ['employment_id', 'employment_type_id', 'task_area_external_id']; @@ -33,105 +27,86 @@ const fixCommaSeparatedArrays = (obj: any, path = ''): { fixed: any; modified: b return { fixed, modified, changedFields }; }; -const app = async (): Promise => { - const db = server.mongo?.db; - if (!db) { - throw new Error('MongoDB connection not available'); - } +command( + async (server) => { + const db = server.mongo?.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } - const subscriptionsCollection = db.collection('subscriptions'); + const subscriptionsCollection = db.collection('subscriptions'); - console.log('Fixing comma-separated arrays in elastic queries'); - console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`); - console.log(`Fields: ${FIELDS_TO_FIX.join(', ')}\n`); + console.log('Fixing comma-separated arrays in elastic queries'); + console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`); + console.log(`Fields: ${FIELDS_TO_FIX.join(', ')}\n`); - const subscriptions = await subscriptionsCollection.find({}).toArray(); - console.log(`Found ${subscriptions.length} subscriptions\n`); + const subscriptions = await subscriptionsCollection.find({}).toArray(); + console.log(`Found ${subscriptions.length} subscriptions\n`); - let fixedCount = 0; - let skippedCount = 0; - let errorCount = 0; - const fixes: Array<{ id: string; fields: string[] }> = []; + let fixedCount = 0; + let skippedCount = 0; + let errorCount = 0; + const fixes: Array<{ id: string; fields: string[] }> = []; - for (const subscription of subscriptions) { - try { - const originalQuery = subscription.elastic_query; - const decoded = Buffer.from(originalQuery, 'base64').toString('utf-8'); - const queryObj = JSON.parse(decoded); + for (const subscription of subscriptions) { + try { + const originalQuery = subscription.elastic_query; + const decoded = Buffer.from(originalQuery, 'base64').toString('utf-8'); + const queryObj = JSON.parse(decoded); - const { fixed: fixedQuery, modified, changedFields } = fixCommaSeparatedArrays(queryObj); + const { fixed: fixedQuery, modified, changedFields } = fixCommaSeparatedArrays(queryObj); - if (modified) { - const fixedStr = JSON.stringify(fixedQuery); - const newEncoded = Buffer.from(fixedStr).toString('base64'); + if (modified) { + const fixedStr = JSON.stringify(fixedQuery); + const newEncoded = Buffer.from(fixedStr).toString('base64'); - console.log(`${DRY_RUN ? '[DRY] ' : ''}Fixed ${subscription._id} (${subscription.email || 'N/A'})`); - console.log(` Fields: ${changedFields.join(', ')}`); - console.log(` Before: ${decoded.substring(0, 120)}...`); - console.log(` After: ${fixedStr.substring(0, 120)}...\n`); + console.log(`${DRY_RUN ? '[DRY] ' : ''}Fixed ${subscription._id} (${subscription.email || 'N/A'})`); + console.log(` Fields: ${changedFields.join(', ')}`); + console.log(` Before: ${decoded.substring(0, 120)}...`); + console.log(` After: ${fixedStr.substring(0, 120)}...\n`); - fixes.push({ - id: subscription._id.toString(), - fields: changedFields, - }); + fixes.push({ + id: subscription._id.toString(), + fields: changedFields, + }); - if (!DRY_RUN) { - await subscriptionsCollection.updateOne({ _id: subscription._id }, { $set: { elastic_query: newEncoded } }); - } + if (!DRY_RUN) { + await subscriptionsCollection.updateOne({ _id: subscription._id }, { $set: { elastic_query: newEncoded } }); + } - fixedCount++; - } else { - skippedCount++; + fixedCount++; + } else { + skippedCount++; + } + } catch (error) { + console.error(`Error processing ${subscription._id}:`, error); + errorCount++; } - } catch (error) { - console.error(`Error processing ${subscription._id}:`, error); - errorCount++; } - } - - console.log('\nSummary:'); - console.log(` ${DRY_RUN ? 'Would fix' : 'Fixed'}: ${fixedCount}`); - console.log(` Skipped: ${skippedCount}`); - console.log(` Errors: ${errorCount}`); - console.log(` Total: ${subscriptions.length}`); - - if (fixes.length > 0) { - console.log('\nFields fixed:'); - const fieldCounts = new Map(); - fixes.forEach(({ fields }) => { - fields.forEach((field) => { - fieldCounts.set(field, (fieldCounts.get(field) || 0) + 1); - }); - }); - - fieldCounts.forEach((count, field) => { - console.log(` ${field}: ${count}`); - }); - } - if (DRY_RUN && fixedCount > 0) { - console.log('\nRun without --dry-run to apply changes'); - } -}; + console.log('\nSummary:'); + console.log(` ${DRY_RUN ? 'Would fix' : 'Fixed'}: ${fixedCount}`); + console.log(` Skipped: ${skippedCount}`); + console.log(` Errors: ${errorCount}`); + console.log(` Total: ${subscriptions.length}`); + + if (fixes.length > 0) { + console.log('\nFields fixed:'); + const fieldCounts = new Map(); + fixes.forEach(({ fields }) => { + fields.forEach((field) => { + fieldCounts.set(field, (fieldCounts.get(field) || 0) + 1); + }); + }); -server.get('/', async function handleRootRequest(_request, _reply) { - await app(); - return { success: true }; -}); - -server.ready((_err) => { - console.log('fastify server ready'); - server.inject( - { - method: 'GET', - url: '/', - }, - function handleInjectResponse(_injectErr, response) { - if (response) { - console.log(JSON.parse(response.payload)); - } + fieldCounts.forEach((count, field) => { + console.log(` ${field}: ${count}`); + }); + } - server.close(); - }, - ); -}); + if (DRY_RUN && fixedCount > 0) { + console.log('\nRun without --dry-run to apply changes'); + } + }, + [mongodb], +); diff --git a/src/bin/hav-init-mongodb.ts b/src/bin/hav-init-mongodb.ts index 1da72bd..2894948 100644 --- a/src/bin/hav-init-mongodb.ts +++ b/src/bin/hav-init-mongodb.ts @@ -8,19 +8,11 @@ * Must be run before starting the application to ensure proper database structure. */ -import dotenv from 'dotenv'; -import fastify from 'fastify'; +import command from '../lib/command'; import mongodb from '../plugins/mongodb'; -dotenv.config(); - -const server = fastify({}); - -// eslint-disable-next-line no-void -void server.register(mongodb); - -const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => { - try { +command( + async (server) => { const db = server.mongo.db; if (!db) { throw new Error('MongoDB connection not available'); @@ -85,6 +77,7 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => }, }, }); + // eslint-disable-next-line no-console console.log('SMS queue collection created:', smsQueueResult?.collectionName); } else { @@ -142,31 +135,10 @@ const initMongoDB = async (): Promise<{ success: boolean; error?: unknown }> => }, }, }); + // eslint-disable-next-line no-console console.log('Subscription collection created:', subscriptionResult?.collectionName); } - - return { success: true }; - } catch (error) { - console.error('Error initializing MongoDB:', error); - return { success: false, error }; - } -}; - -// Wait for Fastify and MongoDB plugin to be fully initialized before creating collections -server.ready(async (err) => { - if (err) { - console.error('Server failed to start:', err); - process.exit(1); - } - - // eslint-disable-next-line no-console - console.log('Fastify server ready'); - - const result = await initMongoDB(); - // eslint-disable-next-line no-console - console.log('MongoDB initialization result:', result); - - await server.close(); - process.exit(result.success ? 0 : 1); // Exit with error code if initialization failed -}); + }, + [mongodb], +); diff --git a/src/bin/hav-migrate-site-id.ts b/src/bin/hav-migrate-site-id.ts index 4843b7c..4333df7 100644 --- a/src/bin/hav-migrate-site-id.ts +++ b/src/bin/hav-migrate-site-id.ts @@ -2,16 +2,9 @@ * Migration Script: Add site_id to existing subscription documents */ -import dotenv from 'dotenv'; -import fastify from 'fastify'; +import command, { type Server } from '../lib/command'; import mongodb from '../plugins/mongodb'; -dotenv.config(); - -const server = fastify({}); -// eslint-disable-next-line no-void -void server.register(mongodb); - interface MigrationOptions { defaultSiteId: string; dryRun: boolean; @@ -19,13 +12,15 @@ interface MigrationOptions { } const migrateSiteId = async ( + server: Server, options: MigrationOptions, ): Promise<{ success: boolean; updated: number; error?: unknown }> => { + const db = server.mongo.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } + try { - const db = server.mongo.db; - if (!db) { - throw new Error('MongoDB connection not available'); - } const collection = db.collection('subscription'); // Find documents without site_id @@ -85,44 +80,38 @@ const migrateSiteId = async ( } }; -// CLI argument parsing -const args = process.argv.slice(2); -const dryRun = args.includes('--dry-run'); -const batchSize = Number.parseInt(args.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1] || '100', 10); - -// Get site_id from first argument (required) -const siteId = args.find((arg) => !arg.startsWith('--')); -if (!siteId) { - console.error('Error: site_id is required'); - console.error('Usage: npm run hav:migrate-site-id [--dry-run] [--batch-size=100]'); - console.error('Example: npm run hav:migrate-site-id rekry'); - process.exit(1); -} +command( + async (server) => { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + const batchSize = Number.parseInt(args.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1] || '100', 10); + + // Get site_id from first argument (required) + const siteId = args.find((arg) => !arg.startsWith('--')); + if (!siteId) { + console.error('Error: site_id is required'); + console.error('Usage: npm run hav:migrate-site-id [--dry-run] [--batch-size=100]'); + console.error('Example: npm run hav:migrate-site-id rekry'); + process.exit(1); + } -server.ready(async (err) => { - if (err) { - console.error('Server failed to start:', err); - process.exit(1); - } + // eslint-disable-next-line no-console + console.log('Starting site_id migration...'); + // eslint-disable-next-line no-console + console.log(`Target site_id: ${siteId}`); + // eslint-disable-next-line no-console + console.log(`Dry run: ${dryRun}`); + // eslint-disable-next-line no-console + console.log(`Batch size: ${batchSize}`); + + const result = await migrateSiteId(server, { + defaultSiteId: siteId, + dryRun, + batchSize, + }); - // eslint-disable-next-line no-console - console.log('Starting site_id migration...'); - // eslint-disable-next-line no-console - console.log(`Target site_id: ${siteId}`); - // eslint-disable-next-line no-console - console.log(`Dry run: ${dryRun}`); - // eslint-disable-next-line no-console - console.log(`Batch size: ${batchSize}`); - - const result = await migrateSiteId({ - defaultSiteId: siteId, - dryRun, - batchSize, - }); - - // eslint-disable-next-line no-console - console.log('Migration result:', result); - - await server.close(); - process.exit(result.success ? 0 : 1); -}); + // eslint-disable-next-line no-console + console.log('Migration result:', result); + }, + [mongodb], +); diff --git a/src/bin/hav-populate-queue.ts b/src/bin/hav-populate-queue.ts index 7ce19ba..1adb3d3 100644 --- a/src/bin/hav-populate-queue.ts +++ b/src/bin/hav-populate-queue.ts @@ -1,9 +1,5 @@ import type { ObjectId } from '@fastify/mongodb'; -import fastifySentry from '@immobiliarelabs/fastify-sentry'; -import dotenv from 'dotenv'; -import fastify from 'fastify'; -import minimist from 'minimist'; - +import command, { type Server } from '../lib/command'; import { expiryEmail, newHitsEmail, newHitsSms } from '../lib/email'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; import atv from '../plugins/atv'; @@ -21,13 +17,6 @@ import { SubscriptionStatus, } from '../types/subscription'; -dotenv.config(); - -// Parse CLI arguments -const argv = minimist(process.argv.slice(2)); -const targetSite: string | undefined = argv.site; -const isDryRun: boolean = argv['dry-run'] === true; - // Statistics tracking interface ProcessingStats { sitesProcessed: number; @@ -37,26 +26,6 @@ interface ProcessingStats { smsQueued: number; } -const server = fastify({}); -const release = process.env.SENTRY_RELEASE ?? ''; - -server.register(fastifySentry, { - dsn: process.env.SENTRY_DSN, - environment: process.env.ENVIRONMENT, - release, - setErrorHandler: true, -}); - -// Register only needed plugins -// eslint-disable-next-line no-void -void server.register(mongodb); -// eslint-disable-next-line no-void -void server.register(elasticproxy); -// eslint-disable-next-line no-void -void server.register(base64Plugin); -// eslint-disable-next-line no-void -void server.register(atv); - export const getLocalizedUrl = ( siteConfig: SiteConfigurationType, langCode: SubscriptionCollectionLanguageType, @@ -75,12 +44,14 @@ export const getLocalizedUrl = ( /** * Deletes subscriptions older than a specified number of days with a certain status for a specific site. * - * @param {SubscriptionStatus} modifyStatus - the status to modify subscriptions - * @param {number} olderThanDays - the number of days to consider for deletion - * @param {string} siteId - the site ID to filter subscriptions + * @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, @@ -135,6 +106,7 @@ const checkShouldSendExpiryNotification = ( const getNewHitsFromElasticsearch = async ( subscription: SubscriptionCollectionType & { _id: ObjectId }, siteConfig: SiteConfigurationType, + server: Server, ): Promise => { const elasticQuery: string = server.b64decode(subscription.elastic_query); const lastChecked: number = subscription.last_checked ? subscription.last_checked : Math.floor(Date.now() / 1000); @@ -167,11 +139,18 @@ const getNewHitsFromElasticsearch = async ( /** * Processes subscriptions for a specific site configuration. * - * @param {SiteConfiguration} siteConfig - The site configuration to process - * @param {ProcessingStats} stats - Statistics object to track processing + * @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 (siteConfig: SiteConfigurationType, stats: ProcessingStats): Promise => { +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'); const smsQueueCollection = server.mongo.db?.collection('smsqueue'); @@ -240,6 +219,7 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats const newHits = await getNewHitsFromElasticsearch( subscription as SubscriptionCollectionType & { _id: ObjectId }, siteConfig, + server, ); // No new hits @@ -326,9 +306,9 @@ const processSiteSubscriptions = async (siteConfig: SiteConfigurationType, stats /** * Main application function that processes all site configurations. * - * @return {Promise} A Promise that resolves when complete. + * @return A Promise that resolves when complete. */ -const app = async (): Promise => { +const app = async (targetSite: string | undefined, isDryRun: boolean, server: Server): Promise => { const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-populate-queue', status: 'in_progress', @@ -379,7 +359,7 @@ const app = async (): Promise => { await previousPromise; // eslint-disable-next-line no-console console.log(`Processing subscriptions for site: ${siteId}`); - await processSiteSubscriptions(siteConfig, stats); + await processSiteSubscriptions(server, siteConfig, stats, isDryRun); stats.sitesProcessed++; return Promise.resolve(); }, Promise.resolve()); @@ -415,45 +395,42 @@ const app = async (): Promise => { } }; -server.get('/', async function handleRootRequest(_request, _reply) { - // Load site configurations - const configLoader = SiteConfigurationLoader.getInstance(); - await configLoader.loadConfigurations(); - const siteConfigs = configLoader.getConfigurations(); +command( + async function handle(server, argv) { + const targetSite: string | undefined = argv.site; + const isDryRun: boolean = argv['dry-run'] === true; - // Clean up expired subscriptions for each site - await Object.entries(siteConfigs).reduce(async (previousPromise, [siteId, siteConfig]) => { - await previousPromise; + // Load site configurations + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); + const siteConfigs = configLoader.getConfigurations(); - // Remove expired subscriptions that haven't been confirmed - await massDeleteSubscriptions(SubscriptionStatus.INACTIVE, siteConfig.subscription.unconfirmedMaxAge, siteId); + // Clean up expired subscriptions for each site + await Object.entries(siteConfigs).reduce(async (previousPromise, [siteId, siteConfig]) => { + await previousPromise; - // Remove expired subscriptions - await massDeleteSubscriptions(SubscriptionStatus.ACTIVE, siteConfig.subscription.maxAge, siteId); + // Remove expired subscriptions that haven't been confirmed + await massDeleteSubscriptions( + server, + SubscriptionStatus.INACTIVE, + siteConfig.subscription.unconfirmedMaxAge, + siteId, + ); - return Promise.resolve(); - }, Promise.resolve()); + // Remove expired subscriptions + await massDeleteSubscriptions(server, SubscriptionStatus.ACTIVE, siteConfig.subscription.maxAge, siteId); - // Loop through subscriptions and add new results to email queue - await app(); - return { success: true }; -}); - -server.ready((_err) => { - // eslint-disable-next-line no-console - console.log('fastify server ready'); - server.inject( - { - method: 'GET', - url: '/', - }, - function handleInjectResponse(_injectErr, response) { - if (response) { - // eslint-disable-next-line no-console - console.log(JSON.parse(response.payload)); - } + return Promise.resolve(); + }, Promise.resolve()); - server.close(); - }, - ); -}); + // 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 index 1b09df4..e1e543f 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -1,158 +1,119 @@ import { ObjectId } from '@fastify/mongodb'; -import fastifySentry from '@immobiliarelabs/fastify-sentry'; -import dotenv from 'dotenv'; -import fastify from 'fastify'; import { JSDOM } from 'jsdom'; +import command from '../lib/command'; import atv from '../plugins/atv'; import mailer from '../plugins/mailer'; import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; import type { AtvDocumentType } from '../types/atv'; -dotenv.config(); - -const server = fastify({}); -const release = process.env.SENTRY_RELEASE ?? ''; - -server.register(fastifySentry, { - dsn: process.env.SENTRY_DSN, - environment: process.env.ENVIRONMENT, - release, - setErrorHandler: true, -}); - -// Register only needed plugins -// eslint-disable-next-line no-void -void server.register(mailer); -// eslint-disable-next-line no-void -void server.register(mongodb); -// eslint-disable-next-line no-void -void server.register(atv); - // Command line/cron application to send all emails from queue collection const BATCH_SIZE = 100; -const app = async (): Promise => { - const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress' }); - - if (typeof server.mongo?.db === 'undefined') { - console.error('MongoDB connection not working'); - throw new Error('MongoDB connection not working'); - } +command( + async (server) => { + const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress' }); - // Email queue - const queueCollection = server.mongo.db?.collection('queue'); - - let hasMoreResults = true; - - while (hasMoreResults) { - // eslint-disable-next-line no-await-in-loop - const result = await queueCollection.find({}).limit(BATCH_SIZE).toArray(); + if (typeof server.mongo?.db === 'undefined') { + console.error('MongoDB connection not working'); + throw new Error('MongoDB connection not working'); + } - if (result.length === 0) { - hasMoreResults = false; - } else { - // Collect email ids as map - const emailIdsMap = new Map(); + // Email queue + const queueCollection = server.mongo.db?.collection('queue'); - result.forEach((email) => { - emailIdsMap.set(email.email, null); - }); + let hasMoreResults = true; - // Get batch of email documents from ATV - const emailIds = [...emailIdsMap.keys()]; + while (hasMoreResults) { // eslint-disable-next-line no-await-in-loop - const emailDocuments: Partial = await server.atvGetDocumentBatch(emailIds); + const result = await queueCollection.find({}).limit(BATCH_SIZE).toArray(); - // Update the email map with unencrypted email list - if (emailDocuments.length > 0) { - emailDocuments.forEach((emailDocument) => { - if (emailDocument?.id) { - emailIdsMap.set(emailDocument.id, emailDocument.content.email); - } + if (result.length === 0) { + hasMoreResults = false; + } else { + // Collect email ids as map + const emailIdsMap = new Map(); + + result.forEach((email) => { + emailIdsMap.set(email.email, null); }); - } - // Send emails sequentially to avoid overwhelming the system - // eslint-disable-next-line no-await-in-loop - await result.reduce(async (previousPromise, email) => { - await previousPromise; - - 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); - }, - ); - }); - } catch (error) { - // Continue even if sending email failed. - server.Sentry?.captureException(error); - - console.error(error); - } + // Get batch of email documents from ATV + const emailIds = [...emailIdsMap.keys()]; + // eslint-disable-next-line no-await-in-loop + const emailDocuments: Partial = await server.atvGetDocumentBatch(emailIds); + + // Update the email map with unencrypted email list + if (emailDocuments.length > 0) { + emailDocuments.forEach((emailDocument) => { + if (emailDocument?.id) { + emailIdsMap.set(emailDocument.id, emailDocument.content.email); + } + }); } - // 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`); + // Send emails sequentially to avoid overwhelming the system + // eslint-disable-next-line no-await-in-loop + await result.reduce(async (previousPromise, email) => { + await previousPromise; + + 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); + }, + ); + }); + } catch (error) { + // Continue even if sending email failed. + server.Sentry?.captureException(error); + + console.error(error); + } + } - throw new Error('Deleting email from queue failed.'); - } + // 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`); - return Promise.resolve(); - }, Promise.resolve()); - } - } - - server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok' }); -}; - -server.get('/', async function handleRootRequest(_request, _reply) { - // Send all emails from queue - await app(); - return { success: true }; -}); - -server.ready((_err) => { - // eslint-disable-next-line no-console - console.log('fastify server ready'); - server.inject( - { - method: 'GET', - url: '/', - }, - function handleInjectResponse(_injectErr, response) { - if (response) { - // eslint-disable-next-line no-console - console.log(JSON.parse(response.payload)); + throw new Error('Deleting email from queue failed.'); + } + + return Promise.resolve(); + }, Promise.resolve()); } + } - server.close(); - }, - ); -}); + server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok' }); + }, + [ + // Register only needed plugins + mailer, + mongodb, + atv, + ], +); diff --git a/src/bin/hav-send-sms-in-queue.ts b/src/bin/hav-send-sms-in-queue.ts index 9cf2361..ace3e34 100644 --- a/src/bin/hav-send-sms-in-queue.ts +++ b/src/bin/hav-send-sms-in-queue.ts @@ -1,146 +1,108 @@ import { ObjectId } from '@fastify/mongodb'; -import fastifySentry from '@immobiliarelabs/fastify-sentry'; -import dotenv from 'dotenv'; -import fastify from 'fastify'; +import command from '../lib/command'; import atv from '../plugins/atv'; import dialogi from '../plugins/dialogi'; import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; import type { AtvDocumentType } from '../types/atv'; -dotenv.config(); - -const server = fastify({}); -const release = process.env.SENTRY_RELEASE ?? ''; - -server.register(fastifySentry, { - dsn: process.env.SENTRY_DSN, - environment: process.env.ENVIRONMENT, - release, - setErrorHandler: true, -}); - -// Register only needed plugins -// eslint-disable-next-line no-void -void server.register(mongodb); -// eslint-disable-next-line no-void -void server.register(atv); -// eslint-disable-next-line no-void -void server.register(dialogi); - // Command line/cron application to send all SMS from queue collection const BATCH_SIZE = 100; -const app = async (): Promise => { - const checkInId = server.Sentry?.captureCheckIn({ - monitorSlug: 'hav-send-sms-in-queue', - status: 'in_progress', - }); +command( + async (server) => { + const checkInId = server.Sentry?.captureCheckIn({ + monitorSlug: 'hav-send-sms-in-queue', + status: 'in_progress', + }); - const db = server.mongo?.db; - if (!db) { - throw new Error('MongoDB connection not available'); - } + const db = server.mongo?.db; + if (!db) { + throw new Error('MongoDB connection not available'); + } - const smsQueueCollection = db.collection('smsqueue'); - let hasMoreResults = true; + const smsQueueCollection = db.collection('smsqueue'); + let hasMoreResults = true; - while (hasMoreResults) { - // eslint-disable-next-line no-await-in-loop - const batch = await smsQueueCollection.find({}).limit(BATCH_SIZE).toArray(); + while (hasMoreResults) { + // eslint-disable-next-line no-await-in-loop + const batch = await smsQueueCollection.find({}).limit(BATCH_SIZE).toArray(); - if (batch.length === 0) { - hasMoreResults = false; - break; - } + if (batch.length === 0) { + hasMoreResults = false; + break; + } - // Collect unique ATV document IDs - const atvIds = [...new Set(batch.map((item) => item.sms))]; - - // Get SMS phone numbers from ATV in batch - // eslint-disable-next-line no-await-in-loop - const atvDocuments: Partial = await server.atvGetDocumentBatch(atvIds); - - // Create map of ATV ID -> phone number - const phoneNumberMap = new Map(); - atvDocuments.forEach((doc) => { - if (doc?.id && doc?.content) { - try { - const content = JSON.parse(doc.content); - if (content.sms) { - phoneNumberMap.set(doc.id, content.sms); + // Collect unique ATV document IDs + const atvIds = [...new Set(batch.map((item) => item.sms))]; + + // Get SMS phone numbers from ATV in batch + // eslint-disable-next-line no-await-in-loop + const atvDocuments: Partial = await server.atvGetDocumentBatch(atvIds); + + // Create map of ATV ID -> phone number + const phoneNumberMap = new Map(); + atvDocuments.forEach((doc) => { + if (doc?.id && doc?.content) { + try { + const content = JSON.parse(doc.content); + if (content.sms) { + phoneNumberMap.set(doc.id, content.sms); + } + } catch (error) { + console.error(`Failed to parse ATV document ${doc.id}:`, error); } - } catch (error) { - console.error(`Failed to parse ATV document ${doc.id}:`, error); } - } - }); + }); - // Process SMS messages sequentially - // eslint-disable-next-line no-await-in-loop - await batch.reduce(async (previousPromise, smsItem) => { - await previousPromise; - - const atvId = smsItem.sms; - const phoneNumber = phoneNumberMap.get(atvId); - const messageContent = smsItem.content; - - console.info('Processing SMS for ATV ID:', atvId); - - if (phoneNumber) { - try { - // Send SMS using Dialogi plugin - await server.dialogi.sendSms(phoneNumber, messageContent); - console.log(`SMS sent successfully for ATV ID: ${atvId}`); - } catch (error) { - // Log error but continue processing queue - server.Sentry?.captureException(error); - console.error(`Failed to send SMS for ATV ID ${atvId}:`, error); + // Process SMS messages sequentially + // eslint-disable-next-line no-await-in-loop + await batch.reduce(async (previousPromise, smsItem) => { + await previousPromise; + + const atvId = smsItem.sms; + const phoneNumber = phoneNumberMap.get(atvId); + const messageContent = smsItem.content; + + console.info('Processing SMS for ATV ID:', atvId); + + if (phoneNumber) { + try { + // Send SMS using Dialogi plugin + await server.dialogi.sendSms(phoneNumber, messageContent); + console.log(`SMS sent successfully for ATV ID: ${atvId}`); + } catch (error) { + // Log error but continue processing queue + server.Sentry?.captureException(error); + console.error(`Failed to send SMS for ATV ID ${atvId}:`, error); + } + } else { + console.warn(`Phone number not found for ATV ID ${atvId}`); } - } else { - console.warn(`Phone number not found for ATV ID ${atvId}`); - } - // Remove from queue regardless of send status - const deleteResult = await smsQueueCollection.deleteOne({ - _id: new ObjectId(smsItem._id), - }); + // Remove from queue regardless of send status + const deleteResult = await smsQueueCollection.deleteOne({ + _id: new ObjectId(smsItem._id), + }); - if (deleteResult.deletedCount === 0) { - console.error(`Failed to delete SMS queue item ${smsItem._id}`); - } + if (deleteResult.deletedCount === 0) { + console.error(`Failed to delete SMS queue item ${smsItem._id}`); + } - return Promise.resolve(); - }, Promise.resolve()); - } - - server.Sentry?.captureCheckIn({ - checkInId, - monitorSlug: 'hav-send-sms-in-queue', - status: 'ok', - }); -}; - -server.get('/', async function handleRootRequest(_request, _reply) { - await app(); - return { success: true }; -}); - -server.ready((_err) => { - // eslint-disable-next-line no-console - console.log('fastify server ready'); - server.inject( - { - method: 'GET', - url: '/', - }, - function handleInjectResponse(_injectErr, response) { - if (response) { - // eslint-disable-next-line no-console - console.log(JSON.parse(response.payload)); - } + return Promise.resolve(); + }, Promise.resolve()); + } - server.close(); - }, - ); -}); + server.Sentry?.captureCheckIn({ + checkInId, + monitorSlug: 'hav-send-sms-in-queue', + status: 'ok', + }); + }, + [ + // Register only needed plugins + mongodb, + atv, + dialogi, + ], +); diff --git a/src/bin/hav-test-sms-sending.ts b/src/bin/hav-test-sms-sending.ts index d02cfa0..872dff1 100644 --- a/src/bin/hav-test-sms-sending.ts +++ b/src/bin/hav-test-sms-sending.ts @@ -1,121 +1,85 @@ -import fastifySentry from '@immobiliarelabs/fastify-sentry'; -import dotenv from 'dotenv'; -import fastify from 'fastify'; +import command from '../lib/command'; import { newHitsSms } from '../lib/email'; import { SiteConfigurationLoader } from '../lib/siteConfigurationLoader'; import dialogi from '../plugins/dialogi'; import '../plugins/sentry'; -dotenv.config(); - -const server = fastify({}); -const release = process.env.SENTRY_RELEASE ?? ''; - -server.register(fastifySentry, { - dsn: process.env.SENTRY_DSN, - environment: process.env.ENVIRONMENT, - release, - setErrorHandler: true, -}); - -// Register only needed plugins -// eslint-disable-next-line no-void -void server.register(dialogi); - // Test script to verify SMS sending via Elisa Dialogi API -const app = async (): Promise => { - 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.'); +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(`Using site configuration: ${siteId}\n`); - - // Test with each language - const languages = ['fi', 'sv', 'en'] as const; + console.log('=== SMS Sending Test ==='); + console.log(`Target number: ${testPhoneNumber}`); + console.log(`Environment: ${process.env.ENVIRONMENT || 'dev'}\n`); - for (const lang of languages) { - console.log(`Testing ${lang.toUpperCase()} SMS...`); + try { + // Load site configurations + const configLoader = SiteConfigurationLoader.getInstance(); + await configLoader.loadConfigurations(); - // Generate SMS content with dummy data - const smsContent = await newHitsSms( - lang, - { - search_description: 'Test search: Open positions in Helsinki', - search_link: '/fi/avoimet-tyopaikat?search=test', - }, - siteConfig, - ); + // Use first available site configuration for testing (default to 'rekry') + const siteConfigs = configLoader.getConfigurations(); + const siteId = Object.keys(siteConfigs)[0]; + const siteConfig = siteConfigs[siteId]; - 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; + if (!siteConfig) { + throw new Error('No site configuration found. Please configure at least one site in conf/ directory.'); } - 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); - } -}; - -server.get('/', async function handleRootRequest(_request, _reply) { - await app(); - return { success: true }; -}); - -server.ready((_err) => { - // eslint-disable-next-line no-console - console.log('fastify server ready'); - server.inject( - { - method: 'GET', - url: '/', - }, - function handleInjectResponse(_injectErr, response) { - if (response) { - // eslint-disable-next-line no-console - console.log(JSON.parse(response.payload)); + 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', + search_link: '/fi/avoimet-tyopaikat?search=test', + }, + 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(''); } - server.close(); - }, - ); -}); + 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-schema.ts b/src/bin/hav-update-schema.ts index 0937d4c..a861d2c 100644 --- a/src/bin/hav-update-schema.ts +++ b/src/bin/hav-update-schema.ts @@ -5,18 +5,14 @@ * Run this AFTER migrating existing documents to have site_id. */ -import dotenv from 'dotenv'; -import fastify from 'fastify'; +import command from '../lib/command'; import mongodb from '../plugins/mongodb'; -dotenv.config(); - -const server = fastify({}); -// eslint-disable-next-line no-void -void server.register(mongodb); +command( + async (server) => { + // eslint-disable-next-line no-console + console.log('Updating subscription collection schema to require site_id...'); -const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => { - try { const db = server.mongo.db; if (!db) { throw new Error('MongoDB connection not available'); @@ -74,26 +70,6 @@ const updateSchema = async (): Promise<{ success: boolean; error?: unknown }> => // eslint-disable-next-line no-console console.log('Schema updated successfully:', result); - return { success: true }; - } catch (error) { - console.error('Error updating schema:', error); - return { success: false, error }; - } -}; - -server.ready(async (err) => { - if (err) { - console.error('Server failed to start:', err); - process.exit(1); - } - - // eslint-disable-next-line no-console - console.log('Updating subscription collection schema to require site_id...'); - - const result = await updateSchema(); - // eslint-disable-next-line no-console - console.log('Schema update result:', result); - - await server.close(); - process.exit(result.success ? 0 : 1); -}); + }, + [mongodb], +); diff --git a/src/lib/command.ts b/src/lib/command.ts new file mode 100644 index 0000000..777bea2 --- /dev/null +++ b/src/lib/command.ts @@ -0,0 +1,57 @@ +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); + }); +} From 629e9f5949edfd30804f00e84cd7cff45aeb9d50 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:11 +0200 Subject: [PATCH 120/228] Remove @types/tap --- package-lock.json | 10 ---------- package.json | 1 - 2 files changed, 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index e821be3..d23c384 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "@types/minimist": "^1.2.5", "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", - "@types/tap": "^15.0.5", "c8": "^10.1.3", "concurrently": "^8.2.2", "fastify-tsconfig": "^2.0.0", @@ -623,15 +622,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", diff --git a/package.json b/package.json index 0e5530a..73dea6c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "@types/minimist": "^1.2.5", "@types/node": "^20.4.4", "@types/nodemailer": "^6.4.14", - "@types/tap": "^15.0.5", "c8": "^10.1.3", "concurrently": "^8.2.2", "fastify-tsconfig": "^2.0.0", From 1f8fd5818737e13f4c5fc2863f971cce1bfee70c Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:14 +0200 Subject: [PATCH 121/228] Update @types/node to match current NodeJS version --- package-lock.json | 20 +++++++++++--------- package.json | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index d23c384..607187c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@biomejs/biome": "^2.2.4", "@types/jsdom": "^21.1.6", "@types/minimist": "^1.2.5", - "@types/node": "^20.4.4", + "@types/node": "^22.19.1", "@types/nodemailer": "^6.4.14", "c8": "^10.1.3", "concurrently": "^8.2.2", @@ -605,12 +605,13 @@ "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", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/nodemailer": { @@ -3363,10 +3364,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", diff --git a/package.json b/package.json index 73dea6c..f4a7b65 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@biomejs/biome": "^2.2.4", "@types/jsdom": "^21.1.6", "@types/minimist": "^1.2.5", - "@types/node": "^20.4.4", + "@types/node": "^22.19.1", "@types/nodemailer": "^6.4.14", "c8": "^10.1.3", "concurrently": "^8.2.2", From 453c5b318a1995053cdc850e8856898e65b39a48 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:15 +0200 Subject: [PATCH 122/228] Run tests only from *.test.ts --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4a7b65..167fdd9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --test-force-exit -r ts-node/register test/**/*.ts", + "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --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", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", From c9805dca53692002aa2eeeafa1a4c476e16b45c3 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:16 +0200 Subject: [PATCH 123/228] Add tests for src/lib/command.ts --- src/lib/command.ts | 2 ++ test/lib/command.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 test/lib/command.test.ts diff --git a/src/lib/command.ts b/src/lib/command.ts index 777bea2..d6d183b 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -54,4 +54,6 @@ export default function command(app: Command, plugins: Array<(...args: any[]) => // Exit with failure if command failed. process.exit(result ? 0 : 1); }); + + return server; } diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts new file mode 100644 index 0000000..a31e18b --- /dev/null +++ b/test/lib/command.test.ts @@ -0,0 +1,71 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, mock, test, type Mock } 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); + }); +}); From b22d0c78985193905720d1e8b5b921db85efb5ea Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:17 +0200 Subject: [PATCH 124/228] Add process manager wrapper for development container The Fastify dev server was running directly as PID 1 in the Docker container, causing the entire container to exit when the process crashed. This commit adds a simple shell script wrapper that keeps the container alive by automatically restarting the application process after crashes. --- openshift/Dockerfile | 5 ++--- tools/entrypoint | 20 ++++++++++++++++++++ tools/make/project/node.mk | 7 ++++++- 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100755 tools/entrypoint diff --git a/openshift/Dockerfile b/openshift/Dockerfile index c1c9855..96bdf62 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -20,12 +20,11 @@ ENV APP_NAME rekry-hakuvahti WORKDIR /app -COPY --chown=default:0 package*.json ./ -RUN npm ci --ignore-scripts +COPY tools/entrypoint /entrypoint EXPOSE 3000 -CMD ["npm", "run", "dev"] +ENTRYPOINT ["/entrypoint"] # Production image diff --git a/tools/entrypoint b/tools/entrypoint new file mode 100755 index 0000000..b236214 --- /dev/null +++ b/tools/entrypoint @@ -0,0 +1,20 @@ +#!/usr/bin/sh + +# Development process manager for Docker container +# +# When running the Fastify dev server directly as PID 1 in Docker +# (via CMD ["npm", "run", "dev"]), any crash or unhandled error causes +# the entire container to exit. +# +# This wrapper script keeps the container alive by automatically +# restarting the application process when it crashes. + +while true; do + npm run dev + EXIT_CODE=$? + echo "=========================================" + echo "Process exited with code $EXIT_CODE" + echo "Restarting in 3 seconds..." + echo "=========================================" + sleep 3 +done diff --git a/tools/make/project/node.mk b/tools/make/project/node.mk index 849e0e9..5a2a1f0 100644 --- a/tools/make/project/node.mk +++ b/tools/make/project/node.mk @@ -1,5 +1,5 @@ NODE_FRESH_TARGETS := up post-install -NODE_POST_INSTALL_TARGETS := dotenv hav-build hav-init-db +NODE_POST_INSTALL_TARGETS := dotenv npm-install hav-build hav-init-db PHONY += fresh fresh: ## Build fresh development environment and sync @@ -13,6 +13,11 @@ PHONY += dotenv dotenv: ## Ensure dotenv exists $(call docker_compose_exec,cp -n .env.dist .env) +PHONY += npm-install +npm-install: + $(call step,npm ci...\n) + $(call npm,ci) + PHONY += hav-build hav-build: ## Compile typescript $(call step,Run tsc...\n) From a9355701fd5b2371c4d27ee534789e5d033941c9 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:18 +0200 Subject: [PATCH 125/228] Add tests for /subscription/confirm and /subscription/delete APIs This also updated fastify plugins. I could not figure out how to access plugins in the older version. In version 5, plugins are available to tests if { skipOverride: true } is passed to the app configuration. See test/helper.ts for more details. --- package-lock.json | 1278 ++++++++++------------- package.json | 20 +- src/app.ts | 18 +- src/plugins/sensible.ts | 7 +- src/routes/confirmSubscription.ts | 58 +- src/routes/deleteSubscription.ts | 61 +- src/routes/healthzAndReadiness.ts | 4 +- test/helper.ts | 43 +- test/routes/confirmSubscription.test.ts | 58 + test/routes/deleteSubscription.test.ts | 57 + 10 files changed, 809 insertions(+), 795 deletions(-) create mode 100644 test/routes/confirmSubscription.test.ts create mode 100644 test/routes/deleteSubscription.test.ts diff --git a/package-lock.json b/package-lock.json index 607187c..c294a88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,18 @@ "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", "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", "jsdom": "^24.0.0", "minimist": "^1.2.8", "nodemailer": "^7.0.10", @@ -34,7 +34,7 @@ "@types/nodemailer": "^6.4.14", "c8": "^10.1.3", "concurrently": "^8.2.2", - "fastify-tsconfig": "^2.0.0", + "fastify-tsconfig": "^3.0.0", "ts-node": "^10.4.0", "typescript": "^5.2.2" } @@ -238,55 +238,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", @@ -294,24 +426,36 @@ } }, "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" @@ -464,13 +608,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/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -552,9 +703,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", @@ -632,27 +784,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", @@ -691,14 +834,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.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "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", @@ -706,9 +850,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" }, @@ -744,18 +889,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", @@ -776,12 +909,12 @@ } }, "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" } }, @@ -799,87 +932,28 @@ "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" - } + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "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": "10.1.3", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", @@ -1024,29 +1098,18 @@ } }, "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": { @@ -1064,9 +1127,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", @@ -1087,7 +1151,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", @@ -1191,29 +1256,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", @@ -1234,6 +1276,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": "*" } @@ -1275,6 +1318,15 @@ "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", @@ -1395,31 +1447,11 @@ "node": ">=6" } }, - "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==", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, - "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/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", @@ -1433,16 +1465,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" } }, @@ -1458,17 +1500,29 @@ "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.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.2.tgz", + "integrity": "sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==", "funding": [ { "type": "github", @@ -1481,51 +1535,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.0", + "@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", @@ -1546,17 +1608,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": { @@ -1568,30 +1652,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": { @@ -1666,24 +1738,6 @@ "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", @@ -1764,35 +1818,6 @@ "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" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1853,13 +1878,10 @@ } }, "node_modules/help-me": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", - "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", - "dependencies": { - "glob": "^8.0.0", - "readable-stream": "^3.6.0" - } + "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", @@ -1928,56 +1950,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==", - "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" - }, + "node_modules/ipaddr.js": { + "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": ">=8" + "node": ">= 10" } }, "node_modules/is-docker": { @@ -1994,14 +1978,6 @@ "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", @@ -2011,26 +1987,6 @@ "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", @@ -2130,6 +2086,7 @@ "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" } @@ -2173,53 +2130,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", @@ -2238,17 +2219,6 @@ "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", @@ -2270,11 +2240,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", @@ -2303,7 +2268,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", @@ -2324,17 +2290,6 @@ "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", @@ -2355,24 +2310,25 @@ } }, "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": { @@ -2400,12 +2356,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": { @@ -2422,14 +2379,6 @@ "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", @@ -2544,23 +2493,13 @@ "dev": true, "license": "ISC" }, - "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.13.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.13.1.tgz", - "integrity": "sha512-Szuj+ViDTjKPQYiKumGmEn3frdl+ZPSdosHyt9SnUevFosOkMY2b7ipxlEctNKPmMD/VibeBI+ZcZCJK+4DPuw==", + "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", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", @@ -2569,7 +2508,6 @@ "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", - "slow-redact": "^0.3.0", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, @@ -2578,97 +2516,65 @@ } }, "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": { + "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==", @@ -2684,61 +2590,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", @@ -2791,14 +2642,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": { @@ -2829,6 +2682,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" } @@ -2847,9 +2701,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" @@ -2866,9 +2720,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", @@ -2904,12 +2759,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": { @@ -2938,17 +2803,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" }, @@ -2957,9 +2831,10 @@ } }, "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", @@ -3011,16 +2886,11 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slow-redact": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.1.tgz", - "integrity": "sha512-NvFvl1GuLZNW4U046Tfi8b26zXo8aBzgCAS2f7yVJR/fArN93mOqSA99cB9uITm92ajSz01bsu1K7SCVVjIMpQ==", - "license": "MIT" - }, "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" } @@ -3029,6 +2899,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" } @@ -3131,11 +3002,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" @@ -3227,18 +3099,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", @@ -3270,14 +3130,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": { @@ -3350,11 +3211,19 @@ "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", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3378,14 +3247,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", @@ -3485,15 +3346,16 @@ } }, "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": ">=18" } }, "node_modules/which": { @@ -3596,11 +3458,6 @@ "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", @@ -3623,6 +3480,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" } diff --git a/package.json b/package.json index 167fdd9..8107a31 100644 --- a/package.json +++ b/package.json @@ -29,18 +29,18 @@ "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", "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", "jsdom": "^24.0.0", "minimist": "^1.2.8", "nodemailer": "^7.0.10", @@ -54,7 +54,7 @@ "@types/nodemailer": "^6.4.14", "c8": "^10.1.3", "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/src/app.ts b/src/app.ts index c94d6c1..2f0d2e3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,14 +1,20 @@ import { join } from 'node:path'; import AutoLoad, { type AutoloadPluginOptions } from '@fastify/autoload'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; -import type { FastifyPluginAsync, FastifyServerOptions } from 'fastify'; +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 = {}; +export const options: AppOptions = {}; + +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 -const app: FastifyPluginAsync = async (fastify, opts): Promise => { if (process.env.ENVIRONMENT === undefined) { throw new Error('ENVIRONMENT environment variable is not set'); } @@ -19,7 +25,6 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise throw new Error('ENVIRONMENT environment variable is not valid'); } - const release = process.env.SENTRY_RELEASE ?? ''; fastify.register(fastifySentry, { dsn: process.env.SENTRY_DSN, beforeSend: (event) => { @@ -39,7 +44,7 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise return event; }, environment: env, - release, + release: process.env.SENTRY_RELEASE ?? '', setErrorHandler: true, }); @@ -56,4 +61,3 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise }; export default app; -export { app, options }; diff --git a/src/plugins/sensible.ts b/src/plugins/sensible.ts index 42787c3..d42fe9f 100644 --- a/src/plugins/sensible.ts +++ b/src/plugins/sensible.ts @@ -1,8 +1,7 @@ -import sensible, { type 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) => { +// This plugin adds some utilities to handle http errors +export default fp(async (fastify) => { fastify.register(sensible); }); diff --git a/src/routes/confirmSubscription.ts b/src/routes/confirmSubscription.ts index 93a1b84..c37eeb8 100644 --- a/src/routes/confirmSubscription.ts +++ b/src/routes/confirmSubscription.ts @@ -1,5 +1,5 @@ import { ObjectId } from '@fastify/mongodb'; -import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginAsync } from 'fastify'; import { Generic500Error, type Generic500ErrorType } from '../types/error'; import { @@ -9,8 +9,8 @@ import { } from '../types/subscription'; // 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; }>( @@ -23,30 +23,44 @@ const confirmSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, }, }, }, - async (request: FastifyRequest, reply: FastifyReply) => { - const mongodb = fastify.mongo; - const collection = mongodb.db?.collection('subscription'); + async (request, reply) => { const { id, hash } = request.params as { id: string; hash: string }; - const subscription = await collection?.findOne({ - _id: new ObjectId(id), - 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.', - }); + 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.', + }) + } }, ); }; diff --git a/src/routes/deleteSubscription.ts b/src/routes/deleteSubscription.ts index 4c66e32..9635b41 100644 --- a/src/routes/deleteSubscription.ts +++ b/src/routes/deleteSubscription.ts @@ -1,12 +1,11 @@ import { ObjectId } from '@fastify/mongodb'; -import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginAsync } from 'fastify'; import { Generic500Error, type Generic500ErrorType } from '../types/error'; 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; }>( @@ -19,44 +18,36 @@ const deleteSubscription: FastifyPluginAsync = async (fastify: FastifyInstance, }, }, }, - async (request: FastifyRequest, reply: FastifyReply) => { - const mongodb = fastify.mongo; - const collection = mongodb.db?.collection('subscription'); + async (request, reply) => { const { id, hash } = request.params as { id: string; hash: string }; - // Check that subscription exists and hash matches - const subscription = await collection?.findOne({ - _id: new ObjectId(id), - 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, - }); + // 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.', - }); + 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, - message: 'Subscription deleted', - }); + return reply + .code(200) + .send({ + statusCode: 200, + statusMessage: 'Subscription deleted', + }); + } }, ); }; diff --git a/src/routes/healthzAndReadiness.ts b/src/routes/healthzAndReadiness.ts index eb92e23..4c1f1aa 100644 --- a/src/routes/healthzAndReadiness.ts +++ b/src/routes/healthzAndReadiness.ts @@ -1,6 +1,6 @@ -import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; -const healthzAndReadiness: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { +const healthzAndReadiness: FastifyPluginAsync = async (fastify, _opts) => { fastify.get( '/healthz', { diff --git a/test/helper.ts b/test/helper.ts index 493de89..487da45 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,7 +1,12 @@ // This file contains code that we reuse between our tests. import helper from 'fastify-cli/helper.js'; -import * as path from 'path' +import * as path from 'node:path' import * as test from 'node:test' +import {FastifyInstance} from "fastify"; +import type {Collection} from "mongodb"; +import {ObjectId} from "@fastify/mongodb"; +import {SubscriptionStatus} from "../src/types/subscription"; +import assert from "node:assert"; export type TestContext = { after: typeof test.after @@ -11,23 +16,51 @@ 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. + */ +export async function createSubscription(collection: Collection | undefined, hash = `test-hash-${Date.now()}`): Promise { + const testSubscription = { + hash, + status: SubscriptionStatus.INACTIVE, + site_id: 'test', + email: 'test-atv-doc-id', + elastic_query: 'test-query', + query: '/search?q=test', + }; + + const insertResult = await collection?.insertOne(testSubscription); + + 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] // 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()) + await app.ready() + return app } diff --git a/test/routes/confirmSubscription.test.ts b/test/routes/confirmSubscription.test.ts new file mode 100644 index 0000000..b6c0a7d --- /dev/null +++ b/test/routes/confirmSubscription.test.ts @@ -0,0 +1,58 @@ +import { describe, test } from 'node:test'; +import * as assert from 'node:assert'; +import { build, createSubscription } from '../helper'; +import { ObjectId } from '@fastify/mongodb'; +import { SubscriptionStatus } from "../../src/types/subscription"; + +describe('/subscription/confirm', () => { + test('invalid subscription ID', async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/confirm/${new ObjectId()}/invalid`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 404); + }); + + test('invalid subscription hash', async (t) => { + const app = await build(t); + + const collection = app.mongo.db?.collection('subscription'); + const subscriptionId = await createSubscription(collection); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/confirm/${subscriptionId}/invalid`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 404); + + // Verify the subscription status was actually updated in MongoDB + const updatedSubscription = await collection?.findOne({ _id: subscriptionId }); + assert.strictEqual(updatedSubscription?.status, SubscriptionStatus.INACTIVE, 'Status should be INACTIVE'); + }); + + test('valid requests are confirmed and status changes from INACTIVE to ACTIVE', async (t) => { + const app = await build(t); + + const collection = app.mongo.db?.collection('subscription'); + const hash = `test-hash-123-${Date.now()}`; + const subscriptionId = await createSubscription(collection, hash); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/confirm/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 200); + + // Verify the subscription status was actually updated in MongoDB + const updatedSubscription = await collection?.findOne({ _id: subscriptionId }); + assert.strictEqual(updatedSubscription?.status, SubscriptionStatus.ACTIVE, 'Status should be ACTIVE'); + }); +}) diff --git a/test/routes/deleteSubscription.test.ts b/test/routes/deleteSubscription.test.ts new file mode 100644 index 0000000..9c3113a --- /dev/null +++ b/test/routes/deleteSubscription.test.ts @@ -0,0 +1,57 @@ +import { describe, test } from 'node:test'; +import * as assert from 'node:assert'; +import { build, createSubscription } from '../helper'; +import { ObjectId } from '@fastify/mongodb'; + +describe('/subscription/delete', () => { + test('invalid subscription ID', async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: 'DELETE', + url: `/subscription/delete/${new ObjectId()}/invalid`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 404); + }); + + test('invalid subscription hash', async (t) => { + const app = await build(t); + + const collection = app.mongo.db?.collection('subscription'); + const subscriptionId = await createSubscription(collection); + + const res = await app.inject({ + method: 'DELETE', + url: `/subscription/delete/${subscriptionId}/invalid`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 404); + + // Verify the subscription status was actually updated in MongoDB + const updatedSubscription = await collection?.findOne({ _id: subscriptionId }); + assert.ok(updatedSubscription, 'Subscription was not deleted'); + }); + + test('valid requests deletes the subscription', async (t) => { + const app = await build(t); + + const collection = app.mongo.db?.collection('subscription'); + const hash = `test-hash-123-${Date.now()}`; + const subscriptionId = await createSubscription(collection, hash); + + const res = await app.inject({ + method: 'DELETE', + url: `/subscription/delete/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 200); + + // Verify the subscription status was actually updated in MongoDB + const updatedSubscription = await collection?.findOne({ _id: subscriptionId }); + assert.ok(!updatedSubscription, 'Subscription was deleted'); + }); +}) From 395ddbdc070484b5f926c2a72007c02d89378d9a Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:19 +0200 Subject: [PATCH 126/228] Various notes --- src/lib/command.ts | 1 + src/lib/siteConfigurationLoader.ts | 6 ++++++ src/plugins/atv.ts | 1 + src/plugins/validateElasticQuery.ts | 5 +++-- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/command.ts b/src/lib/command.ts index d6d183b..0bcdb2c 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -1,3 +1,4 @@ +// @fixme '@immobiliarelabs/fastify-sentry' is no longer maintained. import fastifySentry from '@immobiliarelabs/fastify-sentry'; import dotenv from 'dotenv'; import fastify, { type FastifyInstance } from 'fastify'; diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts index 94a3e4f..5f5aad4 100644 --- a/src/lib/siteConfigurationLoader.ts +++ b/src/lib/siteConfigurationLoader.ts @@ -25,6 +25,12 @@ export class 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; diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 8c5b60d..28a735d 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -176,6 +176,7 @@ const atvGetDocumentBatch = async (emails: string[]): Promise { try { + // @fixme this should not affect all post requests. // Hook only runs on POST requests if (request.method !== 'POST') { return; diff --git a/src/plugins/validateElasticQuery.ts b/src/plugins/validateElasticQuery.ts index 6098f5a..499aed1 100644 --- a/src/plugins/validateElasticQuery.ts +++ b/src/plugins/validateElasticQuery.ts @@ -9,11 +9,12 @@ 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 {FastifyRequest} request - the request object - * @return {void} no return value + * @param request - the request object + * @param fastify - fastify instance */ const validateElasticQueryHook = async (request: FastifyRequest, fastify: FastifyInstance) => { try { + // @fixme this plugin should only be added to addSubscription route, not globally. // Only run on POST requests to /subscription endpoint if (request.method !== 'POST' || request.url !== '/subscription') { return; From 1b651d48936f3ab32e0aa5e013574aad0ced5f55 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:20 +0200 Subject: [PATCH 127/228] Fix renewSubscription.test.ts Fix the test now that mongodb connection actually works. The test was previously just skipped. --- test/helper.ts | 26 +++- test/routes/confirmSubscription.test.ts | 2 +- test/routes/deleteSubscription.test.ts | 2 +- test/routes/renewSubscription.test.ts | 186 +++++++++++------------- 4 files changed, 108 insertions(+), 108 deletions(-) diff --git a/test/helper.ts b/test/helper.ts index 487da45..6c91427 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -28,18 +28,32 @@ function config () { /** * 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, hash = `test-hash-${Date.now()}`): Promise { - const testSubscription = { - hash, +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: `test-hash-${Date.now()}`, status: SubscriptionStatus.INACTIVE, site_id: 'test', email: 'test-atv-doc-id', elastic_query: 'test-query', query: '/search?q=test', - }; - - const insertResult = await collection?.insertOne(testSubscription); + ...subscriptionData, // Override defaults with provided data + }); assert.ok(insertResult) diff --git a/test/routes/confirmSubscription.test.ts b/test/routes/confirmSubscription.test.ts index b6c0a7d..3b844a0 100644 --- a/test/routes/confirmSubscription.test.ts +++ b/test/routes/confirmSubscription.test.ts @@ -41,7 +41,7 @@ describe('/subscription/confirm', () => { const collection = app.mongo.db?.collection('subscription'); const hash = `test-hash-123-${Date.now()}`; - const subscriptionId = await createSubscription(collection, hash); + const subscriptionId = await createSubscription(collection, { hash }); const res = await app.inject({ method: 'GET', diff --git a/test/routes/deleteSubscription.test.ts b/test/routes/deleteSubscription.test.ts index 9c3113a..6aed8c3 100644 --- a/test/routes/deleteSubscription.test.ts +++ b/test/routes/deleteSubscription.test.ts @@ -40,7 +40,7 @@ describe('/subscription/delete', () => { const collection = app.mongo.db?.collection('subscription'); const hash = `test-hash-123-${Date.now()}`; - const subscriptionId = await createSubscription(collection, hash); + const subscriptionId = await createSubscription(collection, { hash }); const res = await app.inject({ method: 'DELETE', diff --git a/test/routes/renewSubscription.test.ts b/test/routes/renewSubscription.test.ts index 3eda27f..6c66b29 100644 --- a/test/routes/renewSubscription.test.ts +++ b/test/routes/renewSubscription.test.ts @@ -1,105 +1,91 @@ -import { test } from 'node:test'; +import { describe, test, mock } from 'node:test'; import * as assert from 'node:assert'; -import { build } from '../helper'; +import { build, createSubscription } from '../helper'; import { ObjectId } from '@fastify/mongodb'; -import * as path from 'path'; - -process.env.ENVIRONMENT = 'local'; -process.env.SENTRY_DSN = 'https://test@sentry.io/test'; -process.env.SITE_CONFIGURATION_PATH = path.join(__dirname, '../../conf'); - -test('renewSubscription - invalid subscription ID', async (t) => { - const app = await build(t); - - const subscriptionId = new ObjectId(); - const hash = 'invalidhash'; - - const res = await app.inject({ - method: 'GET', - url: `/subscription/renew/${subscriptionId}/${hash}`, - headers: { token: 'test' }, - }); - - assert.strictEqual(res.statusCode, 404); - const body = JSON.parse(res.payload); - assert.strictEqual(body.statusCode, 404); - assert.strictEqual(body.statusMessage, 'Subscription not found.'); -}); - -test('renewSubscription - route is registered and responds', async (t) => { - const app = await build(t); - - const subscriptionId = new ObjectId(); - const hash = 'testhash'; - - const res = await app.inject({ - method: 'GET', - url: `/subscription/renew/${subscriptionId}/${hash}`, - headers: { token: 'test' }, +import { SubscriptionStatus } from '../../src/types/subscription'; + +describe('/subscription/renew', () => { + test('renewSubscription - invalid subscription ID', async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/renew/${new ObjectId()}/invalidhash`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 404); + const body = JSON.parse(res.payload); + assert.strictEqual(body.statusCode, 404); + assert.strictEqual(body.statusMessage, 'Subscription not found.'); }); - assert.ok(res.statusCode !== undefined, 'Should get a response'); - assert.ok([200, 400, 404, 500].includes(res.statusCode), 'Should return a valid HTTP status code'); -}); - -test('renewSubscription - successfully renews old subscription with real MongoDB', async (t) => { - const app = await build(t); - - if (!app.mongo?.db) { - console.log('Skipping test - MongoDB not available'); - return; - } - - // Mock ATV update to always succeed - app.atvUpdateDocumentDeleteAfter = async (atvDocId: string, maxAge?: number) => { - return { - id: atvDocId, - delete_after: new Date(Date.now() + (maxAge || 90) * 24 * 60 * 60 * 1000).toISOString().substring(0, 10), - }; - }; - - // Create a subscription that's old enough to renew (87 days ago) - const oldDate = new Date(Date.now() - 87 * 24 * 60 * 60 * 1000); - const hash = 'test-renewal-hash-' + Date.now(); - - const testSubscription = { - hash, - status: 1, - created: oldDate, - modified: oldDate, - email: 'test-atv-doc-id', - site_id: 'rekry', - expiry_notification_sent: 0, - elastic_query: 'test', - query: '/test', - search_description: 'Test subscription for renewal', - lang: 'fi', - }; - - const collection = app.mongo.db.collection('subscription'); - const insertResult = await collection.insertOne(testSubscription); - const subscriptionId = insertResult.insertedId; - - t.after(async () => { - await collection.deleteOne({ _id: subscriptionId }); + test('Only active subscriptions can be renewed', async (t) => { + const app = await build(t); + + const collection = app.mongo.db?.collection('subscription'); + const hash = 'test-renewal-hash-' + Date.now(); + const subscriptionId = await createSubscription(collection, { + hash, + status: SubscriptionStatus.INACTIVE, + }); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/renew/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(JSON.parse(res.payload).statusMessage, 'Only active subscriptions can be renewed.'); + }) + + test('renewSubscription - successfully renews old subscription', async (t) => { + const app = await build(t); + + const atvMock = mock.fn(async (atvDocId: string, maxAge?: number) => { + return { + id: atvDocId, + delete_after: new Date(Date.now() + (maxAge || 90) * 24 * 60 * 60 * 1000).toISOString().substring(0, 10), + }; + }); + + // Mock ATV update to always succeed + (app as any).atvUpdateDocumentDeleteAfter = atvMock; + + // Create a subscription that's old enough to renew (87 days ago) + const oldDate = new Date(Date.now() - 87 * 24 * 60 * 60 * 1000); + const hash = 'test-renewal-hash-' + Date.now(); + + const collection = app.mongo.db?.collection('subscription'); + const subscriptionId = await createSubscription(collection, { + hash, + site_id: 'rekry', + status: SubscriptionStatus.ACTIVE, + created: oldDate, + modified: oldDate, + expiry_notification_sent: 0, + }); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/renew/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 200); + const body = JSON.parse(res.payload); + assert.strictEqual(body.statusCode, 200); + assert.strictEqual(body.statusMessage, 'Subscription renewed successfully.'); + assert.ok(body.expiryDate, 'Should return new expiry date'); + + const updated = await collection?.findOne({ _id: subscriptionId }); + assert.ok(updated, 'Subscription should exist'); + assert.ok(updated?.created.getTime() > oldDate.getTime(), 'Created date should be updated'); + assert.ok(updated?.first_created, 'first_created should be set'); + assert.strictEqual(updated?.first_created.getTime(), oldDate.getTime(), 'Original date should be archived'); + assert.strictEqual(updated?.expiry_notification_sent, 0, 'Expiry notification should be reset'); + + assert.ok(atvMock.mock.callCount() >= 1) }); - - const res = await app.inject({ - method: 'GET', - url: `/subscription/renew/${subscriptionId}/${hash}`, - headers: { token: 'test' }, - }); - - assert.strictEqual(res.statusCode, 200); - const body = JSON.parse(res.payload); - assert.strictEqual(body.statusCode, 200); - assert.strictEqual(body.statusMessage, 'Subscription renewed successfully.'); - assert.ok(body.expiryDate, 'Should return new expiry date'); - - const updated = await collection.findOne({ _id: subscriptionId }); - assert.ok(updated, 'Subscription should exist'); - assert.ok(updated.created.getTime() > oldDate.getTime(), 'Created date should be updated'); - assert.ok(updated.first_created, 'first_created should be set'); - assert.strictEqual(updated.first_created.getTime(), oldDate.getTime(), 'Original date should be archived'); - assert.strictEqual(updated.expiry_notification_sent, 0, 'Expiry notification should be reset'); -}); +}) From bb3907ddee213f97485e4dc1fa2f3b909a96a399 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:21 +0200 Subject: [PATCH 128/228] Add tests for /subscription/status --- src/routes/subscriptionStatus.ts | 25 +++++++------- src/types/error.ts | 6 ++++ test/routes/subscriptionStatus.test.ts | 47 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 test/routes/subscriptionStatus.test.ts diff --git a/src/routes/subscriptionStatus.ts b/src/routes/subscriptionStatus.ts index 8863afa..be14f8d 100644 --- a/src/routes/subscriptionStatus.ts +++ b/src/routes/subscriptionStatus.ts @@ -1,6 +1,6 @@ import { ObjectId } from '@fastify/mongodb'; -import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; -import { Generic500Error, type Generic500ErrorType } from '../types/error'; +import type { FastifyPluginAsync } from 'fastify'; +import {Generic500Error, type Generic500ErrorType, GenericResponse, GenericResponseType} from '../types/error'; import { SubscriptionStatus, @@ -9,33 +9,32 @@ import { } from '../types/subscription'; // Checks subscription status - -const subscriptionStatus: FastifyPluginAsync = async (fastify: FastifyInstance, _opts: object): Promise => { +const subscriptionStatus: FastifyPluginAsync = async (fastify, _opts) => { fastify.get<{ - Reply: SubscriptionStatusResponseType | Generic500ErrorType; + Reply: SubscriptionStatusResponseType | GenericResponseType | Generic500ErrorType; }>( '/subscription/status/:id/:hash', { schema: { response: { 200: SubscriptionStatusResponse, + 404: GenericResponse, 500: Generic500Error, }, }, }, - async (request: FastifyRequest, reply: FastifyReply) => { - const mongodb = fastify.mongo; - const collection = mongodb.db?.collection('subscription'); + async (request, reply) => { const { id, hash } = request.params as { id: string; hash: string }; - const subscription = await collection?.findOne({ - _id: new ObjectId(id), - hash, - }); + const subscription = await fastify.mongo.db + ?.collection('subscription') + ?.findOne({ + _id: new ObjectId(id), + hash, + }); if (!subscription) { return reply.code(404).send({ - statusCode: 404, statusMessage: 'Subscription not found.', }); } diff --git a/src/types/error.ts b/src/types/error.ts index 6a55bbc..5761cfd 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -1,5 +1,11 @@ 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(), }); diff --git a/test/routes/subscriptionStatus.test.ts b/test/routes/subscriptionStatus.test.ts new file mode 100644 index 0000000..f395129 --- /dev/null +++ b/test/routes/subscriptionStatus.test.ts @@ -0,0 +1,47 @@ +import { describe, test } from 'node:test'; +import * as assert from 'node:assert'; +import { build, createSubscription } from '../helper'; +import { ObjectId } from '@fastify/mongodb'; +import { SubscriptionStatus } from '../../src/types/subscription'; + +describe('/subscription/status', () => { + test('404 response for unknown subscription', async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: 'GET', + url: `/subscription/status/${new ObjectId()}/invalid`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 404); + }); + + test('correct values for status', async (t) => { + const tests = [ + [SubscriptionStatus.INACTIVE, 'inactive'], + [SubscriptionStatus.ACTIVE, 'active'], + [SubscriptionStatus.DISABLED, 'disabled'], + ] as const + + const app = await build(t); + const collection = app.mongo.db?.collection('subscription'); + + for (const [status, result] of tests) { + const hash = crypto.randomUUID() + const subscriptionId = await createSubscription(collection, { + status, + hash + }) + + const res = await app.inject({ + method: 'GET', + url: `/subscription/status/${subscriptionId}/${hash}`, + headers: { token: 'test' }, + }); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(JSON.parse(res.payload).subscriptionStatus, result); + } + }) +}) From b5e13524553e4e8cd0baec7959cd7a81b0181fa2 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:22 +0200 Subject: [PATCH 129/228] Add tests for probes --- test/routes/root.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/routes/root.test.ts b/test/routes/root.test.ts index c718b58..f29c796 100644 --- a/test/routes/root.test.ts +++ b/test/routes/root.test.ts @@ -11,3 +11,23 @@ test('default root route', async (t) => { }) assert.deepStrictEqual(JSON.parse(res.payload), { root: true }) }) + +test('/healthz', async (t) => { + const app = await build(t) + + const res = await app.inject({ + url: '/healthz', + }) + + assert.strictEqual(res.statusCode, 200); +}) + +test('/readiness', async (t) => { + const app = await build(t) + + const res = await app.inject({ + url: '/readiness', + }) + + assert.strictEqual(res.statusCode, 200); +}) From 02257f009342af31365230b8db2e7e8f5d389cd7 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 09:10:24 +0200 Subject: [PATCH 130/228] Run biome fix --- src/app.ts | 2 +- src/routes/confirmSubscription.ts | 43 +-- src/routes/deleteSubscription.ts | 27 +- src/routes/subscriptionStatus.ts | 12 +- test/helper.ts | 49 ++- test/lib/command.test.ts | 2 +- test/lib/siteConfigurationLoader.test.ts | 472 +++++++++++------------ test/routes/confirmSubscription.test.ts | 8 +- test/routes/deleteSubscription.test.ts | 6 +- test/routes/renewSubscription.test.ts | 10 +- test/routes/root.test.ts | 28 +- test/routes/subscriptionStatus.test.ts | 16 +- 12 files changed, 307 insertions(+), 368 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2f0d2e3..2e2d584 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,7 +13,7 @@ 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 + delete opts.skipOverride; if (process.env.ENVIRONMENT === undefined) { throw new Error('ENVIRONMENT environment variable is not set'); diff --git a/src/routes/confirmSubscription.ts b/src/routes/confirmSubscription.ts index c37eeb8..8a88b18 100644 --- a/src/routes/confirmSubscription.ts +++ b/src/routes/confirmSubscription.ts @@ -27,16 +27,14 @@ const confirmSubscription: FastifyPluginAsync = async (fastify, _opts) => { const { id, hash } = request.params as { id: string; hash: string }; // 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 } }, - ); + const response = await fastify.mongo.db?.collection('subscription')?.updateOne( + { + _id: new ObjectId(id), + hash, + status: SubscriptionStatus.INACTIVE, + }, + { $set: { status: SubscriptionStatus.ACTIVE } }, + ); if (response?.modifiedCount) { fastify.log.info({ @@ -44,22 +42,15 @@ const confirmSubscription: FastifyPluginAsync = async (fastify, _opts) => { message: `Subscription ${id} confirmed`, }); - 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.', + }); + } else { + return reply.code(404).header('Content-Type', 'application/json; charset=utf-8').send({ + statusCode: 404, + statusMessage: 'Subscription not found.', + }); } }, ); diff --git a/src/routes/deleteSubscription.ts b/src/routes/deleteSubscription.ts index 9635b41..5c5327f 100644 --- a/src/routes/deleteSubscription.ts +++ b/src/routes/deleteSubscription.ts @@ -22,31 +22,24 @@ const deleteSubscription: FastifyPluginAsync = async (fastify, _opts) => { 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 }); + 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 { + 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', - }); + return reply.code(200).send({ + statusCode: 200, + statusMessage: 'Subscription deleted', + }); } }, ); diff --git a/src/routes/subscriptionStatus.ts b/src/routes/subscriptionStatus.ts index be14f8d..f235314 100644 --- a/src/routes/subscriptionStatus.ts +++ b/src/routes/subscriptionStatus.ts @@ -1,6 +1,6 @@ import { ObjectId } from '@fastify/mongodb'; import type { FastifyPluginAsync } from 'fastify'; -import {Generic500Error, type Generic500ErrorType, GenericResponse, GenericResponseType} from '../types/error'; +import { Generic500Error, type Generic500ErrorType, GenericResponse, type GenericResponseType } from '../types/error'; import { SubscriptionStatus, @@ -26,12 +26,10 @@ const subscriptionStatus: FastifyPluginAsync = async (fastify, _opts) => { 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, - }); + const subscription = await fastify.mongo.db?.collection('subscription')?.findOne({ + _id: new ObjectId(id), + hash, + }); if (!subscription) { return reply.code(404).send({ diff --git a/test/helper.ts b/test/helper.ts index 6c91427..9539bf3 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,29 +1,31 @@ // This file contains code that we reuse between our tests. + +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 * as path from 'node:path' -import * as test from 'node:test' -import {FastifyInstance} from "fastify"; -import type {Collection} from "mongodb"; -import {ObjectId} from "@fastify/mongodb"; -import {SubscriptionStatus} from "../src/types/subscription"; -import assert from "node:assert"; +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') +const AppPath = path.join(__dirname, '..', 'src', 'app.ts'); // Fill in this config with all the configurations // needed for testing the application -function config () { +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 - } + skipOverride: true, // Register our application with fastify-plugin + }; } /** @@ -43,10 +45,10 @@ export async function createSubscription( elastic_query: string; query: string; [key: string]: unknown; - }> = {} + }> = {}, ): Promise { const insertResult = await collection?.insertOne({ - hash: `test-hash-${Date.now()}`, + hash: crypto.randomUUID(), status: SubscriptionStatus.INACTIVE, site_id: 'test', email: 'test-atv-doc-id', @@ -55,30 +57,27 @@ export async function createSubscription( ...subscriptionData, // Override defaults with provided data }); - assert.ok(insertResult) + assert.ok(insertResult); return insertResult.insertedId; } // Automatically build and tear down our instance -async function build (t: TestContext): Promise { +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, 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()); - await app.ready() + await app.ready(); - return app + return app; } -export { - config, - build -} +export { config, build }; diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index a31e18b..5fbd238 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -1,5 +1,5 @@ import * as assert from 'node:assert'; -import { afterEach, beforeEach, describe, mock, test, type Mock } from 'node:test'; +import { afterEach, beforeEach, describe, type Mock, mock, test } from 'node:test'; import command, { type Command } from '../../src/lib/command'; /** diff --git a/test/lib/siteConfigurationLoader.test.ts b/test/lib/siteConfigurationLoader.test.ts index d848cea..b9265e4 100644 --- a/test/lib/siteConfigurationLoader.test.ts +++ b/test/lib/siteConfigurationLoader.test.ts @@ -1,9 +1,9 @@ -import { test } from 'node:test' -import * as assert from 'node:assert' -import * as fs from 'fs' -import * as path from 'path' -import * as os from 'os' -import { SiteConfigurationLoader } from '../../src/lib/siteConfigurationLoader' +import * as assert from 'node:assert'; +import { test } from 'node:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { SiteConfigurationLoader } from '../../src/lib/siteConfigurationLoader'; const mockRekryConfig = { name: 'rekry', @@ -12,53 +12,53 @@ const mockRekryConfig = { 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' + sv: 'https://helfi-rekry.docker.so/sv', }, subscription: { maxAge: 90, unconfirmedMaxAge: 5, - expiryNotificationDays: 3 + expiryNotificationDays: 3, }, mail: { - templatePath: 'rekry' + templatePath: 'rekry', }, - elasticProxyUrl: 'http://localhost:9200' + elasticProxyUrl: 'http://localhost:9200', }, 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' + sv: 'https://helfi-rekry.docker.so/sv', }, subscription: { maxAge: 90, unconfirmedMaxAge: 5, - expiryNotificationDays: 3 + expiryNotificationDays: 3, }, mail: { - templatePath: 'rekry' + templatePath: 'rekry', }, - elasticProxyUrl: 'http://localhost:9200' + elasticProxyUrl: 'http://localhost:9200', }, prod: { urls: { base: 'https://hel.fi', en: 'https://hel.fi/en', fi: 'https://hel.fi/fi', - sv: 'https://hel.fi/sv' + sv: 'https://hel.fi/sv', }, subscription: { maxAge: 90, unconfirmedMaxAge: 5, - expiryNotificationDays: 3 + expiryNotificationDays: 3, }, mail: { - templatePath: 'rekry' + templatePath: 'rekry', }, - elasticProxyUrl: 'http://localhost:9200' - } -} + elasticProxyUrl: 'http://localhost:9200', + }, +}; const mockAnotherConfig = { name: 'another-site', @@ -67,347 +67,305 @@ const mockAnotherConfig = { base: 'https://another.docker.so', en: 'https://another.docker.so/en', fi: 'https://another.docker.so/fi', - sv: 'https://another.docker.so/sv' + sv: 'https://another.docker.so/sv', }, subscription: { maxAge: 60, unconfirmedMaxAge: 3, - expiryNotificationDays: 2 + expiryNotificationDays: 2, }, mail: { - templatePath: 'another' + templatePath: 'another', }, - elasticProxyUrl: 'http://localhost:9200' + elasticProxyUrl: 'http://localhost:9200', }, dev: { urls: { base: 'https://another.docker.so', en: 'https://another.docker.so/en', fi: 'https://another.docker.so/fi', - sv: 'https://another.docker.so/sv' + sv: 'https://another.docker.so/sv', }, subscription: { maxAge: 60, unconfirmedMaxAge: 3, - expiryNotificationDays: 2 + expiryNotificationDays: 2, }, mail: { - templatePath: 'another' + templatePath: 'another', }, - elasticProxyUrl: 'http://localhost:9200' + elasticProxyUrl: 'http://localhost:9200', }, prod: { urls: { base: 'https://another.hel.fi', en: 'https://another.hel.fi/en', fi: 'https://another.hel.fi/fi', - sv: 'https://another.hel.fi/sv' + sv: 'https://another.hel.fi/sv', }, subscription: { maxAge: 60, unconfirmedMaxAge: 3, - expiryNotificationDays: 2 + expiryNotificationDays: 2, }, mail: { - templatePath: 'another' + templatePath: 'another', }, - elasticProxyUrl: 'http://localhost:9200' - } -} + elasticProxyUrl: 'http://localhost:9200', + }, +}; -let tempDir: string -let originalCwd: string -let originalEnv: string | undefined +let tempDir: string; +let originalCwd: string; +let originalEnv: string | undefined; test('SiteConfigurationLoader', async (t) => { // Setup: Create temporary directory and mock files await t.before(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'siteconfig-test-')) - originalCwd = process.cwd() - originalEnv = process.env.ENVIRONMENT - + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'siteconfig-test-')); + originalCwd = process.cwd(); + originalEnv = process.env.ENVIRONMENT; + // Change to temp directory - process.chdir(tempDir) - + process.chdir(tempDir); + // Create conf directory with test files - const confDir = path.join(tempDir, 'conf') - fs.mkdirSync(confDir) - - fs.writeFileSync( - path.join(confDir, 'rekry.json'), - JSON.stringify(mockRekryConfig, null, 2) - ) - - fs.writeFileSync( - path.join(confDir, 'another.json'), - JSON.stringify(mockAnotherConfig, null, 2) - ) - }) + const confDir = path.join(tempDir, 'conf'); + fs.mkdirSync(confDir); + + fs.writeFileSync(path.join(confDir, 'rekry.json'), JSON.stringify(mockRekryConfig, null, 2)); + + fs.writeFileSync(path.join(confDir, 'another.json'), JSON.stringify(mockAnotherConfig, null, 2)); + }); await t.after(async () => { // Cleanup - process.chdir(originalCwd) + process.chdir(originalCwd); if (originalEnv !== undefined) { - process.env.ENVIRONMENT = originalEnv + process.env.ENVIRONMENT = originalEnv; } else { - delete process.env.ENVIRONMENT + delete process.env.ENVIRONMENT; } - fs.rmSync(tempDir, { recursive: true, force: true }) - + fs.rmSync(tempDir, { recursive: true, force: true }); + // Reset singleton instance for clean testing - ;(SiteConfigurationLoader as any).instance = undefined - }) + (SiteConfigurationLoader as any).instance = undefined; + }); await t.beforeEach(() => { // Reset singleton instance before each test - ;(SiteConfigurationLoader as any).instance = undefined - + (SiteConfigurationLoader as any).instance = undefined; + // Reset environment to default - process.env.ENVIRONMENT = 'local' - + process.env.ENVIRONMENT = 'local'; + // Ensure clean test files exist - const confDir = path.join(tempDir, 'conf') + const confDir = path.join(tempDir, 'conf'); if (fs.existsSync(confDir)) { // Remove all files - const files = fs.readdirSync(confDir) + const files = fs.readdirSync(confDir); for (const file of files) { - fs.unlinkSync(path.join(confDir, file)) + fs.unlinkSync(path.join(confDir, file)); } } else { - fs.mkdirSync(confDir) + fs.mkdirSync(confDir); } - + // Recreate original test files - fs.writeFileSync( - path.join(confDir, 'rekry.json'), - JSON.stringify(mockRekryConfig, null, 2) - ) - - fs.writeFileSync( - path.join(confDir, 'another.json'), - JSON.stringify(mockAnotherConfig, null, 2) - ) - }) + fs.writeFileSync(path.join(confDir, 'rekry.json'), JSON.stringify(mockRekryConfig, null, 2)); + + fs.writeFileSync(path.join(confDir, 'another.json'), JSON.stringify(mockAnotherConfig, null, 2)); + }); await t.test('getInstance returns singleton instance', () => { - const instance1 = SiteConfigurationLoader.getInstance() - const instance2 = SiteConfigurationLoader.getInstance() - assert.strictEqual(instance1, instance2) - }) + const instance1 = SiteConfigurationLoader.getInstance(); + const instance2 = SiteConfigurationLoader.getInstance(); + assert.strictEqual(instance1, instance2); + }); await t.test('loadConfigurations loads config files successfully', async () => { - process.env.ENVIRONMENT = 'local' - - const loader = SiteConfigurationLoader.getInstance() - await loader.loadConfigurations() - - const configs = loader.getConfigurations() - assert.strictEqual(Object.keys(configs).length, 2) - assert.strictEqual(configs.rekry.name, 'rekry') - assert.strictEqual(configs.rekry.urls.base, 'https://helfi-rekry.docker.so') - assert.strictEqual(configs['another'].name, 'another-site') - }) + process.env.ENVIRONMENT = 'local'; + + const loader = SiteConfigurationLoader.getInstance(); + await loader.loadConfigurations(); + + const configs = loader.getConfigurations(); + assert.strictEqual(Object.keys(configs).length, 2); + assert.strictEqual(configs.rekry.name, 'rekry'); + assert.strictEqual(configs.rekry.urls.base, 'https://helfi-rekry.docker.so'); + assert.strictEqual(configs['another'].name, 'another-site'); + }); await t.test('loadConfigurations uses prod environment when specified', async () => { - process.env.ENVIRONMENT = 'prod' - - const loader = SiteConfigurationLoader.getInstance() - await loader.loadConfigurations() - - const rekryConfig = loader.getConfiguration('rekry') - assert.strictEqual(rekryConfig?.urls.base, 'https://hel.fi') - }) + process.env.ENVIRONMENT = 'prod'; + + const loader = SiteConfigurationLoader.getInstance(); + await loader.loadConfigurations(); + + const rekryConfig = loader.getConfiguration('rekry'); + assert.strictEqual(rekryConfig?.urls.base, 'https://hel.fi'); + }); await t.test('loadConfigurations uses local environment', async () => { - process.env.ENVIRONMENT = 'local' - - const loader = SiteConfigurationLoader.getInstance() - await loader.loadConfigurations() - - const rekryConfig = loader.getConfiguration('rekry') - assert.strictEqual(rekryConfig?.urls.base, 'https://helfi-rekry.docker.so') - }) + process.env.ENVIRONMENT = 'local'; + + const loader = SiteConfigurationLoader.getInstance(); + await loader.loadConfigurations(); + + const rekryConfig = loader.getConfiguration('rekry'); + assert.strictEqual(rekryConfig?.urls.base, 'https://helfi-rekry.docker.so'); + }); await t.test('getConfiguration returns specific site config', async () => { - process.env.ENVIRONMENT = 'local' - - const loader = SiteConfigurationLoader.getInstance() - await loader.loadConfigurations() - - const rekryConfig = loader.getConfiguration('rekry') - assert.ok(rekryConfig) - assert.strictEqual(rekryConfig.id, 'rekry') - assert.strictEqual(rekryConfig.name, 'rekry') - assert.strictEqual(rekryConfig.subscription.maxAge, 90) - assert.strictEqual(rekryConfig.mail.templatePath, 'rekry') - }) + process.env.ENVIRONMENT = 'local'; + + const loader = SiteConfigurationLoader.getInstance(); + await loader.loadConfigurations(); + + const rekryConfig = loader.getConfiguration('rekry'); + assert.ok(rekryConfig); + assert.strictEqual(rekryConfig.id, 'rekry'); + assert.strictEqual(rekryConfig.name, 'rekry'); + assert.strictEqual(rekryConfig.subscription.maxAge, 90); + assert.strictEqual(rekryConfig.mail.templatePath, 'rekry'); + }); await t.test('getConfiguration returns undefined for non-existent site', async () => { - process.env.ENVIRONMENT = 'local' - - const loader = SiteConfigurationLoader.getInstance() - await loader.loadConfigurations() - - const config = loader.getConfiguration('non-existent') - assert.strictEqual(config, undefined) - }) + process.env.ENVIRONMENT = 'local'; + + const loader = SiteConfigurationLoader.getInstance(); + await loader.loadConfigurations(); + + const config = loader.getConfiguration('non-existent'); + assert.strictEqual(config, undefined); + }); await t.test('getSiteIds returns array of site IDs', async () => { - process.env.ENVIRONMENT = 'local' - - const loader = SiteConfigurationLoader.getInstance() - await loader.loadConfigurations() - - const siteIds = loader.getSiteIds() - assert.ok(Array.isArray(siteIds)) - assert.strictEqual(siteIds.length, 2) - assert.ok(siteIds.includes('rekry')) - assert.ok(siteIds.includes('another')) - }) + process.env.ENVIRONMENT = 'local'; + + const loader = SiteConfigurationLoader.getInstance(); + await loader.loadConfigurations(); + + const siteIds = loader.getSiteIds(); + assert.ok(Array.isArray(siteIds)); + assert.strictEqual(siteIds.length, 2); + assert.ok(siteIds.includes('rekry')); + assert.ok(siteIds.includes('another')); + }); await t.test('throws error when configuration directory does not exist', async () => { // Remove conf directory - fs.rmSync(path.join(tempDir, 'conf'), { recursive: true, force: true }) - - const loader = SiteConfigurationLoader.getInstance() - - await assert.rejects( - () => loader.loadConfigurations(), - /Configuration directory not found/ - ) - }) + fs.rmSync(path.join(tempDir, 'conf'), { recursive: true, force: true }); + + const loader = SiteConfigurationLoader.getInstance(); + + await assert.rejects(() => loader.loadConfigurations(), /Configuration directory not found/); + }); await t.test('throws error when no JSON files found', async () => { // Empty the conf directory - const confDir = path.join(tempDir, 'conf') - fs.rmSync(confDir, { recursive: true, force: true }) - fs.mkdirSync(confDir) - - const loader = SiteConfigurationLoader.getInstance() - - await assert.rejects( - () => loader.loadConfigurations(), - /No JSON configuration files found/ - ) - }) + const confDir = path.join(tempDir, 'conf'); + fs.rmSync(confDir, { recursive: true, force: true }); + fs.mkdirSync(confDir); + + const loader = SiteConfigurationLoader.getInstance(); + + await assert.rejects(() => loader.loadConfigurations(), /No JSON configuration files found/); + }); await t.test('throws error when environment not found in config', async () => { - process.env.ENVIRONMENT = 'staging' // Not present in mock config - + process.env.ENVIRONMENT = 'staging'; // Not present in mock config + // Ensure we have config files for this test - const confDir = path.join(tempDir, 'conf') + const confDir = path.join(tempDir, 'conf'); if (!fs.existsSync(path.join(confDir, 'rekry.json'))) { - fs.writeFileSync( - path.join(confDir, 'rekry.json'), - JSON.stringify(mockRekryConfig, null, 2) - ) + fs.writeFileSync(path.join(confDir, 'rekry.json'), JSON.stringify(mockRekryConfig, null, 2)); } - - const loader = SiteConfigurationLoader.getInstance() - - await assert.rejects( - () => loader.loadConfigurations(), - /Environment 'staging' not found in configuration/ - ) - }) + + const loader = SiteConfigurationLoader.getInstance(); + + await assert.rejects(() => loader.loadConfigurations(), /Environment 'staging' not found in configuration/); + }); await t.test('throws error when accessing methods before loading', () => { - const loader = SiteConfigurationLoader.getInstance() - - assert.throws( - () => loader.getConfigurations(), - /Configurations not loaded/ - ) - - assert.throws( - () => loader.getConfiguration('rekry'), - /Configurations not loaded/ - ) - - assert.throws( - () => loader.getSiteIds(), - /Configurations not loaded/ - ) - }) + const loader = SiteConfigurationLoader.getInstance(); + + assert.throws(() => loader.getConfigurations(), /Configurations not loaded/); + + assert.throws(() => loader.getConfiguration('rekry'), /Configurations not loaded/); + + assert.throws(() => loader.getSiteIds(), /Configurations not loaded/); + }); await t.test('throws error for invalid JSON file', async () => { // Clean up first to ensure only this test file exists - const confDir = path.join(tempDir, 'conf') - const files = fs.readdirSync(confDir) + const confDir = path.join(tempDir, 'conf'); + const files = fs.readdirSync(confDir); for (const file of files) { - fs.unlinkSync(path.join(confDir, file)) + fs.unlinkSync(path.join(confDir, file)); } - + // Write invalid JSON - fs.writeFileSync(path.join(confDir, 'invalid.json'), '{ invalid json') - - const loader = SiteConfigurationLoader.getInstance() - - await assert.rejects( - () => loader.loadConfigurations(), - /Failed to load configuration/ - ) - }) + fs.writeFileSync(path.join(confDir, 'invalid.json'), '{ invalid json'); + + const loader = SiteConfigurationLoader.getInstance(); + + await assert.rejects(() => loader.loadConfigurations(), /Failed to load configuration/); + }); await t.test('throws error for missing required properties in config', async () => { // Clean up first to ensure only this test file exists - const confDir = path.join(tempDir, 'conf') - const files = fs.readdirSync(confDir) + const confDir = path.join(tempDir, 'conf'); + const files = fs.readdirSync(confDir); for (const file of files) { - fs.unlinkSync(path.join(confDir, file)) + fs.unlinkSync(path.join(confDir, file)); } - + // Reset to local environment for this test - process.env.ENVIRONMENT = 'local' - + process.env.ENVIRONMENT = 'local'; + // Write config without required properties - fs.writeFileSync(path.join(confDir, 'missing-props.json'), JSON.stringify({ - name: 'test', - local: { - urls: { base: 'test' } - // Missing subscription, mail, and elasticProxyUrl properties - } - })) - - const loader = SiteConfigurationLoader.getInstance() - - await assert.rejects( - () => loader.loadConfigurations(), - /Invalid environment configuration/ - ) - }) + fs.writeFileSync( + path.join(confDir, 'missing-props.json'), + JSON.stringify({ + name: 'test', + local: { + urls: { base: 'test' }, + // Missing subscription, mail, and elasticProxyUrl properties + }, + }), + ); + + const loader = SiteConfigurationLoader.getInstance(); + + await assert.rejects(() => loader.loadConfigurations(), /Invalid environment configuration/); + }); await t.test('prevents multiple loadConfigurations calls', async () => { - process.env.ENVIRONMENT = 'local' - + process.env.ENVIRONMENT = 'local'; + // Clean up first to ensure we have clean test files - const confDir = path.join(tempDir, 'conf') - const files = fs.readdirSync(confDir) + const confDir = path.join(tempDir, 'conf'); + const files = fs.readdirSync(confDir); for (const file of files) { - fs.unlinkSync(path.join(confDir, file)) + fs.unlinkSync(path.join(confDir, file)); } - + // Recreate original test files - fs.writeFileSync( - path.join(confDir, 'rekry.json'), - JSON.stringify(mockRekryConfig, null, 2) - ) - - fs.writeFileSync( - path.join(confDir, 'another.json'), - JSON.stringify(mockAnotherConfig, null, 2) - ) - - const loader = SiteConfigurationLoader.getInstance() - + fs.writeFileSync(path.join(confDir, 'rekry.json'), JSON.stringify(mockRekryConfig, null, 2)); + + fs.writeFileSync(path.join(confDir, 'another.json'), JSON.stringify(mockAnotherConfig, null, 2)); + + const loader = SiteConfigurationLoader.getInstance(); + // First call should load - await loader.loadConfigurations() - const firstResult = loader.getConfigurations() - + await loader.loadConfigurations(); + const firstResult = loader.getConfigurations(); + // Second call should return immediately without reloading - await loader.loadConfigurations() - const secondResult = loader.getConfigurations() - - assert.strictEqual(firstResult, secondResult) - }) -}) + await loader.loadConfigurations(); + const secondResult = loader.getConfigurations(); + + assert.strictEqual(firstResult, secondResult); + }); +}); diff --git a/test/routes/confirmSubscription.test.ts b/test/routes/confirmSubscription.test.ts index 3b844a0..75eb95e 100644 --- a/test/routes/confirmSubscription.test.ts +++ b/test/routes/confirmSubscription.test.ts @@ -1,8 +1,8 @@ -import { describe, test } from 'node:test'; import * as assert from 'node:assert'; -import { build, createSubscription } from '../helper'; +import { describe, test } from 'node:test'; import { ObjectId } from '@fastify/mongodb'; -import { SubscriptionStatus } from "../../src/types/subscription"; +import { SubscriptionStatus } from '../../src/types/subscription'; +import { build, createSubscription } from '../helper'; describe('/subscription/confirm', () => { test('invalid subscription ID', async (t) => { @@ -55,4 +55,4 @@ describe('/subscription/confirm', () => { const updatedSubscription = await collection?.findOne({ _id: subscriptionId }); assert.strictEqual(updatedSubscription?.status, SubscriptionStatus.ACTIVE, 'Status should be ACTIVE'); }); -}) +}); diff --git a/test/routes/deleteSubscription.test.ts b/test/routes/deleteSubscription.test.ts index 6aed8c3..7567b57 100644 --- a/test/routes/deleteSubscription.test.ts +++ b/test/routes/deleteSubscription.test.ts @@ -1,7 +1,7 @@ -import { describe, test } from 'node:test'; import * as assert from 'node:assert'; -import { build, createSubscription } from '../helper'; +import { describe, test } from 'node:test'; import { ObjectId } from '@fastify/mongodb'; +import { build, createSubscription } from '../helper'; describe('/subscription/delete', () => { test('invalid subscription ID', async (t) => { @@ -54,4 +54,4 @@ describe('/subscription/delete', () => { const updatedSubscription = await collection?.findOne({ _id: subscriptionId }); assert.ok(!updatedSubscription, 'Subscription was deleted'); }); -}) +}); diff --git a/test/routes/renewSubscription.test.ts b/test/routes/renewSubscription.test.ts index 6c66b29..ceb9ac4 100644 --- a/test/routes/renewSubscription.test.ts +++ b/test/routes/renewSubscription.test.ts @@ -1,8 +1,8 @@ -import { describe, test, mock } from 'node:test'; import * as assert from 'node:assert'; -import { build, createSubscription } from '../helper'; +import { describe, mock, test } from 'node:test'; import { ObjectId } from '@fastify/mongodb'; import { SubscriptionStatus } from '../../src/types/subscription'; +import { build, createSubscription } from '../helper'; describe('/subscription/renew', () => { test('renewSubscription - invalid subscription ID', async (t) => { @@ -38,7 +38,7 @@ describe('/subscription/renew', () => { assert.strictEqual(res.statusCode, 400); assert.strictEqual(JSON.parse(res.payload).statusMessage, 'Only active subscriptions can be renewed.'); - }) + }); test('renewSubscription - successfully renews old subscription', async (t) => { const app = await build(t); @@ -86,6 +86,6 @@ describe('/subscription/renew', () => { assert.strictEqual(updated?.first_created.getTime(), oldDate.getTime(), 'Original date should be archived'); assert.strictEqual(updated?.expiry_notification_sent, 0, 'Expiry notification should be reset'); - assert.ok(atvMock.mock.callCount() >= 1) + assert.ok(atvMock.mock.callCount() >= 1); }); -}) +}); diff --git a/test/routes/root.test.ts b/test/routes/root.test.ts index f29c796..e9aa012 100644 --- a/test/routes/root.test.ts +++ b/test/routes/root.test.ts @@ -1,33 +1,33 @@ -import { test } from 'node:test' -import * as assert from 'node:assert' -import { build } from '../helper' +import * as assert from 'node:assert'; +import { test } from 'node:test'; +import { build } from '../helper'; test('default root route', async (t) => { - const app = await build(t) + const app = await build(t); const res = await app.inject({ url: '/', - headers: { token: 'test' } - }) - assert.deepStrictEqual(JSON.parse(res.payload), { root: true }) -}) + headers: { token: 'test' }, + }); + assert.deepStrictEqual(JSON.parse(res.payload), { root: true }); +}); test('/healthz', async (t) => { - const app = await build(t) + const app = await build(t); const res = await app.inject({ url: '/healthz', - }) + }); assert.strictEqual(res.statusCode, 200); -}) +}); test('/readiness', async (t) => { - const app = await build(t) + const app = await build(t); const res = await app.inject({ url: '/readiness', - }) + }); assert.strictEqual(res.statusCode, 200); -}) +}); diff --git a/test/routes/subscriptionStatus.test.ts b/test/routes/subscriptionStatus.test.ts index f395129..9c80812 100644 --- a/test/routes/subscriptionStatus.test.ts +++ b/test/routes/subscriptionStatus.test.ts @@ -1,8 +1,8 @@ -import { describe, test } from 'node:test'; import * as assert from 'node:assert'; -import { build, createSubscription } from '../helper'; +import { describe, test } from 'node:test'; import { ObjectId } from '@fastify/mongodb'; import { SubscriptionStatus } from '../../src/types/subscription'; +import { build, createSubscription } from '../helper'; describe('/subscription/status', () => { test('404 response for unknown subscription', async (t) => { @@ -22,17 +22,17 @@ describe('/subscription/status', () => { [SubscriptionStatus.INACTIVE, 'inactive'], [SubscriptionStatus.ACTIVE, 'active'], [SubscriptionStatus.DISABLED, 'disabled'], - ] as const + ] as const; const app = await build(t); const collection = app.mongo.db?.collection('subscription'); for (const [status, result] of tests) { - const hash = crypto.randomUUID() + const hash = crypto.randomUUID(); const subscriptionId = await createSubscription(collection, { status, - hash - }) + hash, + }); const res = await app.inject({ method: 'GET', @@ -43,5 +43,5 @@ describe('/subscription/status', () => { assert.strictEqual(res.statusCode, 200); assert.strictEqual(JSON.parse(res.payload).subscriptionStatus, result); } - }) -}) + }); +}); From 15ab735ec595b2be6b69b91996e97fb251180560 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 12:20:19 +0200 Subject: [PATCH 131/228] Add tests for email sending Refactor email testing to a service class so that it is possible to test --- src/bin/hav-populate-queue.ts | 1 + src/bin/hav-send-emails-in-queue.ts | 118 +++++------------------ src/lib/emailQueueService.ts | 141 ++++++++++++++++++++++++++++ test/lib/emailQueueService.test.ts | 89 ++++++++++++++++++ 4 files changed, 252 insertions(+), 97 deletions(-) create mode 100644 src/lib/emailQueueService.ts create mode 100644 test/lib/emailQueueService.test.ts diff --git a/src/bin/hav-populate-queue.ts b/src/bin/hav-populate-queue.ts index 1adb3d3..d6025f0 100644 --- a/src/bin/hav-populate-queue.ts +++ b/src/bin/hav-populate-queue.ts @@ -211,6 +211,7 @@ const processSiteSubscriptions = async ( // Add email to queue if (!isDryRun) { + // @todo: move email queue code to emailQueueService. await queueCollection.insertOne(expiryEmailToQueue); } stats.expiryEmailsQueued++; diff --git a/src/bin/hav-send-emails-in-queue.ts b/src/bin/hav-send-emails-in-queue.ts index e1e543f..3eeebb1 100644 --- a/src/bin/hav-send-emails-in-queue.ts +++ b/src/bin/hav-send-emails-in-queue.ts @@ -1,114 +1,38 @@ -import { ObjectId } from '@fastify/mongodb'; -import { JSDOM } from 'jsdom'; import command from '../lib/command'; import atv from '../plugins/atv'; import mailer from '../plugins/mailer'; import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; -import type { AtvDocumentType } from '../types/atv'; +import { EmailQueueService } from '../lib/emailQueueService'; // Command line/cron application to send all emails from queue collection -const BATCH_SIZE = 100; - command( async (server) => { - const checkInId = server.Sentry?.captureCheckIn({ monitorSlug: 'hav-send-emails-in-queue', status: 'in_progress' }); + const checkInId = server.Sentry?.captureCheckIn({ + monitorSlug: 'hav-send-emails-in-queue', + status: 'in_progress', + }); 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'); - - let hasMoreResults = true; - - while (hasMoreResults) { - // eslint-disable-next-line no-await-in-loop - 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(); - - result.forEach((email) => { - emailIdsMap.set(email.email, null); - }); - - // Get batch of email documents from ATV - const emailIds = [...emailIdsMap.keys()]; - // eslint-disable-next-line no-await-in-loop - const emailDocuments: Partial = await server.atvGetDocumentBatch(emailIds); - - // Update the email map with unencrypted email list - if (emailDocuments.length > 0) { - emailDocuments.forEach((emailDocument) => { - if (emailDocument?.id) { - emailIdsMap.set(emailDocument.id, emailDocument.content.email); - } - }); - } - - // Send emails sequentially to avoid overwhelming the system - // eslint-disable-next-line no-await-in-loop - await result.reduce(async (previousPromise, email) => { - await previousPromise; - - 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); - }, - ); - }); - } catch (error) { - // Continue even if sending email failed. - 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 new Error('Deleting email from queue failed.'); - } - - return Promise.resolve(); - }, Promise.resolve()); - } - } - - server.Sentry?.captureCheckIn({ checkInId, monitorSlug: 'hav-send-emails-in-queue', status: 'ok' }); + // Create email queue service with dependencies + const emailQueueService = new EmailQueueService({ + db: server.mongo.db, + atvClient: server, + emailSender: server.mailer, + sentry: server.Sentry, + }); + + // Process the email queue + await emailQueueService.processQueue(); + + server.Sentry?.captureCheckIn({ + checkInId, + monitorSlug: 'hav-send-emails-in-queue', + status: 'ok', + }); }, [ // Register only needed plugins diff --git a/src/lib/emailQueueService.ts b/src/lib/emailQueueService.ts new file mode 100644 index 0000000..306bc24 --- /dev/null +++ b/src/lib/emailQueueService.ts @@ -0,0 +1,141 @@ +import { ObjectId } from '@fastify/mongodb'; +import type * as Sentry from '@sentry/node'; +import type { FastifyInstance } from 'fastify'; +import { JSDOM } from 'jsdom'; +import type { Collection, Db } from 'mongodb'; +import type { FastifyMailer } from '../types/mailer'; + +export const BATCH_SIZE = 100; + +export interface EmailQueueItem { + _id: ObjectId; + email: string; // This is the ATV document ID + content: string; // HTML content +} + +export interface EmailQueueServiceDependencies { + db: Db; + atvClient: FastifyInstance; + emailSender: FastifyMailer; + sentry?: typeof Sentry; + batchSize?: number; +} + +/** + * Service for processing email queue. + * Handles fetching emails from queue, retrieving plaintext emails from ATV, + * sending emails, and removing processed items from queue. + */ +export class EmailQueueService { + private readonly queueCollection: Collection; + private readonly emailSender: FastifyMailer; + private readonly atvClient: FastifyInstance; + private readonly sentry?: typeof Sentry; + private readonly batchSize: number; + + constructor(dependencies: EmailQueueServiceDependencies) { + this.queueCollection = dependencies.db.collection('queue'); + this.atvClient = dependencies.atvClient; + this.emailSender = dependencies.emailSender; + this.sentry = dependencies.sentry; + this.batchSize = dependencies.batchSize ?? BATCH_SIZE; + } + + /** + * Process all emails in the queue in batches. + */ + async processQueue(): Promise { + let hasMoreResults = true; + + while (hasMoreResults) { + const result = (await this.queueCollection.find({}).limit(this.batchSize).toArray()) as EmailQueueItem[]; + + if (result.length === 0) { + hasMoreResults = false; + } else { + await this.processBatch(result); + } + } + } + + /** + * Process a batch of emails. + */ + private async processBatch(batch: EmailQueueItem[]): Promise { + // Collect unique email ATV IDs + const emailIdsMap = new Map(); + batch.forEach((email) => { + emailIdsMap.set(email.email, null); + }); + + // Get batch of email documents from ATV + const emailIds = [...emailIdsMap.keys()]; + const emailDocuments = await this.atvClient.atvGetDocumentBatch(emailIds); + + // Update the email map with unencrypted email addresses + if (emailDocuments.length > 0) { + emailDocuments.forEach((emailDocument) => { + if (emailDocument?.id) { + emailIdsMap.set(emailDocument.id, emailDocument.content.email); + } + }); + } + + // Send emails sequentially to avoid overwhelming the system + await batch.reduce(async (previousPromise, email) => { + await previousPromise; + + const plaintextEmail = emailIdsMap.get(email.email); + await this.sendEmail(plaintextEmail, email); + + return Promise.resolve(); + }, Promise.resolve()); + } + + private async sendEmail(plaintextEmail: string | null | undefined, item: EmailQueueItem) { + const atvId = item.email; + const dom = new JSDOM(item.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) => { + this.emailSender.sendMail( + { + to: plaintextEmail, + subject: title, + html: item.content, + }, + (errors, info) => { + if (errors) { + return reject(new Error(`Sending email to ${atvId} failed.`, { cause: errors })); + } + + return resolve(info); + }, + ); + }); + } catch (error) { + // Continue even if sending email failed. + this.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 this.queueCollection.deleteOne({ _id: new ObjectId(item._id) }); + if (deleteResult.deletedCount === 0) { + console.error(`Could not delete email document with id ${item._id} from queue`); + + throw new Error('Deleting email from queue failed.'); + } + } +} diff --git a/test/lib/emailQueueService.test.ts b/test/lib/emailQueueService.test.ts new file mode 100644 index 0000000..b2d82a3 --- /dev/null +++ b/test/lib/emailQueueService.test.ts @@ -0,0 +1,89 @@ +import * as assert from 'node:assert'; +import { after, before, beforeEach, describe, mock, test } from 'node:test'; +import { MongoClient } from 'mongodb'; +import { EmailQueueService } from '../../src/lib/emailQueueService'; +import '../../src/plugins/atv'; +import { ObjectId } from '@fastify/mongodb'; +import type { FastifyInstance } from 'fastify'; +import type { FastifyMailer } from '../../src/types/mailer'; + +describe('EmailQueueService', () => { + assert.ok(process.env.MONGODB); + const mongo = new MongoClient(process.env.MONGODB); + + const emailSender = { + sendMail: mock.fn(), + }; + + const atv = { + atvGetDocumentBatch: mock.fn(), + }; + + before(async () => { + await mongo.connect(); + }); + + after(async () => { + await mongo.close(); + }); + + beforeEach(() => { + emailSender.sendMail.mock.restore(); + atv.atvGetDocumentBatch.mock.restore(); + + // Delete all items. + mongo.db().collection('queue').deleteMany({}); + }); + + test('Sends emails correctly', { concurrency: false }, async () => { + const db = mongo.db(); + const item = await db.collection('queue').insertOne({ + _id: new ObjectId(), + email: '123', + content: 'Hello', + }); + + atv.atvGetDocumentBatch.mock.mockImplementation(() => + Promise.resolve([ + { + // Id that matches email field in queue collection. + id: '123', + tos_function_id: 'a', + tos_record_id: 'b', + content: { + email: 'test@example.com', + }, + }, + ]), + ); + + emailSender.sendMail.mock.mockImplementation((( + options: any, + callback?: (err: Error | null, info: any) => void, + ): void => { + assert.strictEqual(options.to, 'test@example.com'); + + callback?.(null, { + messageId: 'test-id', + }); + }) as any); + + const sut = new EmailQueueService({ + db, + atvClient: atv as any, + emailSender: emailSender as any, + }); + + await sut.processQueue(); + + // Assert that email was sent. + assert.ok(emailSender.sendMail.mock.callCount() >= 1); + + const result = await db.collection('queue').findOne({ + _id: item.insertedId, + }); + + // Assert that item was deleted. + assert.ok(result === null); + }); +}); From 9708fba63dd2af7b24ded921d9a9d7dfccc6eff3 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 12:21:04 +0200 Subject: [PATCH 132/228] Remove env variables that should not be changed from .env file --- .env.dist | 4 ---- compose.yaml | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.env.dist b/.env.dist index f7b197a..951fc19 100644 --- a/.env.dist +++ b/.env.dist @@ -1,9 +1,5 @@ # Environment configuration ENVIRONMENT=local -FASTIFY_PORT=3000 - -# Database configuration -MONGODB=mongodb://mongodb:27017/hakuvahti # Sentry error monitoring SENTRY_DSN= diff --git a/compose.yaml b/compose.yaml index 49e654d..114d203 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,6 +15,9 @@ services: dockerfile: openshift/Dockerfile target: development hostname: hakuvahti + environment: + FASTIFY_PORT: 3000 + MONGODB: mongodb://mongodb:27017/hakuvahti volumes: - .:/app:delegated - node_modules:/app/node_modules From 906940dddb078a0ca129bc6e8f74d8e2c90c843e Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 12:21:25 +0200 Subject: [PATCH 133/228] Misc comments --- src/plugins/atv.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/plugins/atv.ts b/src/plugins/atv.ts index 28a735d..2eb2306 100644 --- a/src/plugins/atv.ts +++ b/src/plugins/atv.ts @@ -9,8 +9,8 @@ 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 { @@ -37,9 +37,9 @@ const atvFetchContentById = async (atvDocumentId: string): Promise>} the created document + * @param email - the email to be included in the document + * @param sms - optional SMS to be included in the document + * @return the created document */ const atvCreateDocumentWithEmail = async (email: string, sms?: string): Promise> => { try { @@ -85,9 +85,9 @@ const atvCreateDocumentWithEmail = async (email: string, sms?: string): Promise< * Updates the delete_after timestamp for an ATV document. * Fetches the existing document first to preserve all content and required fields. * - * @param {string} atvDocumentId - The id of the ATV document to update - * @param {number} maxAge - The number of days until deletion (defaults to SUBSCRIPTION_MAX_AGE env var or 90) - * @return {Promise>} The updated document + * @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) + * @return The updated document */ const atvUpdateDocumentDeleteAfter = async ( atvDocumentId: string, @@ -141,8 +141,8 @@ const atvUpdateDocumentDeleteAfter = async ( /** * Retrieves a batch of documents for the given emails. * - * @param {string[]} emails - The array of document ids for which to retrieve documents - * @return {Promise>} A promise that resolves with a partial array of AtvDocumentType objects + * @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 atvGetDocumentBatch = async (emails: string[]): Promise> => { try { @@ -171,8 +171,7 @@ const atvGetDocumentBatch = async (emails: string[]): Promise { try { @@ -208,6 +207,10 @@ const requestEmailHook = async (request: FastifyRequestType) => { } }; +// @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) => { // Hook handler automatically creates ATV document for the email // and sets the returned documentId to atvResponse.email variable From 319a9e5781077039ab4f930db72c464c0802fa63 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 12:22:11 +0200 Subject: [PATCH 134/228] Simplify signature, rely on type inference --- src/plugins/mailer.ts | 3 +-- src/plugins/mongodb.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plugins/mailer.ts b/src/plugins/mailer.ts index c24e4d3..68f6596 100644 --- a/src/plugins/mailer.ts +++ b/src/plugins/mailer.ts @@ -1,10 +1,9 @@ -import type { FastifyInstance } from 'fastify'; import fp from 'fastify-plugin'; import type { FastifyMailer } from '../types/mailer'; // Initialize mailer as plugin -export default fp(async function mailerPlugin(fastify: FastifyInstance) { +export default fp(async function mailerPlugin(fastify) { const opts = { defaults: { from: process.env.MAIL_FROM, diff --git a/src/plugins/mongodb.ts b/src/plugins/mongodb.ts index 4697ec1..100c61b 100644 --- a/src/plugins/mongodb.ts +++ b/src/plugins/mongodb.ts @@ -1,10 +1,9 @@ import mongo from '@fastify/mongodb'; -import type { FastifyInstance } from 'fastify'; import fp from 'fastify-plugin'; // MongoDB connection -export default fp(async function mongodbPlugin(fastify: FastifyInstance) { +export default fp(async function mongodbPlugin(fastify) { fastify.register(mongo, { url: process.env.MONGODB, forceClose: true, From 85e9b1a596764b8cfd118b00a1e5d564001d05ba Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 13:04:00 +0200 Subject: [PATCH 135/228] Add tests for sms sending --- src/bin/hav-send-sms-in-queue.ts | 89 ++++---------------------- src/lib/baseQueueService.ts | 45 +++++++++++++ src/lib/emailQueueService.ts | 33 ++-------- src/lib/smsQueueService.ts | 105 +++++++++++++++++++++++++++++++ test/lib/smsQueueService.test.ts | 84 +++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 104 deletions(-) create mode 100644 src/lib/baseQueueService.ts create mode 100644 src/lib/smsQueueService.ts create mode 100644 test/lib/smsQueueService.test.ts diff --git a/src/bin/hav-send-sms-in-queue.ts b/src/bin/hav-send-sms-in-queue.ts index ace3e34..363ea53 100644 --- a/src/bin/hav-send-sms-in-queue.ts +++ b/src/bin/hav-send-sms-in-queue.ts @@ -1,14 +1,11 @@ -import { ObjectId } from '@fastify/mongodb'; import command from '../lib/command'; import atv from '../plugins/atv'; import dialogi from '../plugins/dialogi'; import mongodb from '../plugins/mongodb'; import '../plugins/sentry'; -import type { AtvDocumentType } from '../types/atv'; +import { SmsQueueService } from '../lib/smsQueueService'; // Command line/cron application to send all SMS from queue collection -const BATCH_SIZE = 100; - command( async (server) => { const checkInId = server.Sentry?.captureCheckIn({ @@ -16,82 +13,20 @@ command( status: 'in_progress', }); - const db = server.mongo?.db; - if (!db) { - throw new Error('MongoDB connection not available'); + if (typeof server.mongo?.db === 'undefined') { + throw new Error('MongoDB connection not working'); } - const smsQueueCollection = db.collection('smsqueue'); - let hasMoreResults = true; - - while (hasMoreResults) { - // eslint-disable-next-line no-await-in-loop - const batch = await smsQueueCollection.find({}).limit(BATCH_SIZE).toArray(); - - if (batch.length === 0) { - hasMoreResults = false; - break; - } - - // Collect unique ATV document IDs - const atvIds = [...new Set(batch.map((item) => item.sms))]; - - // Get SMS phone numbers from ATV in batch - // eslint-disable-next-line no-await-in-loop - const atvDocuments: Partial = await server.atvGetDocumentBatch(atvIds); - - // Create map of ATV ID -> phone number - const phoneNumberMap = new Map(); - atvDocuments.forEach((doc) => { - if (doc?.id && doc?.content) { - try { - const content = JSON.parse(doc.content); - if (content.sms) { - phoneNumberMap.set(doc.id, content.sms); - } - } catch (error) { - console.error(`Failed to parse ATV document ${doc.id}:`, error); - } - } - }); - - // Process SMS messages sequentially - // eslint-disable-next-line no-await-in-loop - await batch.reduce(async (previousPromise, smsItem) => { - await previousPromise; - - const atvId = smsItem.sms; - const phoneNumber = phoneNumberMap.get(atvId); - const messageContent = smsItem.content; - - console.info('Processing SMS for ATV ID:', atvId); - - if (phoneNumber) { - try { - // Send SMS using Dialogi plugin - await server.dialogi.sendSms(phoneNumber, messageContent); - console.log(`SMS sent successfully for ATV ID: ${atvId}`); - } catch (error) { - // Log error but continue processing queue - server.Sentry?.captureException(error); - console.error(`Failed to send SMS for ATV ID ${atvId}:`, error); - } - } else { - console.warn(`Phone number not found for ATV ID ${atvId}`); - } - - // Remove from queue regardless of send status - const deleteResult = await smsQueueCollection.deleteOne({ - _id: new ObjectId(smsItem._id), - }); - - if (deleteResult.deletedCount === 0) { - console.error(`Failed to delete SMS queue item ${smsItem._id}`); - } + // Create SMS queue service with dependencies + const smsQueueService = new SmsQueueService({ + db: server.mongo.db, + atvClient: server, + smsSender: server.dialogi, + sentry: server.Sentry, + }); - return Promise.resolve(); - }, Promise.resolve()); - } + // Process the SMS queue + await smsQueueService.processQueue(); server.Sentry?.captureCheckIn({ checkInId, diff --git a/src/lib/baseQueueService.ts b/src/lib/baseQueueService.ts new file mode 100644 index 0000000..8df92c3 --- /dev/null +++ b/src/lib/baseQueueService.ts @@ -0,0 +1,45 @@ +import type { ObjectId } from '@fastify/mongodb'; +import type { Collection } from 'mongodb'; + +export const BATCH_SIZE = 100; + +export interface BaseQueueItem { + _id: ObjectId; +} + +/** + * Base class for queue processing services. + * Implements the common pattern of fetching items in batches and processing them. + */ +export abstract class BaseQueueService { + protected readonly queueCollection: Collection; + protected readonly batchSize: number; + + protected constructor(queueCollection: Collection, batchSize = BATCH_SIZE) { + this.queueCollection = queueCollection; + this.batchSize = batchSize; + } + + /** + * Process all items in the queue in batches. + */ + async processQueue(): Promise { + let hasMoreResults = true; + + while (hasMoreResults) { + const result = (await this.queueCollection.find({}).limit(this.batchSize).toArray()) as T[]; + + if (result.length === 0) { + hasMoreResults = false; + } else { + await this.processBatch(result); + } + } + } + + /** + * Process a batch of items from the queue. + * Must be implemented by subclasses to define specific processing logic. + */ + protected abstract processBatch(batch: T[]): Promise; +} diff --git a/src/lib/emailQueueService.ts b/src/lib/emailQueueService.ts index 306bc24..f2e9fd0 100644 --- a/src/lib/emailQueueService.ts +++ b/src/lib/emailQueueService.ts @@ -2,12 +2,11 @@ import { ObjectId } from '@fastify/mongodb'; import type * as Sentry from '@sentry/node'; import type { FastifyInstance } from 'fastify'; import { JSDOM } from 'jsdom'; -import type { Collection, Db } from 'mongodb'; +import type { Db } from 'mongodb'; import type { FastifyMailer } from '../types/mailer'; +import { type BaseQueueItem, BaseQueueService } from './baseQueueService'; -export const BATCH_SIZE = 100; - -export interface EmailQueueItem { +export interface EmailQueueItem extends BaseQueueItem { _id: ObjectId; email: string; // This is the ATV document ID content: string; // HTML content @@ -26,42 +25,22 @@ export interface EmailQueueServiceDependencies { * Handles fetching emails from queue, retrieving plaintext emails from ATV, * sending emails, and removing processed items from queue. */ -export class EmailQueueService { - private readonly queueCollection: Collection; +export class EmailQueueService extends BaseQueueService { private readonly emailSender: FastifyMailer; private readonly atvClient: FastifyInstance; private readonly sentry?: typeof Sentry; - private readonly batchSize: number; constructor(dependencies: EmailQueueServiceDependencies) { - this.queueCollection = dependencies.db.collection('queue'); + super(dependencies.db.collection('queue'), dependencies.batchSize); this.atvClient = dependencies.atvClient; this.emailSender = dependencies.emailSender; this.sentry = dependencies.sentry; - this.batchSize = dependencies.batchSize ?? BATCH_SIZE; - } - - /** - * Process all emails in the queue in batches. - */ - async processQueue(): Promise { - let hasMoreResults = true; - - while (hasMoreResults) { - const result = (await this.queueCollection.find({}).limit(this.batchSize).toArray()) as EmailQueueItem[]; - - if (result.length === 0) { - hasMoreResults = false; - } else { - await this.processBatch(result); - } - } } /** * Process a batch of emails. */ - private async processBatch(batch: EmailQueueItem[]): Promise { + protected async processBatch(batch: EmailQueueItem[]): Promise { // Collect unique email ATV IDs const emailIdsMap = new Map(); batch.forEach((email) => { diff --git a/src/lib/smsQueueService.ts b/src/lib/smsQueueService.ts new file mode 100644 index 0000000..ed88ba2 --- /dev/null +++ b/src/lib/smsQueueService.ts @@ -0,0 +1,105 @@ +import { ObjectId } from '@fastify/mongodb'; +import type * as Sentry from '@sentry/node'; +import type { FastifyInstance } from 'fastify'; +import type { Db } from 'mongodb'; +import type { DialogiClient } from '../plugins/dialogi'; +import type { AtvDocumentType } from '../types/atv'; +import { type BaseQueueItem, BaseQueueService } from './baseQueueService'; + +export interface SmsQueueItem extends BaseQueueItem { + _id: ObjectId; + sms: string; // This is the ATV document ID + content: string; // SMS message content +} + +export interface SmsQueueServiceDependencies { + db: Db; + atvClient: FastifyInstance; + smsSender: DialogiClient; + sentry?: typeof Sentry; + batchSize?: number; +} + +/** + * Service for processing SMS queue. + * Handles fetching SMS from queue, retrieving phone numbers from ATV, + * sending SMS, and removing processed items from queue. + */ +export class SmsQueueService extends BaseQueueService { + private readonly smsSender: DialogiClient; + private readonly atvClient: FastifyInstance; + private readonly sentry?: typeof Sentry; + + constructor(dependencies: SmsQueueServiceDependencies) { + super(dependencies.db.collection('smsqueue'), dependencies.batchSize); + this.atvClient = dependencies.atvClient; + this.smsSender = dependencies.smsSender; + this.sentry = dependencies.sentry; + } + + /** + * Process a batch of SMS messages. + */ + protected async processBatch(batch: SmsQueueItem[]): Promise { + // Collect unique ATV document IDs + const atvIds = [...new Set(batch.map((item) => item.sms))]; + + // Get SMS phone numbers from ATV in batch + const atvDocuments: Partial = await this.atvClient.atvGetDocumentBatch(atvIds); + + // Create map of ATV ID -> phone number + const phoneNumberMap = new Map(); + atvDocuments.forEach((doc) => { + if (doc?.id && doc?.content) { + try { + const content = JSON.parse(doc.content); + if (content.sms) { + phoneNumberMap.set(doc.id, content.sms); + } + } catch (error) { + console.error(`Failed to parse ATV document ${doc.id}:`, error); + } + } + }); + + // Process SMS messages sequentially + await batch.reduce(async (previousPromise, smsItem) => { + await previousPromise; + + const atvId = smsItem.sms; + const phoneNumber = phoneNumberMap.get(atvId); + const messageContent = smsItem.content; + + await this.sendSms(phoneNumber, messageContent, atvId, smsItem); + + return Promise.resolve(); + }, Promise.resolve()); + } + + private async sendSms(phoneNumber: string | undefined, messageContent: string, atvId: string, item: SmsQueueItem) { + console.info('Processing SMS for ATV ID:', atvId); + + if (phoneNumber) { + try { + await this.smsSender.sendSms(phoneNumber, messageContent); + console.log(`SMS sent successfully for ATV ID: ${atvId}`); + } catch (error) { + // Log error but continue processing queue + this.sentry?.captureException(error); + console.error(`Failed to send SMS for ATV ID ${atvId}:`, error); + } + } else { + console.warn(`Phone number not found for ATV ID ${atvId}`); + } + + // Remove from queue regardless of send status + const deleteResult = await this.queueCollection.deleteOne({ + _id: new ObjectId(item._id), + }); + + if (deleteResult.deletedCount === 0) { + console.error(`Failed to delete SMS queue item ${item._id}`); + throw new Error('Deleting SMS from queue failed.'); + } + } +} diff --git a/test/lib/smsQueueService.test.ts b/test/lib/smsQueueService.test.ts new file mode 100644 index 0000000..8e824e7 --- /dev/null +++ b/test/lib/smsQueueService.test.ts @@ -0,0 +1,84 @@ +import * as assert from 'node:assert'; +import { after, before, beforeEach, describe, mock, test } from 'node:test'; +import { MongoClient } from 'mongodb'; +import { SmsQueueService } from '../../src/lib/smsQueueService'; +import '../../src/plugins/atv'; +import { ObjectId } from '@fastify/mongodb'; +import type { FastifyInstance } from 'fastify'; + +describe('SmsQueueService', () => { + assert.ok(process.env.MONGODB); + const mongo = new MongoClient(process.env.MONGODB); + + const smsSender = { + sendSms: mock.fn<(phoneNumber: string, message: string) => Promise>(), + }; + + const atv = { + atvGetDocumentBatch: mock.fn(), + }; + + before(async () => { + await mongo.connect(); + }); + + after(async () => { + await mongo.close(); + }); + + beforeEach(() => { + smsSender.sendSms.mock.restore(); + atv.atvGetDocumentBatch.mock.restore(); + + // Delete all items. + mongo.db().collection('smsqueue').deleteMany({}); + }); + + test('Sends SMS correctly', { concurrency: false }, async () => { + const db = mongo.db(); + const item = await db.collection('smsqueue').insertOne({ + _id: new ObjectId(), + sms: '123', + content: 'Hello, this is a test SMS message', + }); + + atv.atvGetDocumentBatch.mock.mockImplementation(() => + Promise.resolve([ + { + // Id that matches sms field in smsqueue collection. + id: '123', + tos_function_id: 'a', + tos_record_id: 'b', + content: JSON.stringify({ + sms: '+358401234567', + }), + }, + ]), + ); + + smsSender.sendSms.mock.mockImplementation((phoneNumber: string, message: string): Promise => { + assert.strictEqual(phoneNumber, '+358401234567'); + assert.strictEqual(message, 'Hello, this is a test SMS message'); + + return Promise.resolve(); + }); + + const sut = new SmsQueueService({ + db, + atvClient: atv as any, + smsSender: smsSender as any, + }); + + await sut.processQueue(); + + // Assert that SMS was sent. + assert.ok(smsSender.sendSms.mock.callCount() >= 1); + + const result = await db.collection('smsqueue').findOne({ + _id: item.insertedId, + }); + + // Assert that item was deleted. + assert.ok(result === null); + }); +}); From 43873af776e20b9b36e83ae2523c4c0a182e0af5 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Tue, 18 Nov 2025 13:06:53 +0200 Subject: [PATCH 136/228] Various comments --- test/lib/emailQueueService.test.ts | 4 ++-- test/lib/smsQueueService.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/lib/emailQueueService.test.ts b/test/lib/emailQueueService.test.ts index b2d82a3..9f160b9 100644 --- a/test/lib/emailQueueService.test.ts +++ b/test/lib/emailQueueService.test.ts @@ -61,7 +61,7 @@ describe('EmailQueueService', () => { options: any, callback?: (err: Error | null, info: any) => void, ): void => { - assert.strictEqual(options.to, 'test@example.com'); + assert.strictEqual(options.to, 'test@example.com', 'Email To matches the expected value'); callback?.(null, { messageId: 'test-id', @@ -84,6 +84,6 @@ describe('EmailQueueService', () => { }); // Assert that item was deleted. - assert.ok(result === null); + assert.ok(result === null, "Queue item was deleted"); }); }); diff --git a/test/lib/smsQueueService.test.ts b/test/lib/smsQueueService.test.ts index 8e824e7..f24244c 100644 --- a/test/lib/smsQueueService.test.ts +++ b/test/lib/smsQueueService.test.ts @@ -57,7 +57,7 @@ describe('SmsQueueService', () => { ); smsSender.sendSms.mock.mockImplementation((phoneNumber: string, message: string): Promise => { - assert.strictEqual(phoneNumber, '+358401234567'); + assert.strictEqual(phoneNumber, '+358401234567', 'SMS recipient matches the expected value'); assert.strictEqual(message, 'Hello, this is a test SMS message'); return Promise.resolve(); @@ -79,6 +79,6 @@ describe('SmsQueueService', () => { }); // Assert that item was deleted. - assert.ok(result === null); + assert.ok(result === null, 'Queue item was deleted'); }); }); From 5cb1da649a0b4be015254e8489d699d731d383a8 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Wed, 19 Nov 2025 15:41:09 +0200 Subject: [PATCH 137/228] Fix race condition in the tests --- test/lib/emailQueueService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/lib/emailQueueService.test.ts b/test/lib/emailQueueService.test.ts index 9f160b9..d9386fb 100644 --- a/test/lib/emailQueueService.test.ts +++ b/test/lib/emailQueueService.test.ts @@ -27,12 +27,12 @@ describe('EmailQueueService', () => { await mongo.close(); }); - beforeEach(() => { + beforeEach(async () => { emailSender.sendMail.mock.restore(); atv.atvGetDocumentBatch.mock.restore(); // Delete all items. - mongo.db().collection('queue').deleteMany({}); + await mongo.db().collection('queue').deleteMany({}); }); test('Sends emails correctly', { concurrency: false }, async () => { From 2f9f44948128eba1e44addd70ce6d9706871095e Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Wed, 19 Nov 2025 15:43:26 +0200 Subject: [PATCH 138/228] Fix sms test too --- test/lib/smsQueueService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/lib/smsQueueService.test.ts b/test/lib/smsQueueService.test.ts index f24244c..15919eb 100644 --- a/test/lib/smsQueueService.test.ts +++ b/test/lib/smsQueueService.test.ts @@ -26,12 +26,12 @@ describe('SmsQueueService', () => { await mongo.close(); }); - beforeEach(() => { + beforeEach(async () => { smsSender.sendSms.mock.restore(); atv.atvGetDocumentBatch.mock.restore(); // Delete all items. - mongo.db().collection('smsqueue').deleteMany({}); + await mongo.db().collection('smsqueue').deleteMany({}); }); test('Sends SMS correctly', { concurrency: false }, async () => { From 7b251b61512ab95c5c00b9a90e682374b9415bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tarmo=20Sundstr=C3=B6m?= Date: Mon, 17 Nov 2025 14:48:23 +0200 Subject: [PATCH 139/228] Add email translations and template system improvements - Add translations object to rekry.json config with email subjects and instructions in fi/en/sv - Implement translation helper functions in email.ts (translate, buildTranslationContext) - Update wrapWithLayout to accept title parameter and pass translations to templates - Add date variables (year, month, day) to layout context - Update all email functions to use translated subjects - Add translations support to SiteConfiguration type and loader - Add Helsinki logo SVG to footer with responsive stacking layout - Update footer to include copyright with dynamic year variable and instructions link - Convert text links to styled button components with black background - Add responsive CSS for footer branding on mobile - Export template functions so tests can use them - Add tests for email templates and translations - Refactor all translation strings to json translations and make unified template for all languages. --- conf/rekry.json | 112 ++++++++++++++++++ src/lib/email.ts | 105 +++++++++++++--- src/lib/siteConfigurationLoader.ts | 3 + src/templates/index.html | 12 -- src/templates/rekry/confirmation.html | 16 +++ src/templates/rekry/confirmation_en.html | 22 ---- src/templates/rekry/confirmation_fi.html | 22 ---- src/templates/rekry/confirmation_sv.html | 22 ---- src/templates/rekry/expiry_notification.html | 19 +++ .../rekry/expiry_notification_en.html | 22 ---- .../rekry/expiry_notification_fi.html | 22 ---- .../rekry/expiry_notification_sv.html | 22 ---- src/templates/rekry/index.html | 95 +++++++++++++++ src/templates/rekry/newhits.html | 20 ++++ src/templates/rekry/newhits_en.html | 25 ---- src/templates/rekry/newhits_fi.html | 25 ---- src/templates/rekry/newhits_sv.html | 25 ---- src/templates/rekry/sms/sms-en.html | 1 - src/templates/rekry/sms/sms-fi.html | 1 - src/templates/rekry/sms/sms-sv.html | 1 - src/templates/rekry/sms/sms.html | 1 + src/templates/text.html | 1 - src/types/siteConfig.ts | 12 ++ test/lib/email.test.ts | 104 ++++++++++++++++ 24 files changed, 470 insertions(+), 240 deletions(-) delete mode 100644 src/templates/index.html create mode 100644 src/templates/rekry/confirmation.html delete mode 100644 src/templates/rekry/confirmation_en.html delete mode 100644 src/templates/rekry/confirmation_fi.html delete mode 100644 src/templates/rekry/confirmation_sv.html create mode 100644 src/templates/rekry/expiry_notification.html delete mode 100644 src/templates/rekry/expiry_notification_en.html delete mode 100644 src/templates/rekry/expiry_notification_fi.html delete mode 100644 src/templates/rekry/expiry_notification_sv.html create mode 100644 src/templates/rekry/index.html create mode 100644 src/templates/rekry/newhits.html delete mode 100644 src/templates/rekry/newhits_en.html delete mode 100644 src/templates/rekry/newhits_fi.html delete mode 100644 src/templates/rekry/newhits_sv.html delete mode 100644 src/templates/rekry/sms/sms-en.html delete mode 100644 src/templates/rekry/sms/sms-fi.html delete mode 100644 src/templates/rekry/sms/sms-sv.html create mode 100644 src/templates/rekry/sms/sms.html delete mode 100644 src/templates/text.html create mode 100644 test/lib/email.test.ts diff --git a/conf/rekry.json b/conf/rekry.json index 52463fe..5444c60 100644 --- a/conf/rekry.json +++ b/conf/rekry.json @@ -1,5 +1,117 @@ { "name": "rekry", + "translations": { + "email_subject_confirmation": { + "fi": "Vahvista hakuvahdin tilaus sivustolta hel.fi", + "en": "Confirm a saved search on hel.fi", + "sv": "Bekräfta beställningen av en sökvakt på webbplatsen till hel.fi" + }, + "email_subject_expiry": { + "fi": "Hakuvahtisi on vanhentumassa", + "en": "Your saved search is about to expire", + "sv": "Tiden för din sökvakt håller på att gå ut" + }, + "email_subject_newhits": { + "fi": "Uusia hakuvahtiosumia", + "en": "New search matches", + "sv": "Nya träffar med sökvakten" + }, + "instructions_text": { + "fi": "Työpaikkojen hakuvahdin käyttöohjeet", + "en": "Job alert instructions", + "sv": "Anvisningar för jobbevakningen" + }, + "instructions_link": { + "fi": "https://www.hel.fi/fi/", + "en": "https://www.hel.fi/en", + "sv": "https://www.hel.fi/ev" + }, + "email_confirmation_greeting": { + "fi": "Hei!", + "en": "Hi!", + "sv": "Hej!" + }, + "email_confirmation_intro": { + "fi": "Tälle sähköpostille luotiin hakuvahti Helsingin kaupungin verkkosivustolla. Vahvista hakuvahdin tilaus klikkaamalla alla olevaa linkkiä:", + "en": "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:", + "sv": "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:" + }, + "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 huomioimatta.", + "en": "If you did not save a search, you can ignore this message.", + "sv": "Om du inte har beställt sökvakten, kan strunta i det här epostmeddelandet." + }, + "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_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_newhits_intro_prefix": { + "fi": "Löysimme uusia osumia hakuvahdillasi. Hakuehdot:", + "en": "We found new matches for your saved search. Search criteria:", + "sv": "Vi hittade nya träffar med din sökvakt. Sökvillkor:" + }, + "email_newhits_button": { + "fi": "Katso kaikki hakuvahtitulokset", + "en": "See all results", + "sv": "Se alla sökvaktens resultat" + }, + "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", diff --git a/src/lib/email.ts b/src/lib/email.ts index 71de188..4d96c0c 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -3,16 +3,75 @@ import type { PartialDrupalNodeType } from '../types/elasticproxy'; import type { SiteConfigurationType } from '../types/siteConfig'; import type { SubscriptionCollectionLanguageType } from '../types/subscription'; +const TEMPLATE_BASE_PATH = 'dist/templates'; + +export const translate = ( + key: string, + lang: SubscriptionCollectionLanguageType, + siteConfig: SiteConfigurationType, +): string => siteConfig.translations?.[key]?.[lang] ?? ''; + +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 month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + const layoutData: SprightlyContext = { + ...translations, + lang, + title, + content: innerContent, + year, + month, + day, + }; + + return sprightly(`${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/index.html`, layoutData); +}; + // Subscription confirmation email export const confirmationEmail = async ( lang: SubscriptionCollectionLanguageType, data: { link: string }, siteConfig: SiteConfigurationType, ) => - sprightly(`dist/templates/${siteConfig.mail.templatePath}/confirmation_${lang}.html`, { + wrapWithLayout( + `${TEMPLATE_BASE_PATH}/${siteConfig.mail.templatePath}/confirmation.html`, + { + lang, + link: data.link, + }, lang, - link: data.link, - }); + translate('email_subject_confirmation', lang, siteConfig), + siteConfig, + ); // Notification before subscription expires export const expiryEmail = async ( @@ -25,13 +84,19 @@ export const expiryEmail = async ( }, siteConfig: SiteConfigurationType, ) => - sprightly(`dist/templates/${siteConfig.mail.templatePath}/expiry_notification_${lang}.html`, { + 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, + }, lang, - link: data.link, - search_description: data.search_description, - remove_link: data.remove_link, - removal_date: data.removal_date, - }); + translate('email_subject_expiry', lang, siteConfig), + siteConfig, + ); // Email with list of new search monitor hits export const newHitsEmail = async ( @@ -55,14 +120,20 @@ export const newHitsEmail = async ( ) .join(''); - return sprightly(`dist/templates/${siteConfig.mail.templatePath}/newhits_${lang}.html`, { + 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, + }, 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, - }); + translate('email_subject_newhits', lang, siteConfig), + siteConfig, + ); } catch (error) { console.error(error); throw error; @@ -78,7 +149,7 @@ export const newHitsSms = async ( }, siteConfig: SiteConfigurationType, ) => - sprightly(`dist/templates/${siteConfig.mail.templatePath}/sms/sms-${lang}.html`, { + sprightly(`dist/templates/${siteConfig.mail.templatePath}/sms/sms.html`, { lang, search_description: data.search_description, search_link: siteConfig.urls.base + data.search_link, diff --git a/src/lib/siteConfigurationLoader.ts b/src/lib/siteConfigurationLoader.ts index 5f5aad4..033d28c 100644 --- a/src/lib/siteConfigurationLoader.ts +++ b/src/lib/siteConfigurationLoader.ts @@ -72,6 +72,8 @@ export class SiteConfigurationLoader { throw new Error(`Invalid environment configuration for '${environment}' in ${filePath}`); } + const translations = rawConfig.translations ?? undefined; + // Flatten to runtime configuration this.configurations[siteId] = { id: siteId, @@ -80,6 +82,7 @@ export class SiteConfigurationLoader { subscription: envConfig.subscription, mail: envConfig.mail, elasticProxyUrl: envConfig.elasticProxyUrl, + translations, }; } catch (error) { throw new Error(`Failed to load configuration from ${filePath}: ${error}`); 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/rekry/confirmation.html b/src/templates/rekry/confirmation.html new file mode 100644 index 0000000..1e6f804 --- /dev/null +++ b/src/templates/rekry/confirmation.html @@ -0,0 +1,16 @@ +

{{ email_confirmation_greeting }}

+

{{ email_confirmation_intro }}

+ + + + +
+ + {{ email_confirmation_button }} + +
+

{{ email_confirmation_ignore }}

+

+

{{ email_generic_kind_regards }}

+

{{ email_generic_signature }}

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..ed04232 --- /dev/null +++ b/src/templates/rekry/expiry_notification.html @@ -0,0 +1,19 @@ +

+ {{ email_expiry_intro_before_date }} {{ removal_date }}, {{ email_expiry_intro_after_date }} + {{ email_expiry_intro_before_search_description }} {{ search_description }}{{ email_expiry_intro_after_search_description }} +

+

+ + + + +
+ + {{ email_expiry_button }} + +
+

+

{{ email_generic_kind_regards }}

+

{{ email_generic_signature }}

+

{{ email_generic_remove_link }}

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..eae10e8 --- /dev/null +++ b/src/templates/rekry/index.html @@ -0,0 +1,95 @@ + + + + + + + {{ title }} + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ {{ content }} +
+ + + + + + + + +

+ {{ instructions_text }} +

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

{{ email_newhits_intro_prefix }} {{ search_description }}, {{ created_date }}

+

+{{ hits }} +

+ + + + +
+ + {{ email_newhits_button }} + +
+

+

{{ email_generic_kind_regards }}

+

{{ email_generic_signature }}

+

{{ email_newhits_do_not_reply }}

+

---

+

{{ email_generic_remove_link }}

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/sms-en.html b/src/templates/rekry/sms/sms-en.html deleted file mode 100644 index 9a5ca83..0000000 --- a/src/templates/rekry/sms/sms-en.html +++ /dev/null @@ -1 +0,0 @@ -Search alert: New results for "{{ search_description }}". View results: {{ search_link }} diff --git a/src/templates/rekry/sms/sms-fi.html b/src/templates/rekry/sms/sms-fi.html deleted file mode 100644 index a47847d..0000000 --- a/src/templates/rekry/sms/sms-fi.html +++ /dev/null @@ -1 +0,0 @@ -Hakuvahti: Uusia tuloksia haulle "{{ search_description }}". Katso tulokset: {{ search_link }} diff --git a/src/templates/rekry/sms/sms-sv.html b/src/templates/rekry/sms/sms-sv.html deleted file mode 100644 index b1f9af2..0000000 --- a/src/templates/rekry/sms/sms-sv.html +++ /dev/null @@ -1 +0,0 @@ -Sökbevakning: Nya resultat för sökningen "{{ search_description }}". Se resultat: {{ 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/siteConfig.ts b/src/types/siteConfig.ts index 9b9d351..bb8ea3a 100644 --- a/src/types/siteConfig.ts +++ b/src/types/siteConfig.ts @@ -8,6 +8,16 @@ export const SiteLanguageUrls = Type.Object({ }); 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(), @@ -32,6 +42,7 @@ export type SiteEnvironmentConfigType = Static; export const SiteConfigurationFile = Type.Object( { name: Type.String(), + translations: Type.Optional(TranslationMap), }, { additionalProperties: SiteEnvironmentConfig }, ); @@ -44,6 +55,7 @@ export const SiteConfiguration = Type.Object({ subscription: SiteSubscriptionSettings, mail: SiteMailSettings, elasticProxyUrl: Type.String(), + translations: Type.Optional(TranslationMap), }); export type SiteConfigurationType = Static; export const SiteConfigurationMap = Type.Record(Type.String(), SiteConfiguration); diff --git a/test/lib/email.test.ts b/test/lib/email.test.ts new file mode 100644 index 0000000..599ccbb --- /dev/null +++ b/test/lib/email.test.ts @@ -0,0 +1,104 @@ +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, /