diff --git a/eslint.config.mjs b/eslint.config.mjs index 1d6a1be..df28404 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,9 +19,7 @@ export default tseslint.config( }, sourceType: 'commonjs', parserOptions: { - projectService: { - allowDefaultProject: ['src/**/*.spec.ts', 'test/**/*.ts'], - }, + projectService: true, tsconfigRootDir: import.meta.dirname, }, }, @@ -33,4 +31,4 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-argument': 'warn' }, }, -); \ No newline at end of file +); diff --git a/libs/shared-communication/test/tsconfig.json b/libs/shared-communication/test/tsconfig.json new file mode 100644 index 0000000..c51da56 --- /dev/null +++ b/libs/shared-communication/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": ["./**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/microservices/sms-service/.env.example b/microservices/sms-service/.env.example index 2df5954..130fbd3 100644 --- a/microservices/sms-service/.env.example +++ b/microservices/sms-service/.env.example @@ -1,25 +1,40 @@ NODE_ENV=development -SERVICE_PORT=3010 +SERVICE_PORT=3007 +SERVICE_VERSION=1.0.0 -DB_HOST=localhost +DB_TYPE=postgres +DB_HOST=127.0.0.1 DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=sms_service -DB_SYNCHRONIZE=true +DB_SYNC=true -SMS_PROVIDER=console -SMS_DEFAULT_FROM=Quest +SMS_PROVIDER=mock +SMS_DEFAULT_COUNTRY=US +SMS_SENDER_ID=Quest +SMS_STATUS_CALLBACK_URL=http://localhost:3007/receipts/webhook +SMS_RATE_LIMIT_WINDOW_MINUTES=10 +SMS_RATE_LIMIT_MAX_PER_WINDOW=20 +SMS_OTP_RATE_LIMIT_WINDOW_MINUTES=10 +SMS_OTP_RATE_LIMIT_MAX_PER_WINDOW=3 +SMS_DISPATCH_INTERVAL_MS=5000 +SMS_DISPATCH_BATCH_SIZE=25 +SMS_RETRY_BASE_DELAY_MS=60000 +SMS_MAX_RETRIES=3 +SMS_OTP_LENGTH=6 +SMS_OTP_EXPIRY_MINUTES=10 +SMS_OTP_MAX_ATTEMPTS=5 +SMS_OTP_SECRET=replace-me +SMS_OTP_TEMPLATE_NAME=otp-verification +SMS_DEBUG_EXPOSE_CODES=false TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_FROM_NUMBER= -TWILIO_STATUS_CALLBACK_URL= +TWILIO_MESSAGING_SERVICE_SID= AWS_REGION=us-east-1 -AWS_SNS_SENDER_ID=Quest - -OTP_TTL_SECONDS=300 -OTP_LENGTH=6 -SMS_RATE_LIMIT_MAX=5 -SMS_RATE_LIMIT_WINDOW_SECONDS=60 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_SNS_SENDER_ID= diff --git a/microservices/sms-service/.gitignore b/microservices/sms-service/.gitignore new file mode 100644 index 0000000..90a3928 --- /dev/null +++ b/microservices/sms-service/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +.env +coverage diff --git a/microservices/sms-service/.prettierrc b/microservices/sms-service/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/microservices/sms-service/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/microservices/sms-service/Dockerfile b/microservices/sms-service/Dockerfile new file mode 100644 index 0000000..5afbe07 --- /dev/null +++ b/microservices/sms-service/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +COPY tsconfig*.json ./ +COPY nest-cli.json ./ + +RUN npm ci + +COPY src ./src + +RUN npm run build + +FROM node:18-alpine + +WORKDIR /app + +RUN apk add --no-cache dumb-init + +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +COPY --from=builder /app/dist ./dist + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 + +USER nodejs + +EXPOSE 3007 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3007/health || exit 1 + +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/main.js"] diff --git a/microservices/sms-service/README.md b/microservices/sms-service/README.md index b1a99b0..ed4d819 100644 --- a/microservices/sms-service/README.md +++ b/microservices/sms-service/README.md @@ -1,45 +1,123 @@ # SMS Service -NestJS microservice for sending verification codes, alerts, and time-sensitive text messages. +A NestJS microservice for sending SMS verification codes, alerts, reminders, and other time-sensitive notifications. ## Features -- SMS, message, and delivery receipt entities backed by TypeORM/PostgreSQL -- Provider abstraction with local console sending and Twilio HTTP support -- Secure OTP generation with hashed verification codes and expiry -- Handlebars message templates -- Scheduled message dispatch through `@nestjs/schedule` -- Delivery receipt webhook endpoint -- Phone validation, per-phone rate limiting, history, and analytics -- Docker and docker-compose configuration +- Twilio, AWS SNS, and built-in mock provider support +- Secure OTP generation and verification +- Message templates with Handlebars rendering +- Scheduled delivery with automatic retry backoff +- Delivery receipts and webhook/manual confirmation endpoints +- Phone number normalization and validation +- Message history and analytics +- Rate limiting per phone number and stricter OTP throttling -## Local Run +## Quick Start ```bash cd microservices/sms-service npm install +cp .env.example .env npm run start:dev ``` -The default `SMS_PROVIDER=console` logs outbound SMS messages and marks them sent. Set `SMS_PROVIDER=twilio` with `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_FROM_NUMBER` to send real SMS. +The default configuration uses the `mock` provider, so the service can run locally without Twilio or AWS credentials. -## API +## Key Environment Variables -- `GET /health` -- `POST /sms/send` -- `POST /sms/send-template` -- `POST /sms/otp` -- `POST /sms/otp/verify` -- `POST /sms/receipts` -- `GET /sms` -- `GET /sms/:id` -- `POST /sms/:id/cancel` -- `GET /sms-analytics` -- `GET /sms-templates` +| Variable | Description | Default | +|---|---|---| +| `SERVICE_PORT` | HTTP port | `3007` | +| `DB_TYPE` | `postgres` or `sqljs` | `postgres` | +| `SMS_PROVIDER` | `mock`, `twilio`, or `sns` | `mock` | +| `SMS_DEFAULT_COUNTRY` | Phone parsing fallback region | `US` | +| `SMS_DISPATCH_INTERVAL_MS` | Poll interval for scheduled/retry messages | `5000` | +| `SMS_RATE_LIMIT_MAX_PER_WINDOW` | Max SMS per phone per window | `20` | +| `SMS_OTP_RATE_LIMIT_MAX_PER_WINDOW` | Max OTP sends per phone per window | `3` | +| `SMS_OTP_EXPIRY_MINUTES` | OTP expiration | `10` | +| `SMS_DEBUG_EXPOSE_CODES` | Include OTP codes in responses for local testing | `false` | -## Docker +## Main Endpoints + +### Service + +- `GET /` - service info +- `GET /health` - health check + +### SMS + +- `POST /sms/send` - send plain SMS +- `POST /sms/send-templated` - send SMS from template +- `GET /sms/history` - list message history +- `GET /sms/stats` - aggregated delivery analytics +- `POST /sms/:id/cancel` - cancel pending/scheduled SMS +- `POST /sms/:id/retry` - retry failed SMS + +### Templates + +- `POST /templates` - create template +- `GET /templates` - list templates +- `GET /templates/:id` - fetch template +- `GET /templates/name/:name` - fetch template by name +- `PUT /templates/:id` - update template +- `DELETE /templates/:id` - delete template +- `POST /templates/render` - render a template payload + +### OTP + +- `POST /otp/send` - generate and send verification code +- `POST /otp/verify` - verify a code + +### Receipts + +- `POST /receipts/confirm` - manually confirm/update delivery status +- `POST /receipts/webhook` - provider webhook target +- `GET /receipts/message/:messageId` - list receipt events for a message + +## Example Requests + +### Send an Alert ```bash -cd microservices/sms-service -docker compose -f docker/docker-compose.yml up --build +curl -X POST http://localhost:3007/sms/send \ + -H "Content-Type: application/json" \ + -d '{ + "phoneNumber": "+15555550123", + "body": "Your Quest account password was changed.", + "type": "alert", + "priority": "high" + }' ``` + +### Create a Template + +```bash +curl -X POST http://localhost:3007/templates \ + -H "Content-Type: application/json" \ + -d '{ + "name": "otp-verification", + "body": "Your {{purpose}} code is {{code}}. It expires in {{expiryMinutes}} minutes.", + "category": "otp" + }' +``` + +### Send OTP + +```bash +curl -X POST http://localhost:3007/otp/send \ + -H "Content-Type: application/json" \ + -d '{ + "phoneNumber": "+15555550123", + "purpose": "login" + }' +``` + +## Testing + +```bash +npm run build +npm run test:e2e +``` + +The e2e test suite runs against `sqljs` and the mock provider, so it does not require PostgreSQL or a real SMS account. diff --git a/microservices/sms-service/docker/docker-compose.yml b/microservices/sms-service/docker/docker-compose.yml index cf5b7b5..4ca7bed 100644 --- a/microservices/sms-service/docker/docker-compose.yml +++ b/microservices/sms-service/docker/docker-compose.yml @@ -5,27 +5,32 @@ services: container_name: sms-service build: context: .. - dockerfile: docker/Dockerfile + dockerfile: Dockerfile ports: - - '3010:3010' + - '3007:3007' environment: - NODE_ENV=development - - SERVICE_PORT=3010 + - SERVICE_PORT=3007 + - DB_TYPE=postgres - DB_HOST=postgres - DB_PORT=5432 - DB_USER=${DB_USER:-postgres} - DB_PASSWORD=${DB_PASSWORD:-postgres} - DB_NAME=${DB_NAME:-sms_service} - - SMS_PROVIDER=${SMS_PROVIDER:-console} - - SMS_DEFAULT_FROM=${SMS_DEFAULT_FROM:-Quest} + - DB_SYNC=true + - SMS_PROVIDER=${SMS_PROVIDER:-mock} + - SMS_DEFAULT_COUNTRY=${SMS_DEFAULT_COUNTRY:-US} + - SMS_SENDER_ID=${SMS_SENDER_ID:-Quest} + - SMS_STATUS_CALLBACK_URL=${SMS_STATUS_CALLBACK_URL:-http://localhost:3007/receipts/webhook} + - SMS_OTP_SECRET=${SMS_OTP_SECRET:-replace-me} - TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID:-} - TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN:-} - TWILIO_FROM_NUMBER=${TWILIO_FROM_NUMBER:-} - - TWILIO_STATUS_CALLBACK_URL=${TWILIO_STATUS_CALLBACK_URL:-} - - OTP_TTL_SECONDS=${OTP_TTL_SECONDS:-300} - - OTP_LENGTH=${OTP_LENGTH:-6} - - SMS_RATE_LIMIT_MAX=${SMS_RATE_LIMIT_MAX:-5} - - SMS_RATE_LIMIT_WINDOW_SECONDS=${SMS_RATE_LIMIT_WINDOW_SECONDS:-60} + - TWILIO_MESSAGING_SERVICE_SID=${TWILIO_MESSAGING_SERVICE_SID:-} + - AWS_REGION=${AWS_REGION:-us-east-1} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} + - AWS_SNS_SENDER_ID=${AWS_SNS_SENDER_ID:-Quest} depends_on: postgres: condition: service_healthy @@ -39,19 +44,18 @@ services: environment: - POSTGRES_USER=${DB_USER:-postgres} - POSTGRES_PASSWORD=${DB_PASSWORD:-postgres} - - POSTGRES_DB=sms_service + - POSTGRES_DB=${DB_NAME:-sms_service} ports: - - '5440:5432' + - '5437:5432' volumes: - postgres_sms_data:/var/lib/postgresql/data - - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - networks: - - sms-network healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 + networks: + - sms-network restart: unless-stopped volumes: diff --git a/microservices/sms-service/eslint.config.mjs b/microservices/sms-service/eslint.config.mjs index ecbcf9d..ac67076 100644 --- a/microservices/sms-service/eslint.config.mjs +++ b/microservices/sms-service/eslint.config.mjs @@ -1,8 +1,8 @@ import js from '@eslint/js'; +import tseslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; -import tsPlugin from '@typescript-eslint/eslint-plugin'; -import prettierPlugin from 'eslint-plugin-prettier'; -import configPrettier from 'eslint-config-prettier'; +import prettier from 'eslint-plugin-prettier'; +import globals from 'globals'; export default [ js.configs.recommended, @@ -12,31 +12,27 @@ export default [ parser: tsParser, parserOptions: { project: './tsconfig.json', - tsconfigRootDir: import.meta.dirname, sourceType: 'module', }, globals: { - console: 'readonly', - process: 'readonly', - Buffer: 'readonly', - fetch: 'readonly', - describe: 'readonly', - beforeEach: 'readonly', - it: 'readonly', - expect: 'readonly', - jest: 'readonly', + ...globals.node, + ...globals.jest, }, }, plugins: { - '@typescript-eslint': tsPlugin, - prettier: prettierPlugin, + '@typescript-eslint': tseslint, + prettier, }, rules: { - ...tsPlugin.configs.recommended.rules, - 'prettier/prettier': 'error', + ...tseslint.configs.recommended.rules, '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + }, + ], + 'prettier/prettier': 'error', }, }, - configPrettier, ]; diff --git a/microservices/sms-service/package-lock.json b/microservices/sms-service/package-lock.json index 5d41c2f..d2fa7c4 100644 --- a/microservices/sms-service/package-lock.json +++ b/microservices/sms-service/package-lock.json @@ -9,22 +9,24 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@aws-sdk/client-sns": "^3.450.0", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.4", "@nestjs/platform-express": "^10.4.4", - "@nestjs/schedule": "^6.1.1", - "@nestjs/throttler": "^5.2.0", - "@nestjs/typeorm": "^10.0.0", + "@nestjs/typeorm": "^11.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^17.2.3", "handlebars": "^4.7.8", + "libphonenumber-js": "^1.12.40", "pg": "^8.13.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sql.js": "^1.13.0", + "twilio": "^5.5.3", "typeorm": "^0.3.20", - "uuid": "^9.0.0" + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.4.5", @@ -34,12 +36,12 @@ "@types/jest": "^29.5.12", "@types/node": "^20.14.15", "@types/supertest": "^6.0.2", - "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "globals": "^15.11.0", "jest": "^29.7.0", "prettier": "^3.3.3", "source-map-support": "^0.5.21", @@ -211,6 +213,343 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-sns": { + "version": "3.1076.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.1076.0.tgz", + "integrity": "sha512-YBeGgRAFH840l2HeLWx8OPXzzIJEWFWo2nofyJO/TgztR9Nwkkn8mM+6W5NEf6baeRlvgH5ejHekEX1OspkSug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/credential-provider-node": "^3.972.59", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/fetch-http-handler": "^5.6.0", + "@smithy/node-http-handler": "^4.9.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.24.tgz", + "integrity": "sha512-vWB/qJl21vxGKBkBN8fKPTVXgm14v/bUQWTtR5oikrfAZbIN2bxuSiCY5rRAMR4gs3vtR2Vw0aTfVDU4tdfIPg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.14", + "@aws-sdk/xml-builder": "^3.972.32", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.27.0", + "@smithy/signature-v4": "^5.5.3", + "@smithy/types": "^4.15.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.50.tgz", + "integrity": "sha512-l8bWzhPFTi9tDcvtURxeMlfsboul5/0sEN3SwwXxdpYudVB9+EuQcxo2pwlTzXwDo4Gm2VLGyiZ8zti3nfdOLw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.52.tgz", + "integrity": "sha512-FjAlnsIvemWzO3JTM3ObuuxpqCyrqkXOewlYY2+NiR1MYO1JuFYSIJ8SJN5Q2KD1jkL5lIuab8awjb/AxsvjiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/fetch-http-handler": "^5.6.0", + "@smithy/node-http-handler": "^4.9.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.57.tgz", + "integrity": "sha512-8qwNhQ0sK/1KaOpVEFC7TFxrWP3fxzJV1K049MzjouiMIbvTDvIGDEUtj5ND5aTmlHVK/YZxjoYnLCeV/GZU0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/credential-provider-env": "^3.972.50", + "@aws-sdk/credential-provider-http": "^3.972.52", + "@aws-sdk/credential-provider-login": "^3.972.56", + "@aws-sdk/credential-provider-process": "^3.972.50", + "@aws-sdk/credential-provider-sso": "^3.972.56", + "@aws-sdk/credential-provider-web-identity": "^3.972.56", + "@aws-sdk/nested-clients": "^3.997.24", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/credential-provider-imds": "^4.4.3", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.56.tgz", + "integrity": "sha512-S36dCrDaafakFMlaCVGAF4advbQKoJuMcyMtNWVBpUz65uqhbIAsUfvAyp+djA+jkzaEfgZGd+AELjIGzTqyhw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/nested-clients": "^3.997.24", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.59.tgz", + "integrity": "sha512-LkczBXaEsdManijlEZwbKfEoo1C98Yri3LHF8gQI7CYWv+uFkmpS3OZH3BSew8g1A2ppKsScdPUSlhI6NV7a9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.50", + "@aws-sdk/credential-provider-http": "^3.972.52", + "@aws-sdk/credential-provider-ini": "^3.972.57", + "@aws-sdk/credential-provider-process": "^3.972.50", + "@aws-sdk/credential-provider-sso": "^3.972.56", + "@aws-sdk/credential-provider-web-identity": "^3.972.56", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/credential-provider-imds": "^4.4.3", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.50.tgz", + "integrity": "sha512-ARBEVkOQzmowTU0a35smGVyldJ9FN/f57XIGrPatrul4mYN+vvOKxoc1njDOX3nugVze+0sHzQZWJ8kPARAtUA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.56.tgz", + "integrity": "sha512-LvbWiFcLI/D5RPaT68TrpLLHyv7x5X+dm59wJ5dFizyGPZggBC7OdgJTlP0X1bVjiSSAgE1u1oxxcBps0GCEnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/nested-clients": "^3.997.24", + "@aws-sdk/token-providers": "3.1076.0", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.56.tgz", + "integrity": "sha512-OV3JxmqMphVGMLWupYD2UhZxX07ATk1NwyYk7RgCnAEh0y3owHmtEnkWZ3ciCZ6liiFEwS8dYQpJGmKsR6ml4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/nested-clients": "^3.997.24", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.24.tgz", + "integrity": "sha512-+wFVfVofxeiXdRhUjRwYISB2mVfBCdiCq1wThkRipTeOc10Kyr+LS9QJTjgZuhWsna7jyLMPndrCnzLGWWvZXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/signature-v4-multi-region": "^3.996.36", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/fetch-http-handler": "^5.6.0", + "@smithy/node-http-handler": "^4.9.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.36.tgz", + "integrity": "sha512-VSOWIPkI+g3a7NkxIBCO24HnsR0BZXJAi3wrKaGIZwVKyrMtNRdHxPrQI/igazgla5J9FhDzmg4RgnOSr6UQBw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.14", + "@smithy/signature-v4": "^5.5.3", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1076.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1076.0.tgz", + "integrity": "sha512-4rTHETRKe2JWAsFUMo5ENmlzc3i9FD4KqBVXgoaF8DLTADjGid8SA+1LR2nJWjefoafvKAHcQH9F2iKa8uHc6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.24", + "@aws-sdk/nested-clients": "^3.997.24", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.27.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.14.tgz", + "integrity": "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.32.tgz", + "integrity": "sha512-2loKuOMRFDg1nwdni5AtJ9S5juVbRNPNsPC7tWTfkHyycPwACMhxepspUHi8GhvfNlL2cQo3sPMod1uib+KZ0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -752,7 +1091,7 @@ "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, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -765,7 +1104,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -860,6 +1199,22 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/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, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -890,6 +1245,19 @@ "node": "*" } }, + "node_modules/@eslint/eslintrc/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, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", @@ -1107,9 +1475,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -1553,7 +1921,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1574,7 +1942,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1864,19 +2232,6 @@ "@nestjs/core": "^10.0.0" } }, - "node_modules/@nestjs/schedule": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", - "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", - "license": "MIT", - "dependencies": { - "cron": "4.4.0" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/core": "^10.0.0 || ^11.0.0" - } - }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -1929,31 +2284,17 @@ } } }, - "node_modules/@nestjs/throttler": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-5.2.0.tgz", - "integrity": "sha512-G/G/MV3xf6sy1DwmnJsgeL+d2tQ/xGRNa9ZhZjm9Kyxp+3+ylGzwJtcnhWlN82PMEp3TiDQpTt+9waOIg/bpPg==", - "license": "MIT", - "peerDependencies": { - "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", - "reflect-metadata": "^0.1.13 || ^0.2.0" - } - }, "node_modules/@nestjs/typeorm": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", - "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.3.tgz", + "integrity": "sha512-zJ+E5l7auVVA7c0PsvcMdyvRPKTUqU5s2ToYmOA2QEsXQ42qbUGtK4+1HlRfpHqBkCSXP+phiH4luvf9DyJNog==", "license": "MIT", - "dependencies": { - "uuid": "9.0.1" - }, "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", "reflect-metadata": "^0.1.13 || ^0.2.0", "rxjs": "^7.2.0", - "typeorm": "^0.3.0" + "typeorm": "^0.3.0 || ^1.0.0-dev" } }, "node_modules/@noble/hashes": { @@ -2085,6 +2426,125 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/core": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.28.0.tgz", + "integrity": "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.4.tgz", + "integrity": "sha512-jT0WrDaM88L5na9FX1xRNywCS3B1n75wPY5Ksasjo0PHUtuI7d8FclksN1BbOSYTiaiKxUDqU23nUymH/V+AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.1.tgz", + "integrity": "sha512-fW6l9rWoyk1iyzfuZaERnZLNjB6WIojgGm6Bo9Hpfpy3RUpltjLikNlxTsS/YtxVobcfbCGBuAncREYqT4hvqQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.1.tgz", + "integrity": "sha512-m/f15di58P6NtLQ7eVEb5N19NdJWn+4c7zfkFHMT/i3JH7U8UtknpPoy8o2tm2R3OdliYvsvQhZHIfACQDqT+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.0.tgz", + "integrity": "sha512-IkPHQdbyoebSwBCuMTzJ/2oIhKVqiZZAZxQYSlpDZqq/WhJUpmdgbHvP7ItddxsPzcDUJeI0V4PNMSNtlZ0aqA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -2119,28 +2579,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -2333,12 +2793,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", - "license": "MIT" - }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2357,7 +2811,7 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2441,13 +2895,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -2472,17 +2919,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", - "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/type-utils": "8.62.0", - "@typescript-eslint/utils": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2495,22 +2942,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.0", + "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", - "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "engines": { @@ -2526,14 +2973,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", - "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.0", - "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "engines": { @@ -2548,14 +2995,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", - "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2566,9 +3013,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", - "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", "dev": true, "license": "MIT", "engines": { @@ -2583,15 +3030,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", - "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2608,9 +3055,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", - "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -2622,16 +3069,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", - "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.0", - "@typescript-eslint/tsconfig-utils": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2650,16 +3097,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", - "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0" + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2674,13 +3121,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", - "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2903,7 +3350,7 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2912,6 +3359,20 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -2926,7 +3387,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -2935,6 +3396,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -3009,19 +3482,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3101,7 +3561,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -3135,7 +3595,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -3153,6 +3612,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3310,9 +3781,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3386,10 +3857,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3494,6 +3971,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3849,7 +4332,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4046,26 +4528,9 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, - "node_modules/cron": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", - "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", - "license": "MIT", - "dependencies": { - "@types/luxon": "~3.7.0", - "luxon": "~3.7.0" - }, - "engines": { - "node": ">=18.x" - }, - "funding": { - "type": "ko-fi", - "url": "https://ko-fi.com/intcreator" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4168,7 +4633,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4218,7 +4682,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -4288,6 +4752,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4295,9 +4768,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.378", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", - "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", "dev": true, "license": "ISC" }, @@ -4330,9 +4803,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", - "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", + "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", "dev": true, "license": "MIT", "dependencies": { @@ -4371,6 +4844,14 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4387,7 +4868,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4606,6 +5086,22 @@ "node": ">=10.13.0" } }, + "node_modules/eslint/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, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4636,6 +5132,19 @@ "node": "*" } }, + "node_modules/eslint/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, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -5092,6 +5601,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "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", @@ -5187,7 +5716,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5442,16 +5970,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5610,6 +6135,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -6772,9 +7310,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -6855,17 +7393,60 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" } }, "node_modules/keyv": { @@ -6961,6 +7542,42 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6975,6 +7592,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7002,15 +7625,6 @@ "yallist": "^3.0.2" } }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -7044,7 +7658,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -7208,6 +7822,122 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -7327,9 +8057,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.49", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.49.tgz", - "integrity": "sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "license": "MIT", "engines": { @@ -7890,9 +8620,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.3.tgz", + "integrity": "sha512-HWmu+K+zvHNpaMfSnYeqdqrDbR16cuIXaPx8WoHaviQkDJh1/0BNtOZmHVQI5jc3wXv0H1yXc9wjvFdXh+n3hQ==", "dev": true, "license": "MIT", "bin": { @@ -7973,6 +8703,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8431,11 +9170,17 @@ "dev": true, "license": "MIT" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8726,6 +9471,12 @@ "node": ">=14" } }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9456,7 +10207,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -9543,6 +10294,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/twilio": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.13.1.tgz", + "integrity": "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.3", + "qs": "^6.14.1", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9567,9 +10336,9 @@ } }, "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==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -9818,7 +10587,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9869,7 +10638,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -9965,7 +10734,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -10040,6 +10809,53 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/webpack": { + "version": "5.108.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.3.tgz", + "integrity": "sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.2", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/webpack-node-externals": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", @@ -10060,6 +10876,64 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -10183,6 +11057,15 @@ "dev": true, "license": "ISC" }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -10239,7 +11122,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/microservices/sms-service/package.json b/microservices/sms-service/package.json index 83a9011..5a1fb62 100644 --- a/microservices/sms-service/package.json +++ b/microservices/sms-service/package.json @@ -1,7 +1,7 @@ { "name": "sms-service", "version": "0.0.1", - "description": "SMS and text messaging microservice for verification codes, alerts, and time-sensitive notifications", + "description": "SMS and text messaging service for OTPs, alerts, and time-sensitive notifications", "author": "", "private": true, "license": "UNLICENSED", @@ -17,25 +17,28 @@ "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@aws-sdk/client-sns": "^3.450.0", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.4", "@nestjs/platform-express": "^10.4.4", - "@nestjs/schedule": "^6.1.1", - "@nestjs/throttler": "^5.2.0", - "@nestjs/typeorm": "^10.0.0", + "@nestjs/typeorm": "^11.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^17.2.3", "handlebars": "^4.7.8", + "libphonenumber-js": "^1.12.40", "pg": "^8.13.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sql.js": "^1.13.0", + "twilio": "^5.5.3", "typeorm": "^0.3.20", - "uuid": "^9.0.0" + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.4.5", @@ -45,12 +48,12 @@ "@types/jest": "^29.5.12", "@types/node": "^20.14.15", "@types/supertest": "^6.0.2", - "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "globals": "^15.11.0", "jest": "^29.7.0", "prettier": "^3.3.3", "source-map-support": "^0.5.21", diff --git a/microservices/sms-service/src/app.controller.ts b/microservices/sms-service/src/app.controller.ts new file mode 100644 index 0000000..2d394fb --- /dev/null +++ b/microservices/sms-service/src/app.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getInfo() { + return this.appService.getServiceInfo(); + } + + @Get('health') + healthCheck() { + return this.appService.healthCheck(); + } +} diff --git a/microservices/sms-service/src/app.module.ts b/microservices/sms-service/src/app.module.ts index 6f6bf1b..8be3618 100644 --- a/microservices/sms-service/src/app.module.ts +++ b/microservices/sms-service/src/app.module.ts @@ -1,53 +1,38 @@ import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; -import { APP_GUARD } from '@nestjs/core'; +import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm/dist/interfaces/typeorm-options.interface'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { buildDataSourceOptions } from './config/orm-config'; import smsConfig from './config/sms.config'; +import { OtpModule } from './otp/otp.module'; +import { ProvidersModule } from './providers/providers.module'; +import { ReceiptsModule } from './receipts/receipts.module'; import { SmsModule } from './sms/sms.module'; -import { Sms } from './sms/entities/sms.entity'; -import { Message } from './sms/entities/message.entity'; -import { Receipt } from './sms/entities/receipt.entity'; +import { TemplatesModule } from './templates/templates.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [smsConfig], + envFilePath: ['.env'], }), TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - type: 'postgres', - host: configService.get('sms.database.host'), - port: configService.get('sms.database.port'), - username: configService.get('sms.database.user'), - password: configService.get('sms.database.password'), - database: configService.get('sms.database.name'), - entities: [Sms, Message, Receipt], - synchronize: configService.get('sms.database.synchronize'), - }), - inject: [ConfigService], + useFactory: async () => + ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + }) as TypeOrmModuleOptions, }), - ThrottlerModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (configService: ConfigService) => [ - { - ttl: configService.get('sms.rateLimit.windowSeconds'), - limit: configService.get('sms.rateLimit.max'), - }, - ], - }), - ScheduleModule.forRoot(), + ProvidersModule, + TemplatesModule, + ReceiptsModule, SmsModule, + OtpModule, ], - providers: [ - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, - ], + controllers: [AppController], + providers: [AppService], }) export class AppModule {} diff --git a/microservices/sms-service/src/app.service.ts b/microservices/sms-service/src/app.service.ts new file mode 100644 index 0000000..9dba054 --- /dev/null +++ b/microservices/sms-service/src/app.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AppService { + constructor(private readonly configService: ConfigService) {} + + getServiceInfo() { + return { + name: 'sms-service', + version: this.configService.get('SERVICE_VERSION', '1.0.0'), + description: + 'SMS and text messaging service for verification codes, alerts, and scheduled notifications', + }; + } + + healthCheck() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: process.memoryUsage(), + }; + } +} diff --git a/microservices/sms-service/src/config/orm-config.ts b/microservices/sms-service/src/config/orm-config.ts new file mode 100644 index 0000000..4749904 --- /dev/null +++ b/microservices/sms-service/src/config/orm-config.ts @@ -0,0 +1,40 @@ +import { DataSource, DataSourceOptions } from 'typeorm'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.join(__dirname, '../../.env') }); + +const entityGlob = path.join(__dirname, '../**/*.entity.{ts,js}'); + +export function buildDataSourceOptions( + env: NodeJS.ProcessEnv = process.env, + includeEntityGlob = false, +): DataSourceOptions { + const type = env.DB_TYPE || 'postgres'; + const common = includeEntityGlob ? { entities: [entityGlob] } : {}; + + if (type === 'sqljs') { + return { + type: 'sqljs', + autoSave: false, + location: env.DB_NAME || 'sms-service', + synchronize: true, + logging: false, + ...common, + }; + } + + return { + type: 'postgres', + host: env.DB_HOST || '127.0.0.1', + port: parseInt(env.DB_PORT || '5432', 10), + username: env.DB_USER || 'postgres', + password: env.DB_PASSWORD || 'postgres', + database: env.DB_NAME || 'sms_service', + synchronize: (env.DB_SYNC || 'true') === 'true', + logging: env.NODE_ENV === 'development', + ...common, + }; +} + +export const AppDataSource = new DataSource(buildDataSourceOptions(process.env, true)); diff --git a/microservices/sms-service/src/config/sms.config.ts b/microservices/sms-service/src/config/sms.config.ts index 0c574c0..abc8343 100644 --- a/microservices/sms-service/src/config/sms.config.ts +++ b/microservices/sms-service/src/config/sms.config.ts @@ -1,35 +1,51 @@ -export default () => ({ - sms: { - provider: process.env.SMS_PROVIDER || 'console', - defaultFrom: process.env.SMS_DEFAULT_FROM || 'Quest', - database: { - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432', 10), - user: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - name: process.env.DB_NAME || 'sms_service', - synchronize: process.env.DB_SYNCHRONIZE !== 'false', - }, - twilio: { - accountSid: process.env.TWILIO_ACCOUNT_SID, - authToken: process.env.TWILIO_AUTH_TOKEN, - fromNumber: process.env.TWILIO_FROM_NUMBER, - statusCallbackUrl: process.env.TWILIO_STATUS_CALLBACK_URL, - }, - aws: { - region: process.env.AWS_REGION || 'us-east-1', - senderId: process.env.AWS_SNS_SENDER_ID || 'Quest', - }, - otp: { - ttlSeconds: parseInt(process.env.OTP_TTL_SECONDS || '300', 10), - length: parseInt(process.env.OTP_LENGTH || '6', 10), - }, - rateLimit: { - max: parseInt(process.env.SMS_RATE_LIMIT_MAX || '5', 10), - windowSeconds: parseInt( - process.env.SMS_RATE_LIMIT_WINDOW_SECONDS || '60', - 10, - ), - }, +import { registerAs } from '@nestjs/config'; + +export default registerAs('sms', () => ({ + provider: process.env.SMS_PROVIDER || 'mock', + defaultCountry: process.env.SMS_DEFAULT_COUNTRY || 'US', + senderId: process.env.SMS_SENDER_ID || 'Quest', + statusCallbackUrl: process.env.SMS_STATUS_CALLBACK_URL, + debugExposeCodes: process.env.SMS_DEBUG_EXPOSE_CODES === 'true', + + rateLimit: { + windowMinutes: parseInt(process.env.SMS_RATE_LIMIT_WINDOW_MINUTES || '10', 10), + maxPerWindow: parseInt(process.env.SMS_RATE_LIMIT_MAX_PER_WINDOW || '20', 10), + otpWindowMinutes: parseInt( + process.env.SMS_OTP_RATE_LIMIT_WINDOW_MINUTES || '10', + 10, + ), + otpMaxPerWindow: parseInt( + process.env.SMS_OTP_RATE_LIMIT_MAX_PER_WINDOW || '3', + 10, + ), }, -}); + + dispatch: { + intervalMs: parseInt(process.env.SMS_DISPATCH_INTERVAL_MS || '5000', 10), + batchSize: parseInt(process.env.SMS_DISPATCH_BATCH_SIZE || '25', 10), + retryBaseDelayMs: parseInt(process.env.SMS_RETRY_BASE_DELAY_MS || '60000', 10), + maxRetries: parseInt(process.env.SMS_MAX_RETRIES || '3', 10), + }, + + otp: { + length: parseInt(process.env.SMS_OTP_LENGTH || '6', 10), + expiryMinutes: parseInt(process.env.SMS_OTP_EXPIRY_MINUTES || '10', 10), + maxAttempts: parseInt(process.env.SMS_OTP_MAX_ATTEMPTS || '5', 10), + secret: process.env.SMS_OTP_SECRET || 'sms-otp-secret', + templateName: process.env.SMS_OTP_TEMPLATE_NAME || 'otp-verification', + }, + + twilio: { + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + fromNumber: process.env.TWILIO_FROM_NUMBER, + messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID, + }, + + sns: { + region: process.env.AWS_REGION || 'us-east-1', + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + senderId: process.env.AWS_SNS_SENDER_ID || process.env.SMS_SENDER_ID || 'Quest', + }, +})); diff --git a/microservices/sms-service/src/main.ts b/microservices/sms-service/src/main.ts index 9b79583..97867c6 100644 --- a/microservices/sms-service/src/main.ts +++ b/microservices/sms-service/src/main.ts @@ -4,6 +4,7 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -12,8 +13,11 @@ async function bootstrap() { }), ); - const port = process.env.SERVICE_PORT || 3010; - await app.listen(port); + app.enableCors(); + + const port = process.env.SERVICE_PORT || 3007; + await app.listen(port, '0.0.0.0'); + console.log(`SMS Service is running on port ${port}`); } bootstrap(); diff --git a/microservices/sms-service/src/otp/dto/index.ts b/microservices/sms-service/src/otp/dto/index.ts new file mode 100644 index 0000000..4cb0afa --- /dev/null +++ b/microservices/sms-service/src/otp/dto/index.ts @@ -0,0 +1 @@ +export * from './otp.dto'; diff --git a/microservices/sms-service/src/otp/dto/otp.dto.ts b/microservices/sms-service/src/otp/dto/otp.dto.ts new file mode 100644 index 0000000..e94b5d3 --- /dev/null +++ b/microservices/sms-service/src/otp/dto/otp.dto.ts @@ -0,0 +1,33 @@ +import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; + +export class SendOtpDto { + @IsString() + @IsNotEmpty() + phoneNumber: string; + + @IsString() + @IsNotEmpty() + purpose: string; + + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class VerifyOtpDto { + @IsString() + @IsNotEmpty() + phoneNumber: string; + + @IsString() + @IsNotEmpty() + purpose: string; + + @IsString() + @IsNotEmpty() + code: string; +} diff --git a/microservices/sms-service/src/otp/entities/otp-code.entity.ts b/microservices/sms-service/src/otp/entities/otp-code.entity.ts new file mode 100644 index 0000000..2b81574 --- /dev/null +++ b/microservices/sms-service/src/otp/entities/otp-code.entity.ts @@ -0,0 +1,54 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'otp_codes' }) +@Index(['normalizedPhoneNumber', 'purpose', 'createdAt']) +export class OtpCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + phoneNumber: string; + + @Column() + normalizedPhoneNumber: string; + + @Column({ nullable: true }) + userId: string; + + @Column() + purpose: string; + + @Column() + codeHash: string; + + @Column({ default: 0 }) + attempts: number; + + @Column({ default: 5 }) + maxAttempts: number; + + @Column() + expiresAt: Date; + + @Column({ nullable: true }) + verifiedAt: Date; + + @Column({ nullable: true }) + lastAttemptAt: Date; + + @Column({ type: 'simple-json', nullable: true }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/microservices/sms-service/src/otp/otp.controller.ts b/microservices/sms-service/src/otp/otp.controller.ts new file mode 100644 index 0000000..8fa2275 --- /dev/null +++ b/microservices/sms-service/src/otp/otp.controller.ts @@ -0,0 +1,18 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { SendOtpDto, VerifyOtpDto } from './dto'; +import { OtpService } from './otp.service'; + +@Controller('otp') +export class OtpController { + constructor(private readonly otpService: OtpService) {} + + @Post('send') + send(@Body() dto: SendOtpDto) { + return this.otpService.sendOtp(dto); + } + + @Post('verify') + verify(@Body() dto: VerifyOtpDto) { + return this.otpService.verifyOtp(dto); + } +} diff --git a/microservices/sms-service/src/otp/otp.module.ts b/microservices/sms-service/src/otp/otp.module.ts new file mode 100644 index 0000000..3e42434 --- /dev/null +++ b/microservices/sms-service/src/otp/otp.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SmsModule } from '../sms/sms.module'; +import { TemplatesModule } from '../templates/templates.module'; +import { OtpController } from './otp.controller'; +import { OtpService } from './otp.service'; +import { OtpCode } from './entities/otp-code.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([OtpCode]), SmsModule, TemplatesModule], + controllers: [OtpController], + providers: [OtpService], +}) +export class OtpModule {} diff --git a/microservices/sms-service/src/otp/otp.service.ts b/microservices/sms-service/src/otp/otp.service.ts new file mode 100644 index 0000000..09d5aaf --- /dev/null +++ b/microservices/sms-service/src/otp/otp.service.ts @@ -0,0 +1,195 @@ +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { createHash, randomInt } from 'crypto'; +import { Repository } from 'typeorm'; +import { SmsMessageType } from '../sms/entities/sms-message.entity'; +import { PhoneNumberService } from '../sms/phone-number.service'; +import { SmsService } from '../sms/sms.service'; +import { TemplatesService } from '../templates/templates.service'; +import { SendOtpDto, VerifyOtpDto } from './dto'; +import { OtpCode } from './entities/otp-code.entity'; + +@Injectable() +export class OtpService { + constructor( + @InjectRepository(OtpCode) + private readonly otpRepository: Repository, + private readonly smsService: SmsService, + private readonly phoneNumberService: PhoneNumberService, + private readonly templatesService: TemplatesService, + private readonly configService: ConfigService, + ) {} + + async sendOtp(dto: SendOtpDto) { + const normalized = this.phoneNumberService.normalize(dto.phoneNumber); + await this.enforceOtpRateLimit(normalized.e164, dto.purpose); + + const code = this.generateCode( + this.configService.get('sms.otp.length', 6), + ); + const expiryMinutes = this.configService.get( + 'sms.otp.expiryMinutes', + 10, + ); + const expiresAt = new Date(Date.now() + expiryMinutes * 60 * 1000); + + const otp = this.otpRepository.create({ + phoneNumber: dto.phoneNumber, + normalizedPhoneNumber: normalized.e164, + userId: dto.userId, + purpose: dto.purpose, + codeHash: this.hashCode(normalized.e164, dto.purpose, code), + expiresAt, + maxAttempts: this.configService.get('sms.otp.maxAttempts', 5), + metadata: dto.metadata, + }); + + const savedOtp = await this.otpRepository.save(otp); + const body = await this.renderOtpBody(code, dto.purpose, expiryMinutes); + + const message = await this.smsService.send({ + phoneNumber: dto.phoneNumber, + body, + userId: dto.userId, + type: SmsMessageType.OTP, + metadata: { + ...dto.metadata, + otpId: savedOtp.id, + purpose: dto.purpose, + }, + otpPurpose: dto.purpose, + expiresAt: expiresAt.toISOString(), + }); + + return { + otpId: savedOtp.id, + expiresAt, + messageId: message.id, + maskedPhoneNumber: this.phoneNumberService.mask(normalized.e164), + debugCode: this.configService.get('sms.debugExposeCodes', false) + ? code + : undefined, + }; + } + + async verifyOtp(dto: VerifyOtpDto) { + const normalized = this.phoneNumberService.normalize(dto.phoneNumber); + + const otp = await this.otpRepository + .createQueryBuilder('otp') + .where('otp.normalizedPhoneNumber = :phone', { phone: normalized.e164 }) + .andWhere('otp.purpose = :purpose', { purpose: dto.purpose }) + .andWhere('otp.verifiedAt IS NULL') + .orderBy('otp.createdAt', 'DESC') + .getOne(); + + if (!otp) { + throw new BadRequestException('No active OTP found'); + } + + if (otp.expiresAt.getTime() < Date.now()) { + throw new UnauthorizedException('OTP has expired'); + } + + if (otp.attempts >= otp.maxAttempts) { + throw new HttpException( + 'OTP attempt limit reached', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + otp.attempts += 1; + otp.lastAttemptAt = new Date(); + + if (otp.codeHash !== this.hashCode(normalized.e164, dto.purpose, dto.code)) { + await this.otpRepository.save(otp); + throw new UnauthorizedException('Invalid OTP code'); + } + + otp.verifiedAt = new Date(); + await this.otpRepository.save(otp); + + return { + verified: true, + otpId: otp.id, + verifiedAt: otp.verifiedAt, + }; + } + + private async renderOtpBody( + code: string, + purpose: string, + expiryMinutes: number, + ): Promise { + const templateName = this.configService.get( + 'sms.otp.templateName', + 'otp-verification', + ); + + const template = await this.templatesService.findByNameOrNull(templateName); + if (!template) { + return `Your ${purpose} verification code is ${code}. It expires in ${expiryMinutes} minutes.`; + } + + const rendered = await this.templatesService.render({ + templateName, + variables: { + code, + purpose, + expiryMinutes, + }, + }); + + return rendered.body; + } + + private async enforceOtpRateLimit(phoneNumber: string, purpose: string) { + const windowMinutes = this.configService.get( + 'sms.rateLimit.otpWindowMinutes', + 10, + ); + const maxPerWindow = this.configService.get( + 'sms.rateLimit.otpMaxPerWindow', + 3, + ); + const threshold = new Date(Date.now() - windowMinutes * 60 * 1000); + + const count = await this.otpRepository + .createQueryBuilder('otp') + .where('otp.normalizedPhoneNumber = :phone', { phone: phoneNumber }) + .andWhere('otp.purpose = :purpose', { purpose }) + .andWhere('otp.createdAt >= :threshold', { threshold }) + .getCount(); + + if (count >= maxPerWindow) { + throw new HttpException( + 'OTP rate limit exceeded for this phone number', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + private generateCode(length: number): string { + const max = 10 ** length; + const min = 10 ** (length - 1); + return randomInt(min, max).toString(); + } + + private hashCode(phoneNumber: string, purpose: string, code: string): string { + return createHash('sha256') + .update( + `${phoneNumber}:${purpose}:${code}:${this.configService.get( + 'sms.otp.secret', + 'sms-otp-secret', + )}`, + ) + .digest('hex'); + } +} diff --git a/microservices/sms-service/src/providers/aws-sns.provider.ts b/microservices/sms-service/src/providers/aws-sns.provider.ts index 93dcb1c..feef388 100644 --- a/microservices/sms-service/src/providers/aws-sns.provider.ts +++ b/microservices/sms-service/src/providers/aws-sns.provider.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { + SmsPayload, SmsProvider, - SmsProviderRequest, - SmsProviderResponse, + SmsSendResult, } from './interfaces/sms-provider.interface'; @Injectable() @@ -12,11 +12,21 @@ export class AwsSnsSmsProvider implements SmsProvider { constructor(private readonly configService: ConfigService) {} - async send(_request: SmsProviderRequest): Promise { + validateConfig(): boolean { + return Boolean( + this.configService.get('sms.aws.region') && + this.configService.get('sms.aws.accessKeyId') && + this.configService.get('sms.aws.secretAccessKey'), + ); + } + + async send(_request: SmsPayload): Promise { const region = this.configService.get('sms.aws.region'); - throw new Error( - `AWS SNS provider selected for ${region}, but the lightweight service build does not bundle the AWS SDK. Use SMS_PROVIDER=twilio or SMS_PROVIDER=console, or add @aws-sdk/client-sns to enable SNS sends.`, - ); + return { + success: false, + provider: this.name, + error: `AWS SNS provider selected for ${region}, but this compatibility provider is disabled. Use the branch's \`sns\` provider implementation instead.`, + }; } } diff --git a/microservices/sms-service/src/providers/console.provider.ts b/microservices/sms-service/src/providers/console.provider.ts index eb8e911..b8a93d7 100644 --- a/microservices/sms-service/src/providers/console.provider.ts +++ b/microservices/sms-service/src/providers/console.provider.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { + SmsPayload, SmsProvider, - SmsProviderRequest, - SmsProviderResponse, + SmsSendResult, } from './interfaces/sms-provider.interface'; @Injectable() @@ -11,14 +11,20 @@ export class ConsoleSmsProvider implements SmsProvider { readonly name = 'console'; private readonly logger = new Logger(ConsoleSmsProvider.name); - async send(request: SmsProviderRequest): Promise { + validateConfig(): boolean { + return true; + } + + async send(request: SmsPayload): Promise { const messageId = `console_${randomUUID()}`; this.logger.log(`SMS ${messageId} to ${request.to}: ${request.body}`); return { + success: true, provider: this.name, messageId, - status: 'sent', + deliveryStatus: 'sent', + segments: Math.max(1, Math.ceil(request.body.length / 160)), }; } } diff --git a/microservices/sms-service/src/providers/interfaces/sms-provider.interface.ts b/microservices/sms-service/src/providers/interfaces/sms-provider.interface.ts index 6c771a4..0ff24eb 100644 --- a/microservices/sms-service/src/providers/interfaces/sms-provider.interface.ts +++ b/microservices/sms-service/src/providers/interfaces/sms-provider.interface.ts @@ -1,17 +1,23 @@ -export type SmsProviderRequest = { +export interface SmsPayload { to: string; - from: string; body: string; + from?: string; metadata?: Record; -}; + statusCallbackUrl?: string; +} -export type SmsProviderResponse = { +export interface SmsSendResult { + success: boolean; provider: string; - messageId: string; - status: 'sent' | 'queued'; -}; + messageId?: string; + segments?: number; + error?: string; + rawResponse?: Record; + deliveryStatus?: 'sent' | 'delivered'; +} export interface SmsProvider { - readonly name: string; - send(request: SmsProviderRequest): Promise; + name: string; + send(payload: SmsPayload): Promise; + validateConfig(): boolean; } diff --git a/microservices/sms-service/src/providers/mock.provider.ts b/microservices/sms-service/src/providers/mock.provider.ts new file mode 100644 index 0000000..4ea7610 --- /dev/null +++ b/microservices/sms-service/src/providers/mock.provider.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { + SmsPayload, + SmsProvider, + SmsSendResult, +} from './interfaces/sms-provider.interface'; + +@Injectable() +export class MockProvider implements SmsProvider { + name = 'mock'; + + async send(payload: SmsPayload): Promise { + return { + success: true, + provider: this.name, + messageId: randomUUID(), + segments: Math.max(1, Math.ceil(payload.body.length / 160)), + deliveryStatus: 'delivered', + rawResponse: { + acceptedAt: new Date().toISOString(), + to: payload.to, + }, + }; + } + + validateConfig(): boolean { + return true; + } +} diff --git a/microservices/sms-service/src/providers/providers.module.ts b/microservices/sms-service/src/providers/providers.module.ts index 3b17926..93a9f0c 100644 --- a/microservices/sms-service/src/providers/providers.module.ts +++ b/microservices/sms-service/src/providers/providers.module.ts @@ -1,15 +1,17 @@ import { Module } from '@nestjs/common'; -import { AwsSnsSmsProvider } from './aws-sns.provider'; -import { ConsoleSmsProvider } from './console.provider'; +import { ConfigModule } from '@nestjs/config'; +import { MockProvider } from './mock.provider'; +import { SnsProvider } from './sns.provider'; import { SmsProviderFactory } from './sms-provider.factory'; -import { TwilioSmsProvider } from './twilio.provider'; +import { TwilioProvider } from './twilio.provider'; @Module({ + imports: [ConfigModule], providers: [ - AwsSnsSmsProvider, - ConsoleSmsProvider, + MockProvider, + SnsProvider, SmsProviderFactory, - TwilioSmsProvider, + TwilioProvider, ], exports: [SmsProviderFactory], }) diff --git a/microservices/sms-service/src/providers/sms-provider.factory.ts b/microservices/sms-service/src/providers/sms-provider.factory.ts index d8c2823..72e463c 100644 --- a/microservices/sms-service/src/providers/sms-provider.factory.ts +++ b/microservices/sms-service/src/providers/sms-provider.factory.ts @@ -1,30 +1,74 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { AwsSnsSmsProvider } from './aws-sns.provider'; -import { ConsoleSmsProvider } from './console.provider'; -import { SmsProvider } from './interfaces/sms-provider.interface'; -import { TwilioSmsProvider } from './twilio.provider'; +import { + SmsPayload, + SmsProvider, + SmsSendResult, +} from './interfaces/sms-provider.interface'; +import { MockProvider } from './mock.provider'; +import { SnsProvider } from './sns.provider'; +import { TwilioProvider } from './twilio.provider'; @Injectable() -export class SmsProviderFactory { +export class SmsProviderFactory implements OnModuleInit { + private readonly logger = new Logger(SmsProviderFactory.name); + private provider: SmsProvider; + private fallbackProvider: SmsProvider; + constructor( private readonly configService: ConfigService, - private readonly consoleProvider: ConsoleSmsProvider, - private readonly twilioProvider: TwilioSmsProvider, - private readonly awsSnsProvider: AwsSnsSmsProvider, + private readonly twilioProvider: TwilioProvider, + private readonly snsProvider: SnsProvider, + private readonly mockProvider: MockProvider, ) {} - getProvider(): SmsProvider { - const provider = this.configService.get('sms.provider', 'console'); + onModuleInit() { + const configuredProvider = this.configService.get('sms.provider', 'mock'); - if (provider === 'twilio') { - return this.twilioProvider; + switch (configuredProvider) { + case 'twilio': + this.provider = this.twilioProvider.validateConfig() + ? this.twilioProvider + : this.mockProvider; + this.fallbackProvider = this.snsProvider.validateConfig() + ? this.snsProvider + : this.mockProvider; + break; + case 'sns': + this.provider = this.snsProvider.validateConfig() + ? this.snsProvider + : this.mockProvider; + this.fallbackProvider = this.twilioProvider.validateConfig() + ? this.twilioProvider + : this.mockProvider; + break; + default: + this.provider = this.mockProvider; + this.fallbackProvider = this.twilioProvider.validateConfig() + ? this.twilioProvider + : this.snsProvider.validateConfig() + ? this.snsProvider + : this.mockProvider; } - if (provider === 'aws-sns') { - return this.awsSnsProvider; + this.logger.log(`Primary SMS provider: ${this.provider.name}`); + this.logger.log(`Fallback SMS provider: ${this.fallbackProvider.name}`); + } + + getProvider(): SmsProvider { + return this.provider; + } + + async send(payload: SmsPayload): Promise { + const result = await this.provider.send(payload); + + if (!result.success && this.fallbackProvider.name !== this.provider.name) { + this.logger.warn( + `Primary provider ${this.provider.name} failed, retrying with ${this.fallbackProvider.name}`, + ); + return this.fallbackProvider.send(payload); } - return this.consoleProvider; + return result; } } diff --git a/microservices/sms-service/src/providers/sns.provider.ts b/microservices/sms-service/src/providers/sns.provider.ts new file mode 100644 index 0000000..4cd9799 --- /dev/null +++ b/microservices/sms-service/src/providers/sns.provider.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PublishCommand, SNSClient } from '@aws-sdk/client-sns'; +import { + SmsPayload, + SmsProvider, + SmsSendResult, +} from './interfaces/sms-provider.interface'; + +@Injectable() +export class SnsProvider implements SmsProvider { + name = 'sns'; + + constructor(private readonly configService: ConfigService) {} + + validateConfig(): boolean { + return Boolean( + this.configService.get('sms.sns.region') && + this.configService.get('sms.sns.accessKeyId') && + this.configService.get('sms.sns.secretAccessKey'), + ); + } + + async send(payload: SmsPayload): Promise { + if (!this.validateConfig()) { + return { + success: false, + provider: this.name, + error: 'AWS SNS credentials are not configured', + }; + } + + try { + const client = new SNSClient({ + region: this.configService.get('sms.sns.region'), + credentials: { + accessKeyId: this.configService.get('sms.sns.accessKeyId') || '', + secretAccessKey: + this.configService.get('sms.sns.secretAccessKey') || '', + }, + }); + + const response = await client.send( + new PublishCommand({ + PhoneNumber: payload.to, + Message: payload.body, + MessageAttributes: { + 'AWS.SNS.SMS.SenderID': { + DataType: 'String', + StringValue: + payload.from || this.configService.get('sms.sns.senderId', 'Quest'), + }, + }, + }), + ); + + return { + success: true, + provider: this.name, + messageId: response.MessageId, + segments: Math.max(1, Math.ceil(payload.body.length / 160)), + deliveryStatus: 'sent', + rawResponse: { + messageId: response.MessageId, + }, + }; + } catch (error) { + return { + success: false, + provider: this.name, + error: error.message, + }; + } + } +} diff --git a/microservices/sms-service/src/providers/twilio.provider.ts b/microservices/sms-service/src/providers/twilio.provider.ts index e43793f..1953d7c 100644 --- a/microservices/sms-service/src/providers/twilio.provider.ts +++ b/microservices/sms-service/src/providers/twilio.provider.ts @@ -1,59 +1,68 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import Twilio from 'twilio'; import { + SmsPayload, SmsProvider, - SmsProviderRequest, - SmsProviderResponse, + SmsSendResult, } from './interfaces/sms-provider.interface'; @Injectable() -export class TwilioSmsProvider implements SmsProvider { - readonly name = 'twilio'; +export class TwilioProvider implements SmsProvider { + name = 'twilio'; constructor(private readonly configService: ConfigService) {} - async send(request: SmsProviderRequest): Promise { - const accountSid = this.configService.get('sms.twilio.accountSid'); - const authToken = this.configService.get('sms.twilio.authToken'); - const callbackUrl = this.configService.get( - 'sms.twilio.statusCallbackUrl', + validateConfig(): boolean { + return Boolean( + this.configService.get('sms.twilio.accountSid') && + this.configService.get('sms.twilio.authToken') && + (this.configService.get('sms.twilio.fromNumber') || + this.configService.get('sms.twilio.messagingServiceSid')), ); + } - if (!accountSid || !authToken) { - throw new Error('Twilio credentials are not configured'); + async send(payload: SmsPayload): Promise { + if (!this.validateConfig()) { + return { + success: false, + provider: this.name, + error: 'Twilio credentials are not configured', + }; } - const body = new URLSearchParams({ - To: request.to, - From: request.from, - Body: request.body, - }); + try { + const client = Twilio( + this.configService.get('sms.twilio.accountSid'), + this.configService.get('sms.twilio.authToken'), + ); - if (callbackUrl) { - body.set('StatusCallback', callbackUrl); - } + const response = await client.messages.create({ + to: payload.to, + body: payload.body, + from: this.configService.get('sms.twilio.fromNumber') || undefined, + messagingServiceSid: + this.configService.get('sms.twilio.messagingServiceSid') || undefined, + statusCallback: payload.statusCallbackUrl, + }); - const response = await fetch( - `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, - { - method: 'POST', - headers: { - Authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString('base64')}`, - 'Content-Type': 'application/x-www-form-urlencoded', + return { + success: true, + provider: this.name, + messageId: response.sid, + segments: parseInt(response.numSegments || '1', 10), + deliveryStatus: 'sent', + rawResponse: { + sid: response.sid, + status: response.status, }, - body, - }, - ); - - const payload = await response.json(); - if (!response.ok) { - throw new Error(payload?.message || 'Twilio SMS send failed'); + }; + } catch (error) { + return { + success: false, + provider: this.name, + error: error.message, + }; } - - return { - provider: this.name, - messageId: payload.sid, - status: payload.status === 'queued' ? 'queued' : 'sent', - }; } } diff --git a/microservices/sms-service/src/receipts/dto/confirm-receipt.dto.ts b/microservices/sms-service/src/receipts/dto/confirm-receipt.dto.ts new file mode 100644 index 0000000..745c39f --- /dev/null +++ b/microservices/sms-service/src/receipts/dto/confirm-receipt.dto.ts @@ -0,0 +1,39 @@ +import { IsDateString, IsEnum, IsObject, IsOptional, IsString } from 'class-validator'; +import { SmsReceiptStatus } from '../entities/sms-receipt.entity'; + +export class ConfirmReceiptDto { + @IsOptional() + @IsString() + messageId?: string; + + @IsOptional() + @IsString() + providerMessageId?: string; + + @IsOptional() + @IsString() + provider?: string; + + @IsEnum(SmsReceiptStatus) + status: SmsReceiptStatus; + + @IsOptional() + @IsString() + eventType?: string; + + @IsOptional() + @IsString() + errorCode?: string; + + @IsOptional() + @IsString() + errorMessage?: string; + + @IsOptional() + @IsDateString() + occurredAt?: string; + + @IsOptional() + @IsObject() + rawPayload?: Record; +} diff --git a/microservices/sms-service/src/receipts/dto/index.ts b/microservices/sms-service/src/receipts/dto/index.ts new file mode 100644 index 0000000..5fe1cb8 --- /dev/null +++ b/microservices/sms-service/src/receipts/dto/index.ts @@ -0,0 +1 @@ +export * from './confirm-receipt.dto'; diff --git a/microservices/sms-service/src/receipts/entities/sms-receipt.entity.ts b/microservices/sms-service/src/receipts/entities/sms-receipt.entity.ts new file mode 100644 index 0000000..399da26 --- /dev/null +++ b/microservices/sms-service/src/receipts/entities/sms-receipt.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +export enum SmsReceiptStatus { + ACCEPTED = 'accepted', + SENT = 'sent', + DELIVERED = 'delivered', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +@Entity({ name: 'sms_receipts' }) +@Index(['messageId', 'createdAt']) +@Index(['providerMessageId']) +export class SmsReceipt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + messageId: string; + + @Column({ nullable: true }) + providerMessageId: string; + + @Column({ nullable: true }) + provider: string; + + @Column({ + type: 'simple-enum', + enum: SmsReceiptStatus, + }) + status: SmsReceiptStatus; + + @Column() + eventType: string; + + @Column({ nullable: true }) + errorCode: string; + + @Column({ nullable: true }) + errorMessage: string; + + @Column({ nullable: true }) + occurredAt: Date; + + @Column({ type: 'simple-json', nullable: true }) + rawPayload: Record; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/microservices/sms-service/src/receipts/receipts.controller.ts b/microservices/sms-service/src/receipts/receipts.controller.ts new file mode 100644 index 0000000..e20ec4e --- /dev/null +++ b/microservices/sms-service/src/receipts/receipts.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ConfirmReceiptDto } from './dto'; +import { ReceiptsService } from './receipts.service'; + +@Controller('receipts') +export class ReceiptsController { + constructor(private readonly receiptsService: ReceiptsService) {} + + @Post('confirm') + confirm(@Body() dto: ConfirmReceiptDto) { + return this.receiptsService.confirmReceipt(dto); + } + + @Post('webhook') + webhook(@Body() payload: ConfirmReceiptDto) { + return this.receiptsService.confirmReceipt(payload); + } + + @Get('message/:messageId') + findByMessageId(@Param('messageId') messageId: string) { + return this.receiptsService.findByMessageId(messageId); + } +} diff --git a/microservices/sms-service/src/receipts/receipts.module.ts b/microservices/sms-service/src/receipts/receipts.module.ts new file mode 100644 index 0000000..913d797 --- /dev/null +++ b/microservices/sms-service/src/receipts/receipts.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SmsReceipt } from './entities/sms-receipt.entity'; +import { SmsMessage } from '../sms/entities/sms-message.entity'; +import { ReceiptsController } from './receipts.controller'; +import { ReceiptsService } from './receipts.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([SmsReceipt, SmsMessage])], + controllers: [ReceiptsController], + providers: [ReceiptsService], + exports: [ReceiptsService], +}) +export class ReceiptsModule {} diff --git a/microservices/sms-service/src/receipts/receipts.service.ts b/microservices/sms-service/src/receipts/receipts.service.ts new file mode 100644 index 0000000..9c6dbe8 --- /dev/null +++ b/microservices/sms-service/src/receipts/receipts.service.ts @@ -0,0 +1,124 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfirmReceiptDto } from './dto'; +import { SmsReceipt, SmsReceiptStatus } from './entities/sms-receipt.entity'; +import { SmsMessage, SmsMessageStatus } from '../sms/entities/sms-message.entity'; +import { SmsSendResult } from '../providers/interfaces/sms-provider.interface'; + +@Injectable() +export class ReceiptsService { + constructor( + @InjectRepository(SmsReceipt) + private readonly receiptRepository: Repository, + @InjectRepository(SmsMessage) + private readonly messageRepository: Repository, + ) {} + + async recordProviderResult( + message: SmsMessage, + result: SmsSendResult, + ): Promise { + const status = + result.deliveryStatus === 'delivered' + ? SmsReceiptStatus.DELIVERED + : SmsReceiptStatus.SENT; + + const receipt = this.receiptRepository.create({ + messageId: message.id, + providerMessageId: result.messageId, + provider: result.provider, + status, + eventType: status, + occurredAt: new Date(), + rawPayload: result.rawResponse, + }); + + return this.receiptRepository.save(receipt); + } + + async recordFailure( + message: SmsMessage, + errorMessage: string, + provider?: string, + ): Promise { + const receipt = this.receiptRepository.create({ + messageId: message.id, + providerMessageId: message.providerMessageId, + provider: provider || message.provider, + status: SmsReceiptStatus.FAILED, + eventType: 'failed', + errorMessage, + occurredAt: new Date(), + }); + + return this.receiptRepository.save(receipt); + } + + async confirmReceipt(dto: ConfirmReceiptDto): Promise { + const message = await this.findMessage(dto); + + message.provider = dto.provider || message.provider; + message.providerMessageId = dto.providerMessageId || message.providerMessageId; + + switch (dto.status) { + case SmsReceiptStatus.SENT: + message.status = SmsMessageStatus.SENT; + message.sentAt = message.sentAt || new Date(dto.occurredAt || Date.now()); + break; + case SmsReceiptStatus.DELIVERED: + message.status = SmsMessageStatus.DELIVERED; + message.sentAt = message.sentAt || new Date(dto.occurredAt || Date.now()); + message.deliveredAt = new Date(dto.occurredAt || Date.now()); + break; + case SmsReceiptStatus.FAILED: + message.status = SmsMessageStatus.FAILED; + message.failedAt = new Date(dto.occurredAt || Date.now()); + message.lastError = dto.errorMessage || 'Delivery failed'; + break; + case SmsReceiptStatus.CANCELLED: + message.status = SmsMessageStatus.CANCELLED; + message.cancelledAt = new Date(dto.occurredAt || Date.now()); + break; + default: + break; + } + + await this.messageRepository.save(message); + + const receipt = this.receiptRepository.create({ + messageId: message.id, + providerMessageId: dto.providerMessageId || message.providerMessageId, + provider: dto.provider || message.provider, + status: dto.status, + eventType: dto.eventType || dto.status, + errorCode: dto.errorCode, + errorMessage: dto.errorMessage, + occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : new Date(), + rawPayload: dto.rawPayload, + }); + + return this.receiptRepository.save(receipt); + } + + findByMessageId(messageId: string): Promise { + return this.receiptRepository.find({ + where: { messageId }, + order: { createdAt: 'DESC' }, + }); + } + + private async findMessage(dto: ConfirmReceiptDto): Promise { + const message = dto.messageId + ? await this.messageRepository.findOne({ where: { id: dto.messageId } }) + : await this.messageRepository.findOne({ + where: { providerMessageId: dto.providerMessageId }, + }); + + if (!message) { + throw new NotFoundException('SMS message not found for receipt'); + } + + return message; + } +} diff --git a/microservices/sms-service/src/sms/dto/index.ts b/microservices/sms-service/src/sms/dto/index.ts index d01d71a..f2f2d64 100644 --- a/microservices/sms-service/src/sms/dto/index.ts +++ b/microservices/sms-service/src/sms/dto/index.ts @@ -1 +1,3 @@ -export * from './sms.dto'; +export * from './query-sms-history.dto'; +export * from './send-sms.dto'; +export * from './send-templated-sms.dto'; diff --git a/microservices/sms-service/src/sms/dto/query-sms-history.dto.ts b/microservices/sms-service/src/sms/dto/query-sms-history.dto.ts new file mode 100644 index 0000000..709c2eb --- /dev/null +++ b/microservices/sms-service/src/sms/dto/query-sms-history.dto.ts @@ -0,0 +1,51 @@ +import { Type } from 'class-transformer'; +import { + IsDateString, + IsEnum, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { SmsMessageStatus, SmsMessageType } from '../entities/sms-message.entity'; + +export class QuerySmsHistoryDto { + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsString() + phoneNumber?: string; + + @IsOptional() + @IsEnum(SmsMessageStatus) + status?: SmsMessageStatus; + + @IsOptional() + @IsEnum(SmsMessageType) + type?: SmsMessageType; + + @IsOptional() + @IsString() + provider?: string; + + @IsOptional() + @IsDateString() + from?: string; + + @IsOptional() + @IsDateString() + to?: string; + + @IsOptional() + @Type(() => Number) + @Min(1) + @Max(100) + limit?: number = 50; + + @IsOptional() + @Type(() => Number) + @Min(0) + offset?: number = 0; +} diff --git a/microservices/sms-service/src/sms/dto/send-sms.dto.ts b/microservices/sms-service/src/sms/dto/send-sms.dto.ts new file mode 100644 index 0000000..607dc2a --- /dev/null +++ b/microservices/sms-service/src/sms/dto/send-sms.dto.ts @@ -0,0 +1,61 @@ +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsPhoneNumber, + IsString, + Max, + Min, +} from 'class-validator'; +import { SmsMessageType, SmsPriority } from '../entities/sms-message.entity'; + +export class SendSmsDto { + @IsString() + @IsNotEmpty() + phoneNumber: string; + + @IsString() + @IsNotEmpty() + body: string; + + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsEnum(SmsMessageType) + type?: SmsMessageType; + + @IsOptional() + @IsEnum(SmsPriority) + priority?: SmsPriority; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsString() + correlationId?: string; + + @IsOptional() + @IsString() + otpPurpose?: string; + + @IsOptional() + @IsDateString() + scheduledAt?: string; + + @IsOptional() + @IsDateString() + expiresAt?: string; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(10) + maxAttempts?: number; +} diff --git a/microservices/sms-service/src/sms/dto/send-templated-sms.dto.ts b/microservices/sms-service/src/sms/dto/send-templated-sms.dto.ts new file mode 100644 index 0000000..3a1545b --- /dev/null +++ b/microservices/sms-service/src/sms/dto/send-templated-sms.dto.ts @@ -0,0 +1,50 @@ +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, +} from 'class-validator'; +import { SmsMessageType, SmsPriority } from '../entities/sms-message.entity'; + +export class SendTemplatedSmsDto { + @IsString() + @IsNotEmpty() + phoneNumber: string; + + @IsString() + @IsNotEmpty() + templateName: string; + + @IsObject() + variables: Record; + + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsEnum(SmsMessageType) + type?: SmsMessageType; + + @IsOptional() + @IsEnum(SmsPriority) + priority?: SmsPriority; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsString() + correlationId?: string; + + @IsOptional() + @IsDateString() + scheduledAt?: string; + + @IsOptional() + @IsDateString() + expiresAt?: string; +} diff --git a/microservices/sms-service/src/sms/entities/message.entity.ts b/microservices/sms-service/src/sms/entities/message.entity.ts index d474035..c7643d0 100644 --- a/microservices/sms-service/src/sms/entities/message.entity.ts +++ b/microservices/sms-service/src/sms/entities/message.entity.ts @@ -59,21 +59,21 @@ export class Message { body: string; @Column({ - type: 'enum', + type: 'simple-enum', enum: MessageType, default: MessageType.TRANSACTIONAL, }) type: MessageType; @Column({ - type: 'enum', + type: 'simple-enum', enum: MessagePriority, default: MessagePriority.NORMAL, }) priority: MessagePriority; @Column({ - type: 'enum', + type: 'simple-enum', enum: MessageStatus, default: MessageStatus.PENDING, }) @@ -88,10 +88,10 @@ export class Message { @Column({ nullable: true }) templateName: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: 'simple-json', nullable: true }) templateData: Record; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: 'simple-json', nullable: true }) metadata: Record; @Column({ nullable: true }) diff --git a/microservices/sms-service/src/sms/entities/receipt.entity.ts b/microservices/sms-service/src/sms/entities/receipt.entity.ts index 6eed712..698171c 100644 --- a/microservices/sms-service/src/sms/entities/receipt.entity.ts +++ b/microservices/sms-service/src/sms/entities/receipt.entity.ts @@ -32,7 +32,7 @@ export class Receipt { providerMessageId: string; @Column({ - type: 'enum', + type: 'simple-enum', enum: MessageStatus, }) status: MessageStatus; @@ -43,7 +43,7 @@ export class Receipt { @Column({ nullable: true }) errorMessage: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: 'simple-json', nullable: true }) rawPayload: Record; @CreateDateColumn() diff --git a/microservices/sms-service/src/sms/entities/sms-message.entity.ts b/microservices/sms-service/src/sms/entities/sms-message.entity.ts new file mode 100644 index 0000000..48ee86f --- /dev/null +++ b/microservices/sms-service/src/sms/entities/sms-message.entity.ts @@ -0,0 +1,146 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum SmsMessageStatus { + PENDING = 'pending', + PROCESSING = 'processing', + QUEUED = 'queued', + SENT = 'sent', + DELIVERED = 'delivered', + FAILED = 'failed', + CANCELLED = 'cancelled', + EXPIRED = 'expired', +} + +export enum SmsPriority { + LOW = 'low', + NORMAL = 'normal', + HIGH = 'high', + CRITICAL = 'critical', +} + +export enum SmsMessageType { + OTP = 'otp', + ALERT = 'alert', + TRANSACTIONAL = 'transactional', + REMINDER = 'reminder', + SYSTEM = 'system', +} + +@Entity({ name: 'sms_messages' }) +@Index(['normalizedPhoneNumber', 'createdAt']) +@Index(['userId', 'status']) +@Index(['providerMessageId']) +export class SmsMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true }) + @Index() + userId: string; + + @Column() + phoneNumber: string; + + @Column() + normalizedPhoneNumber: string; + + @Column({ nullable: true }) + countryCode: string; + + @Column({ type: 'text' }) + body: string; + + @Column({ + type: 'simple-enum', + enum: SmsMessageStatus, + default: SmsMessageStatus.PENDING, + }) + status: SmsMessageStatus; + + @Column({ + type: 'simple-enum', + enum: SmsPriority, + default: SmsPriority.NORMAL, + }) + priority: SmsPriority; + + @Column({ + type: 'simple-enum', + enum: SmsMessageType, + default: SmsMessageType.TRANSACTIONAL, + }) + type: SmsMessageType; + + @Column({ nullable: true }) + provider: string; + + @Column({ nullable: true }) + providerMessageId: string; + + @Column({ nullable: true }) + templateId: string; + + @Column({ nullable: true }) + templateName: string; + + @Column({ type: 'simple-json', nullable: true }) + templateData: Record; + + @Column({ type: 'simple-json', nullable: true }) + metadata: Record; + + @Column({ nullable: true }) + correlationId: string; + + @Column({ nullable: true }) + otpPurpose: string; + + @Column({ default: 1 }) + segments: number; + + @Column({ type: 'float', nullable: true }) + estimatedCost: number; + + @Column({ default: 0 }) + attempts: number; + + @Column({ default: 3 }) + maxAttempts: number; + + @Column({ nullable: true }) + lastError: string; + + @Column({ nullable: true }) + scheduledAt: Date; + + @Column({ nullable: true }) + nextRetryAt: Date; + + @Column({ nullable: true }) + expiresAt: Date; + + @Column({ nullable: true }) + sentAt: Date; + + @Column({ nullable: true }) + deliveredAt: Date; + + @Column({ nullable: true }) + failedAt: Date; + + @Column({ nullable: true }) + cancelledAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/microservices/sms-service/src/sms/entities/sms.entity.ts b/microservices/sms-service/src/sms/entities/sms.entity.ts index 33ce7e7..637d14c 100644 --- a/microservices/sms-service/src/sms/entities/sms.entity.ts +++ b/microservices/sms-service/src/sms/entities/sms.entity.ts @@ -27,7 +27,7 @@ export class Sms { @Column({ default: true }) active: boolean; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: 'simple-json', nullable: true }) metadata: Record; @OneToMany(() => Message, (message) => message.sender) diff --git a/microservices/sms-service/src/sms/phone-number.service.ts b/microservices/sms-service/src/sms/phone-number.service.ts new file mode 100644 index 0000000..bbf31d6 --- /dev/null +++ b/microservices/sms-service/src/sms/phone-number.service.ts @@ -0,0 +1,34 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CountryCode, parsePhoneNumberFromString } from 'libphonenumber-js'; + +@Injectable() +export class PhoneNumberService { + constructor(private readonly configService: ConfigService) {} + + normalize(phoneNumber: string) { + const parsed = parsePhoneNumberFromString( + phoneNumber, + this.configService.get('sms.defaultCountry', 'US') as CountryCode, + ); + + if (!parsed || !parsed.isValid()) { + throw new BadRequestException('Invalid phone number'); + } + + return { + e164: parsed.number, + country: parsed.country, + national: parsed.formatNational(), + }; + } + + mask(phoneNumber: string): string { + const digits = phoneNumber.replace(/\D/g, ''); + if (digits.length <= 4) { + return `****${digits}`; + } + + return `${'*'.repeat(Math.max(0, digits.length - 4))}${digits.slice(-4)}`; + } +} diff --git a/microservices/sms-service/src/sms/sms-dispatcher.service.ts b/microservices/sms-service/src/sms/sms-dispatcher.service.ts new file mode 100644 index 0000000..c2986b7 --- /dev/null +++ b/microservices/sms-service/src/sms/sms-dispatcher.service.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SmsService } from './sms.service'; + +@Injectable() +export class SmsDispatcherService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SmsDispatcherService.name); + private intervalHandle?: NodeJS.Timeout; + + constructor( + private readonly smsService: SmsService, + private readonly configService: ConfigService, + ) {} + + onModuleInit() { + const intervalMs = this.configService.get('sms.dispatch.intervalMs', 5000); + this.intervalHandle = setInterval(async () => { + const processed = await this.smsService.processDueMessages(); + if (processed > 0) { + this.logger.log(`Processed ${processed} queued SMS messages`); + } + }, intervalMs); + } + + onModuleDestroy() { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + } + } +} diff --git a/microservices/sms-service/src/sms/sms.controller.ts b/microservices/sms-service/src/sms/sms.controller.ts index ac1d2f0..4276754 100644 --- a/microservices/sms-service/src/sms/sms.controller.ts +++ b/microservices/sms-service/src/sms/sms.controller.ts @@ -1,107 +1,56 @@ import { Body, Controller, - DefaultValuePipe, Get, Param, - ParseIntPipe, Post, Query, } from '@nestjs/common'; -import { MessageStatus } from './entities/message.entity'; +import { QuerySmsHistoryDto, SendSmsDto, SendTemplatedSmsDto } from './dto'; +import { SmsMessageType } from './entities/sms-message.entity'; import { SmsService } from './sms.service'; -import { - DeliveryReceiptDto, - GenerateOtpDto, - SendSmsDto, - SendTemplatedSmsDto, - VerifyOtpDto, -} from './dto'; -import { TemplatesService } from '../templates/templates.service'; -@Controller() +@Controller('sms') export class SmsController { - constructor( - private readonly smsService: SmsService, - private readonly templatesService: TemplatesService, - ) {} - - @Get('health') - health() { - return { status: 'ok', service: 'sms-service' }; - } + constructor(private readonly smsService: SmsService) {} - @Post('sms/send') + @Post('send') send(@Body() dto: SendSmsDto) { return this.smsService.send(dto); } - @Post('sms/send-template') + @Post('send-templated') sendTemplated(@Body() dto: SendTemplatedSmsDto) { return this.smsService.sendTemplated(dto); } - @Post('sms/otp') - generateOtp(@Body() dto: GenerateOtpDto) { - return this.smsService.generateOtp(dto); - } - - @Post('sms/otp/verify') - verifyOtp(@Body() dto: VerifyOtpDto) { - return this.smsService.verifyOtp(dto); + @Get('stats') + getStats( + @Query('from') from?: string, + @Query('to') to?: string, + @Query('userId') userId?: string, + @Query('type') type?: SmsMessageType, + ) { + return this.smsService.getStats({ from, to, userId, type }); } - @Post('sms/receipts') - receipt(@Body() dto: DeliveryReceiptDto) { - return this.smsService.recordReceipt(dto); + @Get('history') + getHistory(@Query() query: QuerySmsHistoryDto) { + return this.smsService.getHistory(query); } - @Get('sms/:id') + @Get(':id') findOne(@Param('id') id: string) { return this.smsService.findOne(id); } - @Get('sms') - history( - @Query('userId') userId?: string, - @Query('phone') phone?: string, - @Query('status') status?: MessageStatus, - @Query('from') from?: string, - @Query('to') to?: string, - @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, - @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset?: number, - ) { - return this.smsService.history({ - userId, - phone, - status, - from: from ? new Date(from) : undefined, - to: to ? new Date(to) : undefined, - limit, - offset, - }); - } - - @Get('sms-analytics') - analytics( - @Query('userId') userId?: string, - @Query('from') from?: string, - @Query('to') to?: string, - ) { - return this.smsService.analytics({ - userId, - from: from ? new Date(from) : undefined, - to: to ? new Date(to) : undefined, - }); - } - - @Post('sms/:id/cancel') + @Post(':id/cancel') cancel(@Param('id') id: string) { return this.smsService.cancel(id); } - @Get('sms-templates') - templates() { - return this.templatesService.list(); + @Post(':id/retry') + retry(@Param('id') id: string) { + return this.smsService.retry(id); } } diff --git a/microservices/sms-service/src/sms/sms.module.ts b/microservices/sms-service/src/sms/sms.module.ts index 46a72b2..eb673d2 100644 --- a/microservices/sms-service/src/sms/sms.module.ts +++ b/microservices/sms-service/src/sms/sms.module.ts @@ -1,21 +1,23 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ProvidersModule } from '../providers/providers.module'; +import { ReceiptsModule } from '../receipts/receipts.module'; import { TemplatesModule } from '../templates/templates.module'; -import { Message } from './entities/message.entity'; -import { Receipt } from './entities/receipt.entity'; -import { Sms } from './entities/sms.entity'; +import { SmsMessage } from './entities/sms-message.entity'; +import { PhoneNumberService } from './phone-number.service'; import { SmsController } from './sms.controller'; +import { SmsDispatcherService } from './sms-dispatcher.service'; import { SmsService } from './sms.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Sms, Message, Receipt]), + TypeOrmModule.forFeature([SmsMessage]), ProvidersModule, + ReceiptsModule, TemplatesModule, ], controllers: [SmsController], - providers: [SmsService], - exports: [SmsService], + providers: [PhoneNumberService, SmsDispatcherService, SmsService], + exports: [PhoneNumberService, SmsService], }) export class SmsModule {} diff --git a/microservices/sms-service/src/sms/sms.service.ts b/microservices/sms-service/src/sms/sms.service.ts index 5d4c5f3..0f39f43 100644 --- a/microservices/sms-service/src/sms/sms.service.ts +++ b/microservices/sms-service/src/sms/sms.service.ts @@ -7,417 +7,408 @@ import { NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; -import { createHash, randomInt, timingSafeEqual } from 'crypto'; -import { Between, LessThanOrEqual, MoreThan, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { SmsProviderFactory } from '../providers/sms-provider.factory'; +import { ReceiptsService } from '../receipts/receipts.service'; import { TemplatesService } from '../templates/templates.service'; +import { QuerySmsHistoryDto, SendSmsDto, SendTemplatedSmsDto } from './dto'; import { - DeliveryReceiptDto, - GenerateOtpDto, - SendSmsDto, - SendTemplatedSmsDto, - VerifyOtpDto, -} from './dto'; -import { Message, MessageStatus, MessageType } from './entities/message.entity'; -import { Receipt } from './entities/receipt.entity'; -import { Sms } from './entities/sms.entity'; + SmsMessage, + SmsMessageStatus, + SmsMessageType, + SmsPriority, +} from './entities/sms-message.entity'; +import { PhoneNumberService } from './phone-number.service'; @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); - private readonly rateLimits = new Map(); + private processingDueMessages = false; constructor( - @InjectRepository(Sms) - private readonly smsRepository: Repository, - @InjectRepository(Message) - private readonly messageRepository: Repository, - @InjectRepository(Receipt) - private readonly receiptRepository: Repository, - private readonly providerFactory: SmsProviderFactory, + @InjectRepository(SmsMessage) + private readonly messageRepository: Repository, + private readonly providers: SmsProviderFactory, + private readonly receiptsService: ReceiptsService, private readonly templatesService: TemplatesService, + private readonly phoneNumberService: PhoneNumberService, private readonly configService: ConfigService, ) {} - async send(dto: SendSmsDto): Promise { - this.assertPhoneAllowed(dto.toPhoneNumber); + async send(dto: SendSmsDto): Promise { + const normalized = this.phoneNumberService.normalize(dto.phoneNumber); + await this.enforceRateLimit(normalized.e164); + const scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : undefined; - const provider = this.providerFactory.getProvider(); - const fromNumber = this.resolveFromNumber(dto.fromNumber); + const expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : undefined; + + if (scheduledAt && expiresAt && scheduledAt > expiresAt) { + throw new BadRequestException('scheduledAt must be before expiresAt'); + } - const sender = await this.findOrCreateSender(provider.name, fromNumber); const message = this.messageRepository.create({ - ...dto, - toPhoneNumber: this.normalizePhone(dto.toPhoneNumber), - fromNumber, - provider: provider.name, + userId: dto.userId, + phoneNumber: dto.phoneNumber, + normalizedPhoneNumber: normalized.e164, + countryCode: normalized.country, + body: dto.body, + type: dto.type || SmsMessageType.TRANSACTIONAL, + priority: dto.priority || SmsPriority.NORMAL, + metadata: dto.metadata, + correlationId: dto.correlationId, + otpPurpose: dto.otpPurpose, status: - scheduledAt && scheduledAt > new Date() - ? MessageStatus.SCHEDULED - : MessageStatus.PENDING, + scheduledAt && scheduledAt.getTime() > Date.now() + ? SmsMessageStatus.QUEUED + : SmsMessageStatus.PENDING, scheduledAt, - sender, + expiresAt, + segments: this.estimateSegments(dto.body), + maxAttempts: + dto.maxAttempts || + this.configService.get('sms.dispatch.maxRetries', 3), }); - await this.messageRepository.save(message); - - if (message.status === MessageStatus.SCHEDULED) { - return message; - } - - return this.dispatch(message); + const saved = await this.messageRepository.save(message); + return this.maybeDispatch(saved); } - async sendTemplated(dto: SendTemplatedSmsDto): Promise { - const body = this.templatesService.render(dto.templateName, dto.variables); + async sendTemplated(dto: SendTemplatedSmsDto): Promise { + const template = await this.templatesService.findByName(dto.templateName); + const rendered = await this.templatesService.render({ + templateName: dto.templateName, + variables: dto.variables, + }); - const message = await this.send({ - toPhoneNumber: dto.toPhoneNumber, + return this.send({ + phoneNumber: dto.phoneNumber, + body: rendered.body, userId: dto.userId, - fromNumber: dto.fromNumber, - body, type: dto.type, priority: dto.priority, metadata: dto.metadata, + correlationId: dto.correlationId, scheduledAt: dto.scheduledAt, + expiresAt: dto.expiresAt, + }).then(async (message) => { + message.templateId = template.id; + message.templateName = template.name; + message.templateData = dto.variables; + await this.messageRepository.save(message); + return message; }); - - message.templateName = dto.templateName; - message.templateData = dto.variables; - return this.messageRepository.save(message); } - async generateOtp( - dto: GenerateOtpDto, - ): Promise<{ messageId: string; expiresAt: Date }> { - const code = this.generateCode(); - const ttlSeconds = this.configService.get('sms.otp.ttlSeconds'); - const expiresAt = new Date(Date.now() + ttlSeconds * 1000); - const minutes = Math.max(1, Math.ceil(ttlSeconds / 60)); - const templateName = dto.templateName || 'otp'; - const body = this.templatesService.render(templateName, { - ...(dto.variables || {}), - code, - minutes, - }); - - const message = await this.send({ - toPhoneNumber: dto.toPhoneNumber, - userId: dto.userId, - body, - type: MessageType.OTP, - metadata: { purpose: 'otp' }, - }); - - message.otpHash = this.hashOtp(dto.toPhoneNumber, code); - message.otpExpiresAt = expiresAt; - await this.messageRepository.save(message); - - return { messageId: message.id, expiresAt }; + async findOne(id: string): Promise { + const message = await this.messageRepository.findOne({ where: { id } }); + if (!message) { + throw new NotFoundException(`SMS message with ID ${id} not found`); + } + return message; } - async verifyOtp( - dto: VerifyOtpDto, - ): Promise<{ valid: boolean; messageId?: string }> { - const where: any = { - toPhoneNumber: this.normalizePhone(dto.toPhoneNumber), - type: MessageType.OTP, - otpExpiresAt: MoreThan(new Date()), - }; + async getHistory(dto: QuerySmsHistoryDto) { + const query = this.messageRepository.createQueryBuilder('sms'); - if (dto.messageId) { - where.id = dto.messageId; + if (dto.userId) { + query.andWhere('sms.userId = :userId', { userId: dto.userId }); } - const message = await this.messageRepository.findOne({ - where, - order: { createdAt: 'DESC' }, - }); - - if (!message || !message.otpHash) { - return { valid: false }; + if (dto.phoneNumber) { + const normalized = this.phoneNumberService.normalize(dto.phoneNumber); + query.andWhere('sms.normalizedPhoneNumber = :phone', { + phone: normalized.e164, + }); } - if (message.otpAttempts >= 5) { - throw new HttpException( - 'Maximum OTP verification attempts exceeded', - HttpStatus.TOO_MANY_REQUESTS, - ); + if (dto.status) { + query.andWhere('sms.status = :status', { status: dto.status }); } - message.otpAttempts += 1; - const expected = Buffer.from(message.otpHash); - const actual = Buffer.from(this.hashOtp(dto.toPhoneNumber, dto.code)); - const valid = - expected.length === actual.length && timingSafeEqual(expected, actual); - - if (valid) { - message.status = MessageStatus.DELIVERED; - message.deliveredAt = message.deliveredAt || new Date(); - message.otpExpiresAt = new Date(); + if (dto.type) { + query.andWhere('sms.type = :type', { type: dto.type }); } - await this.messageRepository.save(message); - return { valid, messageId: message.id }; - } - - async recordReceipt(dto: DeliveryReceiptDto): Promise { - const message = await this.messageRepository.findOne({ - where: { providerMessageId: dto.providerMessageId }, - }); - - if (!message) { - throw new NotFoundException(`Message ${dto.providerMessageId} not found`); + if (dto.provider) { + query.andWhere('sms.provider = :provider', { provider: dto.provider }); } - const receipt = this.receiptRepository.create({ - messageId: message.id, - provider: dto.provider || message.provider, - providerMessageId: dto.providerMessageId, - status: dto.status, - errorCode: dto.errorCode, - errorMessage: dto.errorMessage, - rawPayload: dto.rawPayload, - }); - - this.applyStatus(message, dto.status, dto.errorMessage); - await this.messageRepository.save(message); - return this.receiptRepository.save(receipt); - } - - async findOne(id: string): Promise { - const message = await this.messageRepository.findOne({ - where: { id }, - relations: ['receipts'], - }); - - if (!message) { - throw new NotFoundException(`SMS message ${id} not found`); + if (dto.from) { + query.andWhere('sms.createdAt >= :from', { from: new Date(dto.from) }); } - return message; - } - - async history(options: { - userId?: string; - phone?: string; - status?: MessageStatus; - from?: Date; - to?: Date; - limit?: number; - offset?: number; - }): Promise<{ messages: Message[]; total: number }> { - const where: any = {}; - - if (options.userId) { - where.userId = options.userId; - } - if (options.phone) { - where.toPhoneNumber = this.normalizePhone(options.phone); - } - if (options.status) { - where.status = options.status; - } - if (options.from && options.to) { - where.createdAt = Between(options.from, options.to); + if (dto.to) { + query.andWhere('sms.createdAt <= :to', { to: new Date(dto.to) }); } - const [messages, total] = await this.messageRepository.findAndCount({ - where, - order: { createdAt: 'DESC' }, - take: options.limit || 50, - skip: options.offset || 0, - }); + const [messages, total] = await query + .orderBy('sms.createdAt', 'DESC') + .take(dto.limit || 50) + .skip(dto.offset || 0) + .getManyAndCount(); return { messages, total }; } - async analytics(options: { from?: Date; to?: Date; userId?: string }) { - const query = this.messageRepository.createQueryBuilder('message'); + async getStats(filters?: { + from?: string; + to?: string; + userId?: string; + type?: SmsMessageType; + }) { + const query = this.messageRepository.createQueryBuilder('sms'); - if (options.userId) { - query.andWhere('message.userId = :userId', { userId: options.userId }); + if (filters?.from) { + query.andWhere('sms.createdAt >= :from', { from: new Date(filters.from) }); } - if (options.from && options.to) { - query.andWhere('message.createdAt BETWEEN :from AND :to', { - from: options.from, - to: options.to, - }); + if (filters?.to) { + query.andWhere('sms.createdAt <= :to', { to: new Date(filters.to) }); + } + if (filters?.userId) { + query.andWhere('sms.userId = :userId', { userId: filters.userId }); + } + if (filters?.type) { + query.andWhere('sms.type = :type', { type: filters.type }); } - const stats = await query - .select('message.status', 'status') + const rows = await query + .select('sms.status', 'status') .addSelect('COUNT(*)', 'count') - .groupBy('message.status') + .groupBy('sms.status') .getRawMany(); - const count = (status: MessageStatus) => - parseInt(stats.find((item) => item.status === status)?.count || '0', 10); - const total = stats.reduce( - (sum, item) => sum + parseInt(item.count, 10), - 0, - ); - const delivered = count(MessageStatus.DELIVERED); - const sent = count(MessageStatus.SENT) + delivered; - const failed = - count(MessageStatus.FAILED) + count(MessageStatus.UNDELIVERED); + const getCount = (status: SmsMessageStatus) => + parseInt(rows.find((row) => row.status === status)?.count || '0', 10); + + const total = rows.reduce((sum, row) => sum + parseInt(row.count, 10), 0); + const sent = getCount(SmsMessageStatus.SENT) + getCount(SmsMessageStatus.DELIVERED); + const delivered = getCount(SmsMessageStatus.DELIVERED); + const failed = getCount(SmsMessageStatus.FAILED); + const queued = + getCount(SmsMessageStatus.QUEUED) + getCount(SmsMessageStatus.PENDING); return { total, sent, delivered, failed, - scheduled: count(MessageStatus.SCHEDULED), - pending: count(MessageStatus.PENDING), + queued, deliveryRate: sent > 0 ? (delivered / sent) * 100 : 0, failureRate: total > 0 ? (failed / total) * 100 : 0, }; } - async cancel(id: string): Promise { + async cancel(id: string) { const message = await this.findOne(id); + if ( - ![MessageStatus.PENDING, MessageStatus.SCHEDULED].includes(message.status) + ![SmsMessageStatus.PENDING, SmsMessageStatus.QUEUED].includes(message.status) ) { - throw new BadRequestException( - 'Only pending or scheduled SMS messages can be cancelled', - ); + return { success: false }; } - message.status = MessageStatus.CANCELLED; - return this.messageRepository.save(message); + message.status = SmsMessageStatus.CANCELLED; + message.cancelledAt = new Date(); + await this.messageRepository.save(message); + + return { success: true }; } - @Cron(CronExpression.EVERY_MINUTE) - async sendScheduledMessages(): Promise { - const messages = await this.messageRepository.find({ - where: { - status: MessageStatus.SCHEDULED, - scheduledAt: LessThanOrEqual(new Date()), - }, - take: 50, - }); + async retry(id: string): Promise { + const message = await this.findOne(id); - for (const message of messages) { - try { - await this.dispatch(message); - } catch (error) { - this.logger.error( - `Scheduled SMS ${message.id} failed: ${error.message}`, - ); - } + if ( + ![SmsMessageStatus.FAILED, SmsMessageStatus.EXPIRED].includes(message.status) + ) { + throw new BadRequestException('Only failed or expired messages can be retried'); } + + message.status = SmsMessageStatus.QUEUED; + message.lastError = null; + message.failedAt = null; + message.nextRetryAt = new Date(); + + await this.messageRepository.save(message); + return this.dispatchMessage(message.id); } - private async dispatch(message: Message): Promise { - if (message.expiresAt && message.expiresAt < new Date()) { - message.status = MessageStatus.EXPIRED; - return this.messageRepository.save(message); + async dispatchMessage(id: string): Promise { + const message = await this.findOne(id); + return this.dispatch(message); + } + + async processDueMessages(): Promise { + if (this.processingDueMessages) { + return 0; } + this.processingDueMessages = true; + try { - const provider = this.providerFactory.getProvider(); - const result = await provider.send({ - to: message.toPhoneNumber, - from: message.fromNumber, - body: message.body, - metadata: message.metadata, - }); + const now = new Date(); + const batchSize = this.configService.get('sms.dispatch.batchSize', 25); + + const messages = await this.messageRepository + .createQueryBuilder('sms') + .where('sms.status IN (:...statuses)', { + statuses: [SmsMessageStatus.PENDING, SmsMessageStatus.QUEUED], + }) + .andWhere('(sms.scheduledAt IS NULL OR sms.scheduledAt <= :now)', { now }) + .andWhere('(sms.nextRetryAt IS NULL OR sms.nextRetryAt <= :now)', { now }) + .orderBy('sms.createdAt', 'ASC') + .take(batchSize) + .getMany(); + + for (const message of messages) { + await this.dispatch(message); + } - message.provider = result.provider; - message.providerMessageId = result.messageId; - message.status = - result.status === 'queued' ? MessageStatus.PENDING : MessageStatus.SENT; - message.sentAt = new Date(); - message.lastError = null; - } catch (error) { - message.status = MessageStatus.FAILED; - message.failedAt = new Date(); - message.lastError = error.message; + return messages.length; + } finally { + this.processingDueMessages = false; } + } - return this.messageRepository.save(message); + private async maybeDispatch(message: SmsMessage): Promise { + if ( + message.status === SmsMessageStatus.QUEUED && + message.scheduledAt && + message.scheduledAt.getTime() > Date.now() + ) { + return message; + } + + return this.dispatch(message); } - private applyStatus( - message: Message, - status: MessageStatus, - error?: string, - ): void { - message.status = status; - if (status === MessageStatus.DELIVERED) { - message.deliveredAt = new Date(); + private async dispatch(message: SmsMessage): Promise { + if ( + [ + SmsMessageStatus.CANCELLED, + SmsMessageStatus.DELIVERED, + SmsMessageStatus.PROCESSING, + ].includes(message.status) + ) { + return message; } - if ([MessageStatus.FAILED, MessageStatus.UNDELIVERED].includes(status)) { + + if (message.expiresAt && message.expiresAt.getTime() < Date.now()) { + message.status = SmsMessageStatus.EXPIRED; message.failedAt = new Date(); - message.lastError = error; + message.lastError = 'Message expired before dispatch'; + return this.messageRepository.save(message); } - } - private findOrCreateSender( - provider: string, - fromNumber: string, - ): Promise { - return this.smsRepository.findOne({ where: { provider, fromNumber } }).then( - (sender) => - sender || - this.smsRepository.save( - this.smsRepository.create({ - provider, - fromNumber, - displayName: this.configService.get('sms.defaultFrom'), - }), - ), + if (message.scheduledAt && message.scheduledAt.getTime() > Date.now()) { + message.status = SmsMessageStatus.QUEUED; + return this.messageRepository.save(message); + } + + if (message.nextRetryAt && message.nextRetryAt.getTime() > Date.now()) { + return message; + } + + message.status = SmsMessageStatus.PROCESSING; + await this.messageRepository.save(message); + + const result = await this.providers.send({ + to: message.normalizedPhoneNumber, + body: message.body, + from: this.configService.get('sms.senderId'), + metadata: message.metadata, + statusCallbackUrl: this.configService.get('sms.statusCallbackUrl'), + }); + + message.attempts += 1; + message.provider = result.provider; + message.providerMessageId = result.messageId || message.providerMessageId; + + if (result.success) { + message.status = + result.deliveryStatus === 'delivered' + ? SmsMessageStatus.DELIVERED + : SmsMessageStatus.SENT; + message.sentAt = message.sentAt || new Date(); + if (message.status === SmsMessageStatus.DELIVERED) { + message.deliveredAt = new Date(); + } + message.lastError = null; + message.nextRetryAt = null; + message.segments = result.segments || this.estimateSegments(message.body); + message.estimatedCost = this.estimateCost(message.segments); + + const saved = await this.messageRepository.save(message); + await this.receiptsService.recordProviderResult(saved, result); + return saved; + } + + return this.handleDispatchFailure( + message, + result.error || 'Provider send failed', + result.provider, ); } - private normalizePhone(phone: string): string { - return phone.replace(/[^\d+]/g, ''); - } + private async handleDispatchFailure( + message: SmsMessage, + error: string, + provider?: string, + ): Promise { + message.provider = provider || message.provider; + message.lastError = error; - private resolveFromNumber(fromNumber?: string): string { - return ( - fromNumber || - this.configService.get('sms.twilio.fromNumber') || - this.configService.get('sms.defaultFrom') - ); + if (message.attempts >= message.maxAttempts) { + message.status = SmsMessageStatus.FAILED; + message.failedAt = new Date(); + message.nextRetryAt = null; + } else { + const delay = this.configService.get( + 'sms.dispatch.retryBaseDelayMs', + 60000, + ); + message.status = SmsMessageStatus.QUEUED; + message.nextRetryAt = new Date( + Date.now() + delay * Math.pow(2, Math.max(0, message.attempts - 1)), + ); + } + + const saved = await this.messageRepository.save(message); + await this.receiptsService.recordFailure(saved, error, provider); + this.logger.warn(`SMS ${message.id} failed: ${error}`); + return saved; } - private assertPhoneAllowed(phone: string): void { - const normalized = this.normalizePhone(phone); - const now = Date.now(); - const windowMs = - this.configService.get('sms.rateLimit.windowSeconds') * 1000; - const max = this.configService.get('sms.rateLimit.max'); - const attempts = (this.rateLimits.get(normalized) || []).filter( - (timestamp) => now - timestamp < windowMs, + private async enforceRateLimit(normalizedPhoneNumber: string) { + const windowMinutes = this.configService.get( + 'sms.rateLimit.windowMinutes', + 10, ); + const maxPerWindow = this.configService.get( + 'sms.rateLimit.maxPerWindow', + 20, + ); + + const threshold = new Date(Date.now() - windowMinutes * 60 * 1000); + const recentCount = await this.messageRepository + .createQueryBuilder('sms') + .where('sms.normalizedPhoneNumber = :phone', { phone: normalizedPhoneNumber }) + .andWhere('sms.createdAt >= :threshold', { threshold }) + .getCount(); - if (attempts.length >= max) { + if (recentCount >= maxPerWindow) { throw new HttpException( 'SMS rate limit exceeded for this phone number', HttpStatus.TOO_MANY_REQUESTS, ); } - - attempts.push(now); - this.rateLimits.set(normalized, attempts); } - private generateCode(): string { - const length = this.configService.get('sms.otp.length'); - const min = 10 ** (length - 1); - const max = 10 ** length - 1; - return randomInt(min, max).toString(); + private estimateSegments(body: string): number { + return Math.max(1, Math.ceil(body.length / 160)); } - private hashOtp(phone: string, code: string): string { - return createHash('sha256') - .update( - `${this.normalizePhone(phone)}:${code}:${process.env.OTP_SECRET || 'sms-service-dev-secret'}`, - ) - .digest('hex'); + private estimateCost(segments: number): number { + return parseFloat((segments * 0.05).toFixed(2)); } } diff --git a/microservices/sms-service/src/templates/dto/index.ts b/microservices/sms-service/src/templates/dto/index.ts new file mode 100644 index 0000000..4f6a8a9 --- /dev/null +++ b/microservices/sms-service/src/templates/dto/index.ts @@ -0,0 +1 @@ +export * from './template.dto'; diff --git a/microservices/sms-service/src/templates/dto/template.dto.ts b/microservices/sms-service/src/templates/dto/template.dto.ts new file mode 100644 index 0000000..f2196a6 --- /dev/null +++ b/microservices/sms-service/src/templates/dto/template.dto.ts @@ -0,0 +1,62 @@ +import { IsArray, IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; + +export class CreateSmsTemplateDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsString() + @IsNotEmpty() + body: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsArray() + variables?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateSmsTemplateDto { + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + body?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsBoolean() + active?: boolean; + + @IsOptional() + @IsArray() + variables?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class RenderSmsTemplateDto { + @IsString() + @IsNotEmpty() + templateName: string; + + @IsObject() + variables: Record; +} diff --git a/microservices/sms-service/src/templates/entities/sms-template.entity.ts b/microservices/sms-service/src/templates/entities/sms-template.entity.ts new file mode 100644 index 0000000..2613065 --- /dev/null +++ b/microservices/sms-service/src/templates/entities/sms-template.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'sms_templates' }) +export class SmsTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + @Index({ unique: true }) + name: string; + + @Column({ nullable: true }) + description: string; + + @Column({ type: 'text' }) + body: string; + + @Column({ nullable: true }) + category: string; + + @Column({ default: true }) + active: boolean; + + @Column({ default: 1 }) + version: number; + + @Column({ type: 'simple-json', nullable: true }) + variables: string[]; + + @Column({ type: 'simple-json', nullable: true }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/microservices/sms-service/src/templates/template-engine.service.ts b/microservices/sms-service/src/templates/template-engine.service.ts new file mode 100644 index 0000000..87991da --- /dev/null +++ b/microservices/sms-service/src/templates/template-engine.service.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as Handlebars from 'handlebars'; + +@Injectable() +export class TemplateEngineService { + private readonly logger = new Logger(TemplateEngineService.name); + private readonly compiledTemplates = new Map(); + + constructor() { + this.registerHelpers(); + } + + private registerHelpers() { + Handlebars.registerHelper('uppercase', (str: string) => str?.toUpperCase()); + Handlebars.registerHelper('lowercase', (str: string) => str?.toLowerCase()); + Handlebars.registerHelper('eq', (a: any, b: any) => a === b); + Handlebars.registerHelper('currentYear', () => new Date().getFullYear()); + } + + render(template: string, data: Record, cacheKey?: string): string { + try { + const compiled = cacheKey + ? this.getCompiledTemplate(cacheKey, template) + : Handlebars.compile(template); + return compiled(data); + } catch (error) { + this.logger.error(`Failed to render template: ${error.message}`); + throw error; + } + } + + validateTemplate(template: string): { valid: boolean; error?: string } { + try { + Handlebars.compile(template); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: error.message, + }; + } + } + + extractVariables(template: string): string[] { + const regex = /\{\{([^}]+)\}\}/g; + const variables = new Set(); + let match: RegExpExecArray | null; + + while ((match = regex.exec(template)) !== null) { + const raw = match[1].trim(); + if (!raw.startsWith('#') && !raw.startsWith('/')) { + const token = raw.split(' ')[0]; + if (token && !token.includes('(')) { + variables.add(token.replace(/^\./, '')); + } + } + } + + return Array.from(variables); + } + + private getCompiledTemplate( + cacheKey: string, + template: string, + ): HandlebarsTemplateDelegate { + let compiled = this.compiledTemplates.get(cacheKey); + + if (!compiled) { + compiled = Handlebars.compile(template); + this.compiledTemplates.set(cacheKey, compiled); + } + + return compiled; + } +} diff --git a/microservices/sms-service/src/templates/templates.controller.ts b/microservices/sms-service/src/templates/templates.controller.ts new file mode 100644 index 0000000..02b15db --- /dev/null +++ b/microservices/sms-service/src/templates/templates.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, +} from '@nestjs/common'; +import { TemplatesService } from './templates.service'; +import { + CreateSmsTemplateDto, + RenderSmsTemplateDto, + UpdateSmsTemplateDto, +} from './dto'; + +@Controller('templates') +export class TemplatesController { + constructor(private readonly templatesService: TemplatesService) {} + + @Post() + create(@Body() dto: CreateSmsTemplateDto) { + return this.templatesService.create(dto); + } + + @Get() + findAll() { + return this.templatesService.findAll(); + } + + @Get('name/:name') + findByName(@Param('name') name: string) { + return this.templatesService.findByName(name); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.templatesService.findOne(id); + } + + @Put(':id') + update(@Param('id') id: string, @Body() dto: UpdateSmsTemplateDto) { + return this.templatesService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id') id: string) { + await this.templatesService.remove(id); + } + + @Post('render') + render(@Body() dto: RenderSmsTemplateDto) { + return this.templatesService.render(dto); + } + + @Post(':id/preview') + preview(@Param('id') id: string, @Body('variables') variables: Record) { + return this.templatesService.preview(id, variables || {}); + } +} diff --git a/microservices/sms-service/src/templates/templates.module.ts b/microservices/sms-service/src/templates/templates.module.ts index e33dd68..5e072a6 100644 --- a/microservices/sms-service/src/templates/templates.module.ts +++ b/microservices/sms-service/src/templates/templates.module.ts @@ -1,8 +1,14 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SmsTemplate } from './entities/sms-template.entity'; +import { TemplateEngineService } from './template-engine.service'; +import { TemplatesController } from './templates.controller'; import { TemplatesService } from './templates.service'; @Module({ - providers: [TemplatesService], - exports: [TemplatesService], + imports: [TypeOrmModule.forFeature([SmsTemplate])], + controllers: [TemplatesController], + providers: [TemplateEngineService, TemplatesService], + exports: [TemplateEngineService, TemplatesService], }) export class TemplatesModule {} diff --git a/microservices/sms-service/src/templates/templates.service.ts b/microservices/sms-service/src/templates/templates.service.ts index d02985c..93c1e2c 100644 --- a/microservices/sms-service/src/templates/templates.service.ts +++ b/microservices/sms-service/src/templates/templates.service.ts @@ -1,36 +1,106 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import * as Handlebars from 'handlebars'; - -type TemplateDefinition = { - body: string; -}; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TemplateEngineService } from './template-engine.service'; +import { + CreateSmsTemplateDto, + RenderSmsTemplateDto, + UpdateSmsTemplateDto, +} from './dto'; +import { SmsTemplate } from './entities/sms-template.entity'; @Injectable() export class TemplatesService { - private readonly templates = new Map([ - [ - 'otp', - { - body: 'Your Quest verification code is {{code}}. It expires in {{minutes}} minutes.', - }, - ], - ['alert', { body: '{{title}}: {{message}}' }], - ['time-sensitive', { body: '{{message}} Reply STOP to opt out.' }], - ]); - - render(templateName: string, variables: Record = {}): string { - const template = this.templates.get(templateName); + constructor( + @InjectRepository(SmsTemplate) + private readonly templateRepository: Repository, + private readonly templateEngine: TemplateEngineService, + ) {} + + async create(dto: CreateSmsTemplateDto): Promise { + const validation = this.templateEngine.validateTemplate(dto.body); + if (!validation.valid) { + throw new BadRequestException(validation.error); + } + + const template = this.templateRepository.create({ + ...dto, + variables: dto.variables?.length + ? dto.variables + : this.templateEngine.extractVariables(dto.body), + }); + + return this.templateRepository.save(template); + } + + findAll(): Promise { + return this.templateRepository.find({ + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: string): Promise { + const template = await this.templateRepository.findOne({ where: { id } }); + if (!template) { + throw new NotFoundException(`Template with ID ${id} not found`); + } + return template; + } + + async findByName(name: string): Promise { + const template = await this.templateRepository.findOne({ where: { name } }); if (!template) { - throw new NotFoundException(`SMS template "${templateName}" not found`); + throw new NotFoundException(`Template ${name} not found`); } + return template; + } + + findByNameOrNull(name: string): Promise { + return this.templateRepository.findOne({ where: { name } }); + } + + async update(id: string, dto: UpdateSmsTemplateDto): Promise { + const template = await this.findOne(id); + + if (dto.body) { + const validation = this.templateEngine.validateTemplate(dto.body); + if (!validation.valid) { + throw new BadRequestException(validation.error); + } + } + + const merged = this.templateRepository.merge(template, dto); + if (dto.body) { + merged.version += 1; + merged.variables = + dto.variables?.length || dto.variables === undefined + ? dto.variables || this.templateEngine.extractVariables(dto.body) + : []; + } + + return this.templateRepository.save(merged); + } + + async remove(id: string): Promise { + const template = await this.findOne(id); + await this.templateRepository.remove(template); + } - return Handlebars.compile(template.body)(variables); + async render(dto: RenderSmsTemplateDto): Promise<{ body: string }> { + const template = await this.findByName(dto.templateName); + return { + body: this.templateEngine.render(template.body, dto.variables, template.name), + }; } - list(): Array<{ name: string; body: string }> { - return Array.from(this.templates.entries()).map(([name, template]) => ({ - name, - body: template.body, - })); + async preview(id: string, variables: Record) { + const template = await this.findOne(id); + return { + body: this.templateEngine.render(template.body, variables, template.name), + }; } } diff --git a/microservices/sms-service/test/app.e2e-spec.ts b/microservices/sms-service/test/app.e2e-spec.ts new file mode 100644 index 0000000..1ec8702 --- /dev/null +++ b/microservices/sms-service/test/app.e2e-spec.ts @@ -0,0 +1,139 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('SmsService E2E', () => { + let app: INestApplication; + let templateName: string; + let messageId: string; + + beforeAll(async () => { + process.env.DB_TYPE = 'sqljs'; + process.env.DB_NAME = `sms-service-test-${Date.now()}`; + process.env.SMS_PROVIDER = 'mock'; + process.env.SMS_DEBUG_EXPOSE_CODES = 'true'; + process.env.SMS_DISPATCH_INTERVAL_MS = '50'; + + templateName = `alert-template-${Date.now()}`; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /health returns ok', () => { + return request(app.getHttpServer()) + .get('/health') + .expect(200) + .expect((res) => { + expect(res.body.status).toBe('ok'); + }); + }); + + it('POST /templates creates an SMS template', () => { + return request(app.getHttpServer()) + .post('/templates') + .send({ + name: templateName, + body: 'Hello {{name}}, your quest starts at {{time}}.', + category: 'alert', + }) + .expect(201) + .expect((res) => { + expect(res.body.id).toBeDefined(); + expect(res.body.name).toBe(templateName); + }); + }); + + it('POST /sms/send-templated sends through the mock provider', () => { + return request(app.getHttpServer()) + .post('/sms/send-templated') + .send({ + phoneNumber: '+14155552671', + templateName, + variables: { + name: 'Ayo', + time: '08:00', + }, + type: 'alert', + }) + .expect(201) + .expect((res) => { + expect(res.body.id).toBeDefined(); + expect(['sent', 'delivered']).toContain(res.body.status); + expect(res.body.provider).toBe('mock'); + messageId = res.body.id; + }); + }); + + it('GET /sms/history returns tracked messages', () => { + return request(app.getHttpServer()) + .get('/sms/history') + .query({ phoneNumber: '+14155552671' }) + .expect(200) + .expect((res) => { + expect(res.body.total).toBeGreaterThanOrEqual(1); + expect(Array.isArray(res.body.messages)).toBe(true); + }); + }); + + it('GET /receipts/message/:messageId returns receipt events', () => { + return request(app.getHttpServer()) + .get(`/receipts/message/${messageId}`) + .expect(200) + .expect((res) => { + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + }); + + it('POST /otp/send and /otp/verify completes OTP flow', async () => { + const sendResponse = await request(app.getHttpServer()) + .post('/otp/send') + .send({ + phoneNumber: '+14155552672', + purpose: 'login', + }) + .expect(201); + + expect(sendResponse.body.otpId).toBeDefined(); + expect(sendResponse.body.debugCode).toHaveLength(6); + + await request(app.getHttpServer()) + .post('/otp/verify') + .send({ + phoneNumber: '+14155552672', + purpose: 'login', + code: sendResponse.body.debugCode, + }) + .expect(201) + .expect((res) => { + expect(res.body.verified).toBe(true); + }); + }); + + it('GET /sms/stats exposes analytics', () => { + return request(app.getHttpServer()) + .get('/sms/stats') + .expect(200) + .expect((res) => { + expect(res.body.total).toBeGreaterThanOrEqual(1); + expect(res.body.deliveryRate).toBeDefined(); + }); + }); +}); diff --git a/microservices/sms-service/test/jest-e2e.json b/microservices/sms-service/test/jest-e2e.json index e9d912f..3bd6e60 100644 --- a/microservices/sms-service/test/jest-e2e.json +++ b/microservices/sms-service/test/jest-e2e.json @@ -2,7 +2,7 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "testRegex": ".*\\.e2e-spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" } diff --git a/microservices/sms-service/tsconfig.build.json b/microservices/sms-service/tsconfig.build.json index 64f86c6..4912473 100644 --- a/microservices/sms-service/tsconfig.build.json +++ b/microservices/sms-service/tsconfig.build.json @@ -1,4 +1,9 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": [ + "node_modules", + "test", + "dist", + "**/*spec.ts" + ] } diff --git a/microservices/sms-service/tsconfig.json b/microservices/sms-service/tsconfig.json index 95f5641..3c62a0f 100644 --- a/microservices/sms-service/tsconfig.json +++ b/microservices/sms-service/tsconfig.json @@ -12,7 +12,7 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, + "strict": false, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..1d834f9 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": ["./**/*", "../src/types.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json index c8eb696..f1c4b25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,10 +31,6 @@ "exclude": [ "node_modules", "dist", - "test", - "src/**/*.spec.ts", - "src/**/__tests__/**", - "src/**/tests/**", "src/File Storage and CDN Service Setup/**", "src/Scheduler and Cron Service Setup/**" ]