diff --git a/.gitignore b/.gitignore index 9a6ace3..7dd0e19 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ coverage docs/generated file-output .vscode -src/di/default/temp \ No newline at end of file +src/di/default/temp +.env \ No newline at end of file diff --git a/README.md b/README.md index 784a9e4..4c9f991 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Zibri is an opiniated typescript backend framework based on express. It's heavily inspired by frameworks like [LoopBack](https://loopback.io/doc/en/lb4/index.html) and [Nest](https://docs.nestjs.com/). -What differentiates it from such frameworks can be found in our [goals section](#goals). +What differentiates it from such frameworks can be found in our [goals section](#-goals). # 📑 Documentation The official documentation of Zibri can be found under [https://service-soft.github.io/zibri](https://service-soft.github.io/zibri) @@ -54,7 +54,7 @@ With Zibri you can rely on a strong foundation of battle tested libraries that h - express as the server - typeorm for handling everything database related - nodemailer for sending emails -- handlebars for templating +- handlebars and preact for templating - busboy for file uploads (the foundation of multer) - socket.io for websockets - node-cron for cron jobs diff --git a/docs/http-client.md b/docs/http-client.md index d2d9adf..15ecda3 100644 --- a/docs/http-client.md +++ b/docs/http-client.md @@ -8,12 +8,7 @@ import { HttpClientInterface, inject, ZIBRI_DI_TOKENS } from 'zibri'; // ... const http: HttpClientInterface = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); // undefined is assumed for the response body because we did not define anything -const response: HttpClientResponse = await http.get( - 'https://some-api.com/tests', - { - - } -); +const response: HttpClientResponse = await http.get('https://some-api.com/tests'); // ... ``` diff --git a/docs/templating.md b/docs/templating.md index 3dc8f30..28368d6 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -38,7 +38,7 @@ These are also hot reloaded when you change your templates and eg. introduce or # Pages For simple html pages Zibri uses [preact](https://preactjs.com/), but with some [heavy modifications](#additional-functionality). -This system is pretty great for a server side framework to render some basic pages. If you have more advanced use cases you should however you will probably be better of by creating a separate client application that consumes the Zibri API. +This system is pretty great for a server side framework to render some basic pages. If you have more advanced use cases however you will probably be better of by creating a separate client application that consumes the Zibri API. ```ts import { Controller, Get, GlobalRegistry, HtmlResponse, PreactUtilities, Response } from 'zibri'; @@ -115,7 +115,7 @@ NO imports are resolved automatically. This means that you can't simply leak server side secrets like api keys to the client just because you used `environment.apiUrl` somewhere and the `index.ts` where its imported from also contains some secrets that tsx compiles into the code. (A reoccuring problem with frameworks that mix the line between front- and backend) -But this also means that the functionality is a lot more restrictive than React, because every hook has to be custom provided. There are currently only two hooks available: `onClient` and `onServer`. Everything else that you might know (useState etc.) simply won't work. +But this also means that the functionality is a lot more restrictive than React, because every hook has to be custom provided. There are currently only two hooks available: `onClient` and `onServer`. Everything else that you might know (useState etc.) simply won't work (yet). Let's take the metrics page as an example (full content down below), because we have a lot of client functionality that gets restored. The first thing you will probably notice is the strange import at the start of the file: @@ -143,7 +143,7 @@ let snaps: MetricsSnapshot[] = []; let automaticReload: boolean = true; // the onClient hook provides a way to mark logic that should only be called on the client. -// In this case window would throw an error on the server side, because it does not exist there. +// In this case, window would throw an error on the server side, because it does not exist there. onClient(() => { window.addEventListener('load', () => { void loadSnapshots(); diff --git a/jest.config.mjs b/jest.config.mjs index 3993075..0f77ff3 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -9,7 +9,7 @@ const config = { setupFilesAfterEnv: ['/jest.setup.ts'], bail: false, modulePathIgnorePatterns: ['tmp'], - // testPathIgnorePatterns: ['/data-source/transaction/transaction.test.ts'], + testPathIgnorePatterns: ['/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts'], // coverage // collectCoverage: true, coverageProvider: 'v8', diff --git a/package-lock.json b/package-lock.json index fbd27ea..0c2d5cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zibri", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zibri", - "version": "2.2.1", + "version": "2.3.0", "license": "MIT", "dependencies": { "@fastify/busboy": "^3.2.0", @@ -14,7 +14,7 @@ "express": "^5.1.0", "glob": "^13.0.6", "node-cron": "^4.2.1", - "nodemailer": "^7.0.6", + "nodemailer": "^8.0.4", "pg": "^8.16.3", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", @@ -37,7 +37,7 @@ "@types/swagger-ui-express": "^4.1.8", "@types/swagger2openapi": "^7.0.4", "eslint": "^9.36.0", - "eslint-config-service-soft": "^2.1.5", + "eslint-config-service-soft": "^2.1.6", "jest": "^30.2.0", "npm-run-all": "^4.1.5", "openapi3-ts": "^4.5.0", @@ -68,13 +68,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2003.18", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.18.tgz", - "integrity": "sha512-pPEDby3wQb40YSpH+UrjodJ78Z7q0Qvy3DTkS7mP2EIM4r0WVz8OlxLGS2uAc6tXSbIZe0bPp0B56P6uet3tUw==", + "version": "0.2003.22", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.22.tgz", + "integrity": "sha512-gxVOslVweD+Co6gpRVlByHus/3HVAnsl99MobS9PBh8vh2g6bJ011PBgl0TKsP/pqBGawZOkJXYrRPeMKnobYA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.18", + "@angular-devkit/core": "20.3.22", "rxjs": "7.8.2" }, "engines": { @@ -84,16 +84,16 @@ } }, "node_modules/@angular-devkit/core": { - "version": "20.3.18", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.18.tgz", - "integrity": "sha512-zGWMjMqE8qXYr8baYCs43k9HlKz9J4Gh3Yx+7XE0uS0Y1LXzzALevSoUw7GIPdSvOriQJAEgtWE6QKssqSGltQ==", + "version": "20.3.22", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.22.tgz", + "integrity": "sha512-1vZnZTAjGcCM+86v2al+2eiROiSw0uAWeVllfHSQe0KsKOP1FE8UUUiWChhxVn7vIxypphlfGunkeeIn1C/ZFw==", "dev": true, "license": "MIT", "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.3", + "picomatch": "4.0.4", "rxjs": "7.8.2", "source-map": "0.7.6" }, @@ -136,13 +136,13 @@ "license": "MIT" }, "node_modules/@angular-devkit/schematics": { - "version": "20.3.18", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.18.tgz", - "integrity": "sha512-GRMEGl3YTL/qhQhaxYXLbSQxUTPTYMQ65IlxLQRq5+UKPomN9KVxxVdADXqs7Ss1uQcetr+jc+taVgxOqsAoxg==", + "version": "20.3.22", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.22.tgz", + "integrity": "sha512-gN2XSXRn3eErGEJlH0iSfQZZ7NdxVZNdjSxuVEGBEFhe3cVeC21LzM3GTWW6xwtBb4pxHglFyc7BUFiYtZiYtg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.18", + "@angular-devkit/core": "20.3.22", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "8.2.0", @@ -2324,9 +2324,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -3780,9 +3780,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -4324,9 +4324,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4384,9 +4384,9 @@ } }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -5065,9 +5065,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -6730,9 +6730,9 @@ } }, "node_modules/eslint-config-service-soft": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-service-soft/-/eslint-config-service-soft-2.1.5.tgz", - "integrity": "sha512-ltlHaeKl3nCvVhQzMQ9Dh29FMJru4sn+addh2eeZLfR3m0nflBLDukFoS/QHwqjKFtYISrIsjesm0fnbd+Rjyw==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-service-soft/-/eslint-config-service-soft-2.1.6.tgz", + "integrity": "sha512-sD3wDHw0rPk5HE5kA3cArajjLGVocLw/hY92Ohgu+zBa6CA2fjhvOaM1CE3qHxaYNWZ1cGm5UT4P4Q3Ee1O6tA==", "dev": true, "license": "ISC", "dependencies": { @@ -6753,7 +6753,7 @@ "eslint-plugin-jsdoc": "62.7.0", "eslint-plugin-jsonc": "3.0.0", "eslint-plugin-promise": "7.2.1", - "eslint-plugin-sonarjs": "4.0.0", + "eslint-plugin-sonarjs": "4.0.2", "eslint-plugin-unicorn": "63.0.0", "eslint-plugin-unused-imports": "4.4.1", "jsonc-eslint-parser": "3.1.0" @@ -7223,23 +7223,23 @@ } }, "node_modules/eslint-plugin-sonarjs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.0.tgz", - "integrity": "sha512-ihyH9HO52OeeWer/gWRndkW/ZhGqx9HDg+Iptu+ApSfiomT2LzhHgHCoyJrhh7DjCyKhjU3Hmmz1pzcXRf7B3g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.2.tgz", + "integrity": "sha512-BTcT1zr1iTbmJtVlcesISwnXzh+9uhf9LEOr+RRNf4kR8xA0HQTPft4oiyOCzCOGKkpSJxjR8ZYF6H7VPyplyw==", "dev": true, "license": "LGPL-3.0-only", "dependencies": { - "@eslint-community/regexpp": "4.12.2", - "builtin-modules": "3.3.0", - "bytes": "3.1.2", - "functional-red-black-tree": "1.0.1", - "globals": "17.3.0", - "jsx-ast-utils-x": "0.1.0", - "lodash.merge": "4.6.2", - "minimatch": "10.2.1", - "scslre": "0.3.0", - "semver": "7.7.4", - "ts-api-utils": "2.4.0", + "@eslint-community/regexpp": "^4.12.2", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.4.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.4", + "scslre": "^0.3.0", + "semver": "^7.7.4", + "ts-api-utils": "^2.4.0", "typescript": ">=5" }, "peerDependencies": { @@ -7257,9 +7257,9 @@ } }, "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7270,9 +7270,9 @@ } }, "node_modules/eslint-plugin-sonarjs/node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -7283,16 +7283,16 @@ } }, "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", - "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7815,9 +7815,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -8209,15 +8209,15 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/jackspeak": { @@ -8316,9 +8316,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -9477,9 +9477,9 @@ } }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -9835,9 +9835,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -10428,9 +10428,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -10713,9 +10713,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10951,9 +10951,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", - "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -11206,9 +11206,9 @@ } }, "node_modules/oas-linter/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -11234,9 +11234,9 @@ } }, "node_modules/oas-resolver/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -11271,9 +11271,9 @@ } }, "node_modules/oas-validator/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -11720,9 +11720,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -11856,9 +11856,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -12363,9 +12363,9 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -13064,9 +13064,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13105,9 +13105,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -13767,9 +13767,9 @@ } }, "node_modules/swagger2openapi/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -14347,9 +14347,9 @@ } }, "node_modules/typedoc/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -14475,9 +14475,9 @@ } }, "node_modules/typeorm/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -14619,9 +14619,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "dev": true, "license": "MIT", "engines": { @@ -15209,9 +15209,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index a4f3ee7..6b13cc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zibri", - "version": "2.2.1", + "version": "2.3.0", "main": "./dist/cjs/index.js", "types": "./dist/cjs/index.d.ts", "module": "./dist/esm/index.mjs", @@ -71,7 +71,7 @@ "express": "^5.1.0", "glob": "^13.0.6", "node-cron": "^4.2.1", - "nodemailer": "^7.0.6", + "nodemailer": "^8.0.4", "pg": "^8.16.3", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", @@ -94,7 +94,7 @@ "@types/swagger-ui-express": "^4.1.8", "@types/swagger2openapi": "^7.0.4", "eslint": "^9.36.0", - "eslint-config-service-soft": "^2.1.5", + "eslint-config-service-soft": "^2.1.6", "jest": "^30.2.0", "npm-run-all": "^4.1.5", "openapi3-ts": "^4.5.0", diff --git a/sandbox/src/controllers/file.controller.ts b/sandbox/src/controllers/file.controller.ts index 4890d5b..110a57e 100644 --- a/sandbox/src/controllers/file.controller.ts +++ b/sandbox/src/controllers/file.controller.ts @@ -1,4 +1,4 @@ -import { AssetService, Body, Controller, File, FileResponse, FormData, Get, Inject, MimeType, Post, Property, Response, ZIBRI_DI_TOKENS, FsUtilities, Path } from 'zibri'; +import { AssetService, Body, Controller, File, FileResponse, FormData, Get, Inject, MimeType, Post, Property, Response, ZIBRI_DI_TOKENS, FsUtilities, FsPath } from 'zibri'; export class FileCreateDTO { @Property.file({ allowedMimeTypes: [MimeType.JSON] }) @@ -25,7 +25,7 @@ export class FileController { @Response.file() @Get('/stream') async findDocumentFor(): Promise { - const assetPath: Path = FsUtilities.getPath(this.assetService.publicAssetsPath, 'logo.jpg'); + const assetPath: FsPath = FsUtilities.getPath(this.assetService.publicAssetsPath, 'logo.jpg'); return FileResponse.fromStream({ stream: FsUtilities.createReadStream(assetPath), filename: 'logo.jpg', diff --git a/sandbox/src/controllers/index.ts b/sandbox/src/controllers/index.ts index 571a4b5..ff1db70 100644 --- a/sandbox/src/controllers/index.ts +++ b/sandbox/src/controllers/index.ts @@ -5,5 +5,4 @@ export * from './template.controller'; export * from './metrics.controller'; export * from './test-crud.controller'; export * from './test.websocket-controller'; -export * from './page.controller'; -export * from './mailing-list.controller'; \ No newline at end of file +export * from './page.controller'; \ No newline at end of file diff --git a/sandbox/src/controllers/mailing-list.controller.ts b/sandbox/src/controllers/mailing-list.controller.ts deleted file mode 100644 index d805d8e..0000000 --- a/sandbox/src/controllers/mailing-list.controller.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Body, Controller, Get, HtmlResponse, InjectRepository, MailingList, MailingListService, MailingListSubscriber, Param, Patch, PreactUtilities, Repository, Response, UpdateMailingListPreferences } from 'zibri'; - -import { MailingListPreferencesPage } from '../templates/pages/mailing-list-preferences'; -import { MailingListUnsubscribeConfirmationPage } from '../templates/pages/mailing-list-unsubscribe-confirmation'; - -@Controller('/mailing-lists') -export class MailingListController { - - constructor( - @InjectRepository(MailingListSubscriber) - private readonly subscriberRepository: Repository, - @InjectRepository(MailingList) - private readonly mailingListRepository: Repository, - private readonly mailingListService: MailingListService - ) {} - - @Response.html() - @Get('/:id/unsubscribe') - async unsubscribe( - @Param.path('id') - id: string, - @Param.query('subscriberId') - subscriberId: string - ): Promise { - const subscriber: MailingListSubscriber = await this.subscriberRepository.findById(subscriberId); - const mailingList: MailingList = await this.mailingListRepository.findById(id); - - await this.mailingListService.unsubscribeFromList(id, subscriberId); - - return PreactUtilities.renderResponse(MailingListUnsubscribeConfirmationPage, { subscriber, mailingList }); - } - - @Response.html() - @Get('/preferences') - async preferences( - @Param.query('subscriberId') - subscriberId: string - ): Promise { - const subscriber: MailingListSubscriber = await this.subscriberRepository.findById(subscriberId); - const mailingLists: MailingList[] = await this.mailingListRepository.findAll(); - return PreactUtilities.renderResponse(MailingListPreferencesPage, { subscriber, mailingLists }); - } - - @Response.empty() - @Patch('/preferences') - async changePreferences( - @Param.query('subscriberId') - subscriberId: string, - @Body(UpdateMailingListPreferences) - body: UpdateMailingListPreferences - ): Promise { - const mailingLists: MailingList[] = await this.mailingListRepository.findAll({ where: { id: { oneOf: body.mailingListIds } } }); - await this.subscriberRepository.updateById(subscriberId, { mailingLists }); - } -} \ No newline at end of file diff --git a/sandbox/src/controllers/template.controller.ts b/sandbox/src/controllers/template.controller.ts index a53e32d..400c871 100644 --- a/sandbox/src/controllers/template.controller.ts +++ b/sandbox/src/controllers/template.controller.ts @@ -1,26 +1,9 @@ -import { Controller, errorToLoggedError, FormatDateFn, Get, GlobalRegistry, HtmlResponse, HttpMethod, inject, Log, LogLevel, Param, PreactUtilities, Response, UUIDUtilities, ZIBRI_DI_TOKENS } from 'zibri'; +import { Controller, errorToLoggedError, Get, HtmlResponse, HttpMethod, Log, LogLevel, Param, PreactUtilities, Response, UUIDUtilities } from 'zibri'; -import renderBaseEmail from '../templates/emails/base-email.hbs'; -import renderLog from '../templates/emails/log.hbs'; -import renderPasswordResetTemplate from '../templates/emails/password-reset.hbs'; +import { LogEmail } from '../templates/emails/log'; +import { PasswordResetEmail } from '../templates/emails/password-reset'; import { SocketIoTestPage } from '../templates/pages/socket-io-test'; -const logLevelLabels: Record = { - [LogLevel.DEBUG]: 'Debug Log', - [LogLevel.INFO]: 'Info Log', - [LogLevel.WARN]: 'Warning', - [LogLevel.ERROR]: 'Error', - [LogLevel.CRITICAL]: 'Critical Error' -}; - -const bgColorForLogLevel: Record = { - [LogLevel.DEBUG]: '#00b4d8', - [LogLevel.INFO]: '#00b4d8', - [LogLevel.WARN]: '#edff4aff', - [LogLevel.ERROR]: '#ff5959ff', - [LogLevel.CRITICAL]: '#cc6cffff' -}; - @Controller('/templates') export class TemplateController { @@ -33,27 +16,18 @@ export class TemplateController { @Response.html() @Get('/password-reset-mail') getMailTemplate(): HtmlResponse { - const content: string = renderPasswordResetTemplate({ - confirmPasswordResetUrl: 'http://localhost:4200/confirm-password-reset', - resetToken: 'test-token', - user: { name: 'Max Mustermann' } - }); - const html: string = renderBaseEmail({ - content, - base: { - title: 'Password Reset', - baseUrl: 'http://localhost:3000', - mailingListData: { - mailingList: { - id: '42' - }, - mailingListBaseRoute: 'mailing-lists', - subscriber: { - id: '43' - } + const html: string = PreactUtilities.renderEmail( + PasswordResetEmail, + { + confirmPasswordResetLink: 'http://localhost:4200/confirm-password-reset/test-token', + user: { + id: '42', + email: 'admin@test.com', + name: 'root', + roles: [] } } - }); + ); return HtmlResponse.fromString(html); } @@ -63,8 +37,6 @@ export class TemplateController { @Param.query('level', { type: 'number', min: LogLevel.DEBUG, max: LogLevel.CRITICAL }) logLevel: LogLevel ): HtmlResponse { - const formatDate: FormatDateFn = inject(ZIBRI_DI_TOKENS.FORMAT_DATE); - // eslint-disable-next-line unicorn/error-message const line: string = (new Error().stack ?? '').split('\n')[1]; const matches: RegExpMatchArray | null = line.match(/\((.*):\d+:\d+\)/); @@ -81,26 +53,13 @@ export class TemplateController { request: { method: HttpMethod.GET, url: 'http://localhost:3000/templates/log', - clientIp: '192.168.237.42', + clientIp: '123.456.789.10', userAgent: 'Mozilla/Firefox' } } }; - const content: string = renderLog({ - // eslint-disable-next-line typescript/no-unsafe-assignment, typescript/no-explicit-any - log: log as any, - levelName: logLevelLabels[log.level], - appName: GlobalRegistry.getAppData('name') ?? '', - boxBgColor: bgColorForLogLevel[log.level], - createdAtString: formatDate(log.createdAt, true) - }); - const html: string = renderBaseEmail({ - content, - base: { - title: 'New log event', - baseUrl: 'http://localhost:3000' - } - }); - return HtmlResponse.fromString(html); + + const preactHtml: string = PreactUtilities.renderEmail(LogEmail, { log }); + return HtmlResponse.fromString(preactHtml); } } \ No newline at end of file diff --git a/sandbox/src/create-default-data.function.ts b/sandbox/src/create-default-data.function.ts index 79af4c3..e9a435b 100644 --- a/sandbox/src/create-default-data.function.ts +++ b/sandbox/src/create-default-data.function.ts @@ -24,7 +24,7 @@ async function createDefaultAdmin(dataSource: DataSourceInterface): Promise { const app: ZibriApplication = new ZibriApplication({ name: 'Zibri Api', baseUrl: 'http://localhost:3000', - plugins: [new ZibriInvoicingPlugin()], + plugins: [new ZibriInvoicingPlugin(), new ZibriMailingListPlugin()], controllers: [ TestController, FileController, diff --git a/sandbox/src/models/user.model.ts b/sandbox/src/models/user.model.ts index 8bdcc03..56b18db 100644 --- a/sandbox/src/models/user.model.ts +++ b/sandbox/src/models/user.model.ts @@ -6,6 +6,9 @@ import { OmitStrict } from '../types'; @Entity() export class User extends BaseUserEntity(Roles) { + @Property.string() + name!: string; + @Property.manyToOne({ target: () => Company, inverseSide: 'workers', required: false }) company?: Company; } diff --git a/sandbox/src/providers.ts b/sandbox/src/providers.ts index 42322d6..4b6b112 100644 --- a/sandbox/src/providers.ts +++ b/sandbox/src/providers.ts @@ -1,6 +1,12 @@ -import { defineProvider, DiProvider, LoggerTransport, LogLevel, ZIBRI_DI_TOKENS, ZIBRI_INVOICING_DI_TOKENS } from 'zibri'; +import { defineProvider, DiProvider, LoggerTransport, LogLevel, ZIBRI_DI_TOKENS, ZIBRI_INVOICING_PLUGIN_DI_TOKENS, ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS } from 'zibri'; +import { MailingListBaseEmail } from './templates/email-components/mailing-list-base-email'; +import { MailingListSubscribeConfirmationEmail } from './templates/emails/mailing-list-subscribe-confirmation'; +import { PasswordResetEmail } from './templates/emails/password-reset'; import { ErrorPage } from './templates/pages/error'; +import { MailingListPreferencesPage } from './templates/pages/mailing-list-preferences'; +import { MailingListUnsubscribeConfirmationPage } from './templates/pages/mailing-list-unsubscribe-confirmation'; +import { SubscribeSuccessPage } from './templates/pages/subscribe-success'; export const providers: DiProvider[] = [ defineProvider({ @@ -11,6 +17,10 @@ export const providers: DiProvider[] = [ token: ZIBRI_DI_TOKENS.LOGGER_TRANSPORTS, useFactory: () => [LoggerTransport.console(LogLevel.INFO)] }), + defineProvider({ + token: ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE, + useFactory: () => PasswordResetEmail + }), defineProvider({ token: ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_SECRET, useFactory: () => 'test' @@ -39,7 +49,27 @@ export const providers: DiProvider[] = [ useFactory: () => 'http://localhost:4200/confirm-password-reset' }), defineProvider({ - token: ZIBRI_INVOICING_DI_TOKENS.OPTIONS_INPUT, + token: ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.PREFERENCES_PAGE_TEMPLATE, + useFactory: () => MailingListPreferencesPage + }), + defineProvider({ + token: ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.UNSUBSCRIBE_CONFIRMATION_PAGE_TEMPLATE, + useFactory: () => MailingListUnsubscribeConfirmationPage + }), + defineProvider({ + token: ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_CONFIRMATION_EMAIL_TEMPLATE, + useFactory: () => MailingListSubscribeConfirmationEmail + }), + defineProvider({ + token: ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.BASE_EMAIL_TEMPLATE, + useFactory: () => MailingListBaseEmail + }), + defineProvider({ + token: ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_SUCCESS_PAGE_TEMPLATE, + useFactory: () => SubscribeSuccessPage + }), + defineProvider({ + token: ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS_INPUT, useFactory: () => { return { companyInfo: { diff --git a/sandbox/src/repositories/user.repository.ts b/sandbox/src/repositories/user.repository.ts index 3a8f3c4..add057f 100644 --- a/sandbox/src/repositories/user.repository.ts +++ b/sandbox/src/repositories/user.repository.ts @@ -1,4 +1,4 @@ -import { inject, InjectRepository, JwtCredentials, Repository, repositoryTokenFor, UserRepo, UserRepositoryInterface } from 'zibri'; +import { Inject, inject, InjectRepository, JwtCredentials, LoggerInterface, Repository, repositoryTokenFor, UserRepo, UserRepositoryInterface, ZIBRI_DI_TOKENS } from 'zibri'; import { Roles, User, UserCreateData } from '../models'; @@ -8,9 +8,11 @@ export class UserRepository extends Repository constructor( @InjectRepository(User) - repo: Repository + repo: Repository, + @Inject(ZIBRI_DI_TOKENS.LOGGER) + logger: LoggerInterface ) { - super(User, repo); + super(User, repo, logger); } async findByEmail(email: string): Promise { diff --git a/sandbox/src/templates/email-components/base-email-data-list-item.tsx b/sandbox/src/templates/email-components/base-email-data-list-item.tsx new file mode 100644 index 0000000..4b9055a --- /dev/null +++ b/sandbox/src/templates/email-components/base-email-data-list-item.tsx @@ -0,0 +1,29 @@ +import { PreactEmailComponent } from 'zibri'; + +import { EmailColumn } from './email-column'; +import { EmailPre } from './email-pre'; +import { EmailText } from './email-text'; + +type Props = { + label: string, + value: string | string[], + twoRows?: boolean +}; + +export const BaseEmailDataListItem: PreactEmailComponent = ({ + label, + value, + twoRows = false +}: Props) => { + + return ( + <> + + {label}: + + + {value} + + + ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/base-email-footer.tsx b/sandbox/src/templates/email-components/base-email-footer.tsx new file mode 100644 index 0000000..65171b6 --- /dev/null +++ b/sandbox/src/templates/email-components/base-email-footer.tsx @@ -0,0 +1,26 @@ +import { GlobalRegistry, MailingListTemplateData, PreactEmailComponent } from 'zibri'; + +import { BaseMailingListFooter } from './base-mailing-list-footer'; +import { EmailColumn } from './email-column'; +import { EmailSection } from './email-section'; +import { EmailText } from './email-text'; + +type Props = { + mailingListData?: MailingListTemplateData +}; + +export const BaseEmailFooter: PreactEmailComponent = ({ + mailingListData +}) => { + const appName: string = GlobalRegistry.getAppData('name') ?? ''; + + return <> + + + Kind regards, + Your Team at {appName} + + + {mailingListData && } + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/base-email-header.tsx b/sandbox/src/templates/email-components/base-email-header.tsx new file mode 100644 index 0000000..1522486 --- /dev/null +++ b/sandbox/src/templates/email-components/base-email-header.tsx @@ -0,0 +1,30 @@ +import { GlobalRegistry, PreactEmailComponent } from 'zibri'; + +import { EmailColumn } from './email-column'; +import { EmailImage } from './email-image'; +import { EmailSection } from './email-section'; +import { EmailText } from './email-text'; + +type Props = { + children: string +}; + +export const BaseEmailHeader: PreactEmailComponent = ({ children }) => { + const appName: string = GlobalRegistry.getAppData('name') ?? ''; + return ( + + + {appName} + + + {children} + + + + ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/base-email.tsx b/sandbox/src/templates/email-components/base-email.tsx new file mode 100644 index 0000000..55f5aaa --- /dev/null +++ b/sandbox/src/templates/email-components/base-email.tsx @@ -0,0 +1,76 @@ +import { ComponentChildren } from 'preact'; +import { LanguageCode, PreactEmailComponent } from 'zibri'; + +import { EmailBody } from './email-body'; +import { EmailHead } from './email-head'; +import { EmailHtml } from './email-html'; + +type Props = { + lang?: LanguageCode, + dir?: 'auto' | 'rtl' | 'ltr', + backgroundColor?: string, + width?: string, + cssClass?: string, + textAlign?: 'left' | 'center' | 'right' | 'justify', + color?: string, + fontFamily?: string, + lineHeight?: number, + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + + title: string, + children: ComponentChildren +}; + +export const BaseEmail: PreactEmailComponent = ({ + title, + children, + dir = 'auto', + lang = 'und', + backgroundColor = '#2a2a35', + color = 'whitesmoke', + width, + cssClass, + textAlign, + fontFamily, + lineHeight, + padding, + paddingBottom = '40px', + paddingLeft, + paddingRight, + paddingTop = '40px' +}) => { + const customStyles: string = ` + .btn-primary a:hover { + background-color: #0e456f !important; + transition: background-color 300ms ease !important; + } + `; + + return ( + + + + {children} + + + ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/base-mailing-list-footer.tsx b/sandbox/src/templates/email-components/base-mailing-list-footer.tsx new file mode 100644 index 0000000..d654d77 --- /dev/null +++ b/sandbox/src/templates/email-components/base-mailing-list-footer.tsx @@ -0,0 +1,57 @@ +import { inject, MailingList, MailingListServiceInterface, MailingListSubscriber, PreactEmailComponent, ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS } from 'zibri'; + +import { EmailColumn } from './email-column'; +import { EmailLink } from './email-link'; +import { EmailSection } from './email-section'; +import { EmailText } from './email-text'; + +type Props = { + list: MailingList, + subscriber: MailingListSubscriber +}; + +export const BaseMailingListFooter: PreactEmailComponent = ({ + list, + subscriber +}) => { + const mailingListService: MailingListServiceInterface = inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.MAILING_LIST_SERVICE); + const unsubscribeLink: string = mailingListService.getUnsubscribeLink(list.id, subscriber.id); + const managePreferencesLink: string = mailingListService.getManagePreferencesLink(subscriber.id); + + return <> + + + + You are receiving this email because you are subscribed to the mailing list "{list.name}" + + + + + + + manage preferences + + + {''} + + + unsubscribe + + + + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-body.tsx b/sandbox/src/templates/email-components/email-body.tsx new file mode 100644 index 0000000..b15e721 --- /dev/null +++ b/sandbox/src/templates/email-components/email-body.tsx @@ -0,0 +1,86 @@ +import { ComponentChildren } from 'preact'; +import { PreactEmailComponent } from 'zibri'; + +import { EmailContext } from './email-context'; + +type Props = { + lang: string, + dir: 'auto' | 'rtl' | 'ltr', + children: ComponentChildren, + backgroundColor: string | undefined, + width: string | undefined, + cssClass: string | undefined, + textAlign: 'left' | 'center' | 'right' | 'justify' | undefined, + color: string | undefined, + fontFamily: string | undefined, + lineHeight: number | undefined, + padding: string | undefined, + paddingTop: string | undefined, + paddingRight: string | undefined, + paddingBottom: string | undefined, + paddingLeft: string | undefined +}; + +export const EmailBody: PreactEmailComponent = ({ + lang, + dir, + children, + backgroundColor, + width = '600px', + cssClass = '', + textAlign = 'center', + color = '#000000', + fontFamily = 'Ubuntu, Helvetica, Arial, sans-serif', + lineHeight = 1.25, + padding = '0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft +}) => { + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + const backgroundColorStyle: string = backgroundColor ? `background-color: ${backgroundColor};` : ''; + const bodyStyle: string = [ + 'word-spacing:normal', + backgroundColorStyle, + `color:${color}`, + `font-family:${fontFamily}`, + `line-height:${lineHeight}` + ].filter(Boolean).join('; '); + const outerTdStyle: string = [ + backgroundColorStyle, + `padding:${resolvedPadding}` + ].filter(Boolean).join('; '); + + return ( + + +
+ + + + + + +
+ {children} +
+
+ +
+ ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-button.tsx b/sandbox/src/templates/email-components/email-button.tsx new file mode 100644 index 0000000..fccb22f --- /dev/null +++ b/sandbox/src/templates/email-components/email-button.tsx @@ -0,0 +1,157 @@ +import { PreactEmailComponent } from 'zibri'; + +type Props = { + href: string, + children: string, + rel?: string, + target?: '_blank' | '_self' | '_parent' | '_top', + align?: 'left' | 'center' | 'right', + backgroundColor?: string, + border?: string, + borderBottom?: string, + borderLeft?: string, + borderRight?: string, + borderTop?: string, + borderRadius?: string, + color?: string, + containerBackgroundColor?: string, + fontFamily?: string, + fontSize?: string, + fontStyle?: 'normal' | 'italic' | 'oblique', + fontWeight?: string, + height?: string, + letterSpacing?: string, + lineHeight?: string, + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + innerPadding?: string, + textDecoration?: string, + textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase', + verticalAlign?: 'top' | 'middle' | 'bottom', + width?: string, + cssClass?: string, + boxShadow?: string +}; + +export const EmailButton: PreactEmailComponent = ({ + href, + children, + rel, + target = '_blank', + align = 'left', + backgroundColor = '#2a2a35', + border = 'none', + borderBottom, + borderLeft, + borderRight, + borderTop, + borderRadius = '5px', + color = '#f5f5f5', + containerBackgroundColor, + fontFamily = 'Ubuntu, Helvetica, Arial, sans-serif', + fontSize = '16px', + fontStyle = 'normal', + fontWeight = 'normal', + height, + letterSpacing, + lineHeight = '120%', + padding = '0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + innerPadding = '12px 25px', + textDecoration = 'none', + textTransform = 'none', + verticalAlign = 'middle', + width, + cssClass = '', + boxShadow +}) => { + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const resolvedBorder: string = [ + border !== 'none' ? `border:${border}` : '', + borderTop ? `border-top:${borderTop}` : '', + borderRight ? `border-right:${borderRight}` : '', + borderBottom ? `border-bottom:${borderBottom}` : '', + borderLeft ? `border-left:${borderLeft}` : '' + ].filter(Boolean).join('; '); + + const tdStyle: string = [ + containerBackgroundColor ? `background:${containerBackgroundColor}` : '', + `padding:${resolvedPadding}`, + 'word-break:break-word' + ].filter(Boolean).join('; '); + + const tableStyle: string = [ + `background-color:${backgroundColor}`, + resolvedBorder, + `border-radius:${borderRadius}`, + height ? `height:${height}` : '', + width ? `width:${width}` : '' + ].filter(Boolean).join('; '); + + const anchorStyle: string = [ + 'display:inline-block', + `background-color:${backgroundColor}`, + resolvedBorder, + `color:${color}`, + `font-family:${fontFamily}`, + `font-size:${fontSize}`, + `font-style:${fontStyle}`, + `font-weight:${fontWeight}`, + `letter-spacing:${letterSpacing ?? 'normal'}`, + `line-height:${lineHeight}`, + 'margin:0', + `text-decoration:${textDecoration}`, + `text-transform:${textTransform}`, + `padding:${innerPadding}`, + 'mso-padding-alt:0px', + `border-radius:${borderRadius}`, + boxShadow ? `box-shadow:${boxShadow}` : '' + ].filter(Boolean).join('; '); + + return <> + {containerBackgroundColor + ? `` + : ''} +
+ + + + + + +
+ {''} + + {children} + + {''} +
+
+ {containerBackgroundColor + ? '' + : ''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-column.tsx b/sandbox/src/templates/email-components/email-column.tsx new file mode 100644 index 0000000..7e42f68 --- /dev/null +++ b/sandbox/src/templates/email-components/email-column.tsx @@ -0,0 +1,147 @@ +import { ComponentChildren } from 'preact'; +import { PreactEmailComponent } from 'zibri'; + +import { useEmailContext } from './email-context'; + +type Props = { + children: ComponentChildren, + width?: string, + verticalAlign?: 'top' | 'middle' | 'bottom', + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + border?: string, + borderBottom?: string, + borderLeft?: string, + borderRight?: string, + borderTop?: string, + borderRadius?: string, + innerBorder?: string, + innerBorderBottom?: string, + innerBorderLeft?: string, + innerBorderRight?: string, + innerBorderTop?: string, + innerBorderRadius?: string, + innerBackgroundColor?: string, + backgroundColor?: string, + cssClass?: string +}; + +export const EmailColumn: PreactEmailComponent = ({ + children, + width = '100%', + verticalAlign = 'top', + padding = '0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + border = 'none', + borderBottom, + borderLeft, + borderRight, + borderTop, + borderRadius, + innerBorder = 'none', + innerBorderBottom, + innerBorderLeft, + innerBorderRight, + innerBorderTop, + innerBorderRadius, + innerBackgroundColor, + backgroundColor, + cssClass = '' +}) => { + const { width: sectionWidth, dir, textAlign } = useEmailContext(); + + // Resolve pixel width for MSO from section width and column width + const sectionWidthNum: number = Number.parseInt(sectionWidth); + const columnWidthNum: number | undefined = width.endsWith('%') + ? Number.isNaN(sectionWidthNum) + ? undefined + : Math.round(sectionWidthNum * (Number.parseFloat(width) / 100)) + : Number.isNaN(Number.parseInt(width)) + ? undefined + : Number.parseInt(width); + + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const borderStyle: string = [ + border !== 'none' ? `border:${border}` : '', + borderTop ? `border-top:${borderTop}` : '', + borderRight ? `border-right:${borderRight}` : '', + borderBottom ? `border-bottom:${borderBottom}` : '', + borderLeft ? `border-left:${borderLeft}` : '', + borderRadius ? `border-radius:${borderRadius}` : '' + ].filter(Boolean).join('; '); + + const innerBorderStyle: string = [ + innerBorder !== 'none' ? `border:${innerBorder}` : '', + innerBorderTop ? `border-top:${innerBorderTop}` : '', + innerBorderRight ? `border-right:${innerBorderRight}` : '', + innerBorderBottom ? `border-bottom:${innerBorderBottom}` : '', + innerBorderLeft ? `border-left:${innerBorderLeft}` : '', + innerBorderRadius ? `border-radius:${innerBorderRadius}` : '' + ].filter(Boolean).join('; '); + + const outerTdStyle: string = [ + 'font-size:0px', + `padding:${resolvedPadding}`, + `text-align:${textAlign}`, + `vertical-align:${verticalAlign}`, + borderStyle + ].filter(Boolean).join('; '); + + const containerStyle: string = [ + backgroundColor ? `background-color:${backgroundColor}` : '', + borderStyle, + `vertical-align:${verticalAlign}` + ].filter(Boolean).join('; '); + + const innerTableStyle: string = [ + innerBackgroundColor ? `background-color:${innerBackgroundColor}` : '', + innerBorderStyle + ].filter(Boolean).join('; '); + + const msoWidth: string = columnWidthNum !== undefined ? `${columnWidthNum}px` : width; + const msoWidthAttr: string = columnWidthNum !== undefined ? ` width="${columnWidthNum}"` : ''; + + return <> + {``} +
+ + + + + + +
+ {children} +
+
+ {''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-context.ts b/sandbox/src/templates/email-components/email-context.ts new file mode 100644 index 0000000..bb05e63 --- /dev/null +++ b/sandbox/src/templates/email-components/email-context.ts @@ -0,0 +1,24 @@ +import { Context, createContext } from 'preact'; +import { useContext } from 'preact/hooks'; + +export type EmailContextValue = { + width: string, + dir: 'rtl' | 'ltr', + textAlign: 'left' | 'center' | 'right' | 'justify', + color: string, + fontFamily: string, + lineHeight: number +}; + +export const EmailContext: Context = createContext({ + width: '600px', + dir: 'ltr', + textAlign: 'center', + color: '#000000', + fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif', + lineHeight: 1 +}); + +export function useEmailContext(): EmailContextValue { + return useContext(EmailContext); +} \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-divider.tsx b/sandbox/src/templates/email-components/email-divider.tsx new file mode 100644 index 0000000..8199a76 --- /dev/null +++ b/sandbox/src/templates/email-components/email-divider.tsx @@ -0,0 +1,84 @@ +import { PreactEmailComponent } from 'zibri'; + +type Props = { + borderColor?: string, + borderStyle?: 'solid' | 'dashed' | 'dotted', + borderWidth?: string, + align?: 'left' | 'center' | 'right', + width?: string, + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + containerBackgroundColor?: string, + cssClass?: string +}; + +export const EmailDivider: PreactEmailComponent = ({ + borderColor = '#000000', + borderStyle = 'solid', + borderWidth = '3px', + align = 'center', + width = '100%', + padding = '0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + containerBackgroundColor, + cssClass = '' +}) => { + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const tdStyle: string = [ + containerBackgroundColor ? `background:${containerBackgroundColor}` : '', + `padding:${resolvedPadding}`, + 'word-break:break-word' + ].filter(Boolean).join('; '); + + const pStyle: string = [ + `border-top:${borderWidth} ${borderStyle} ${borderColor}`, + 'font-size:1px', + 'margin:0px auto', + `width:${width}` + ].join('; '); + + const msoAlign: string = align === 'center' + ? 'margin:0px auto' + : align === 'right' + ? 'margin-left:auto' + : 'margin-right:auto'; + + return <> + {containerBackgroundColor + ? `` + : ''} + + + + + + +
+

+

+ {/* eslint-disable-next-line stylistic/max-len */} + {``} +
+ {containerBackgroundColor + ? '' + : ''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-head.tsx b/sandbox/src/templates/email-components/email-head.tsx new file mode 100644 index 0000000..e862525 --- /dev/null +++ b/sandbox/src/templates/email-components/email-head.tsx @@ -0,0 +1,69 @@ +import { PreactEmailComponent } from 'zibri'; + +type Props = { + title: string, + customStyles: string | undefined +}; + +export const EmailHead: PreactEmailComponent = ({ + title, + customStyles +}) => { + return + {title} + {''} + + {''} + + + + {''} + {''} + {customStyles && } + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-html.tsx b/sandbox/src/templates/email-components/email-html.tsx new file mode 100644 index 0000000..eb5c021 --- /dev/null +++ b/sandbox/src/templates/email-components/email-html.tsx @@ -0,0 +1,28 @@ +import { ComponentChildren } from 'preact'; +import { PreactEmailComponent } from 'zibri'; + +type Props = { + children: ComponentChildren, + lang: string, + dir: 'auto' | 'rtl' | 'ltr' +}; + +export const EmailHtml: PreactEmailComponent = ({ + lang, + dir, + children +}) => { + return + {children} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-image.tsx b/sandbox/src/templates/email-components/email-image.tsx new file mode 100644 index 0000000..4061786 --- /dev/null +++ b/sandbox/src/templates/email-components/email-image.tsx @@ -0,0 +1,120 @@ +import { JSX } from 'preact'; +import { PreactEmailComponent } from 'zibri'; + +type Props = { + src: string, + alt: string, + href?: string, + rel?: string, + target?: '_blank' | '_self' | '_parent' | '_top', + title?: string, + width?: string, + height?: string, + align?: 'left' | 'center' | 'right', + border?: string, + borderRadius?: string, + containerBackgroundColor?: string, + fluidOnMobile?: boolean, + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + name?: string, + cssClass?: string +}; + +export const EmailImage: PreactEmailComponent = ({ + src, + alt, + href, + rel, + target = '_blank', + title, + width = '100%', + height = 'auto', + align = 'center', + border = 'none', + borderRadius, + containerBackgroundColor, + fluidOnMobile = false, + padding = '0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + name, + cssClass = '' +}) => { + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const widthNum: number | undefined = Number.isNaN(Number.parseInt(width)) + ? undefined + : Number.parseInt(width); + + const tdStyle: string = [ + containerBackgroundColor ? `background:${containerBackgroundColor}` : '', + `padding:${resolvedPadding}`, + `text-align:${align}`, + 'word-break:break-word' + ].filter(Boolean).join('; '); + + const imgStyle: string = [ + `border:${border}`, + borderRadius ? `border-radius:${borderRadius}` : '', + 'display:block', + align === 'center' ? 'margin:0 auto' : align === 'right' ? 'margin-left:auto; margin-right:0' : 'margin-right:auto', + fluidOnMobile ? 'max-width:100%' : '', + `height:${height}`, + `width:${width}` + ].filter(Boolean).join('; '); + + const img: JSX.Element = {alt}; + + const linkedImg: JSX.Element = href + ? + {img} + + : img; + + const msoWidth: string = widthNum !== undefined ? `${widthNum}px` : width; + + return <> + {containerBackgroundColor + ? `` + : ''} + {``} + + + + + + +
+ {linkedImg} +
+ {''} + {containerBackgroundColor + ? '' + : ''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-link.tsx b/sandbox/src/templates/email-components/email-link.tsx new file mode 100644 index 0000000..4d876bd --- /dev/null +++ b/sandbox/src/templates/email-components/email-link.tsx @@ -0,0 +1,111 @@ +import { PreactEmailComponent } from 'zibri'; + +import { EmailContextValue, useEmailContext } from './email-context'; + +type Props = { + href: string, + children: string | string[], + color?: string, + fontFamily?: string, + fontSize?: string, + fontWeight?: string, + fontStyle?: 'normal' | 'italic' | 'oblique', + textDecoration?: 'none' | 'underline' | 'overline' | 'line-through', + letterSpacing?: string, + lineHeight?: number, + align?: 'left' | 'right' | 'center' | 'justify', + textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase', + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + containerBackgroundColor?: string, + height?: string, + target?: '_blank' | '_self' | '_parent' | '_top', + rel?: string, + title?: string, + cssClass?: string +}; + +export const EmailLink: PreactEmailComponent = ({ + href, + children, + color, + fontFamily, + fontSize = '16px', + fontWeight = 'normal', + fontStyle = 'normal', + textDecoration = 'underline', + letterSpacing, + lineHeight, + align = 'left', + textTransform = 'none', + padding = '0px 0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + containerBackgroundColor, + height, + target = '_blank', + rel, + title, + cssClass = '' +}) => { + const ctx: EmailContextValue = useEmailContext(); + + const resolvedColor: string = color ?? ctx.color; + const resolvedFontFamily: string = fontFamily ?? ctx.fontFamily; + const resolvedLineHeight: number = lineHeight ?? ctx.lineHeight; + + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const tdStyle: string = [ + containerBackgroundColor ? `background:${containerBackgroundColor}` : '', + height ? `height:${height}` : '', + `padding:${resolvedPadding}`, + 'word-break:break-word' + ].filter(Boolean).join('; '); + + const anchorStyle: string = [ + `color:${resolvedColor}`, + `font-family:${resolvedFontFamily}`, + `font-size:${fontSize}`, + `font-style:${fontStyle}`, + `font-weight:${fontWeight}`, + `letter-spacing:${letterSpacing ?? 'normal'}`, + `line-height:${resolvedLineHeight}`, + 'display: block', + 'margin:0', + `text-align:${align}`, + `text-decoration:${textDecoration}`, + `text-transform:${textTransform}`, + 'mso-style-priority:99' + ].join('; '); + + return <> + {containerBackgroundColor + ? `` + : ''} + + {containerBackgroundColor + ? '' + : ''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-pre.tsx b/sandbox/src/templates/email-components/email-pre.tsx new file mode 100644 index 0000000..380c6c3 --- /dev/null +++ b/sandbox/src/templates/email-components/email-pre.tsx @@ -0,0 +1,51 @@ +import { PreactEmailComponent } from 'zibri'; + +import { EmailText } from './email-text'; + +type Props = { + children: string | string[], + fontSize?: string, + color?: string, + /** + * The pixel width of a single space character at the given font size. + * Defaults to 8px which matches most monospace fonts at 13-16px. + */ + spaceWidth?: number, + spacesPerTab?: number +}; + +export const EmailPre: PreactEmailComponent = ({ + children, + fontSize, + color, + spaceWidth = 8, + spacesPerTab = 4 +}) => { + const lines: string[] = Array.isArray(children) ? children : children.split('\n'); + return <> + {lines.map((line, i) => { + const leadingSpaces: number = line.match(/^ */)?.[0].length ?? 0; + const leadingTabs: number = line.match(/^\t*/)?.[0].length ?? 0; + const paddingLeft: number = (leadingSpaces * spaceWidth) + (leadingTabs * spaceWidth * spacesPerTab); + const trimmed: string = line.trimStart(); + + if (!trimmed) { + return + {'\u00A0'} + ; + } + + return ( + + {trimmed} + + ); + })} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-section.tsx b/sandbox/src/templates/email-components/email-section.tsx new file mode 100644 index 0000000..3ce81de --- /dev/null +++ b/sandbox/src/templates/email-components/email-section.tsx @@ -0,0 +1,169 @@ +import { ComponentChildren } from 'preact'; +import { PreactEmailComponent } from 'zibri'; + +import { EmailColumn } from './email-column'; +import { EmailContext, EmailContextValue, useEmailContext } from './email-context'; +import { EmailDivider } from './email-divider'; +import { EmailText } from './email-text'; + +type Props = { + children: ComponentChildren, + title?: string, + color?: string, + fontFamily?: string, + lineHeight?: number, + backgroundColor?: string, + backgroundUrl?: string, + backgroundSize?: string, + backgroundRepeat?: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | 'initial' | 'inherit', + backgroundPosition?: string, + backgroundPositionX?: string, + backgroundPositionY?: string, + border?: string, + borderBottom?: string, + borderLeft?: string, + borderRight?: string, + borderTop?: string, + borderRadius?: string, + direction?: 'ltr' | 'rtl', + fullWidth?: boolean, + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + textAlign?: 'left' | 'center' | 'right' | 'justify', + cssClass?: string +}; + +export const EmailSection: PreactEmailComponent = ({ + children, + title, + color, + fontFamily, + lineHeight, + backgroundColor, + backgroundUrl, + backgroundSize = 'auto', + backgroundRepeat = 'repeat', + backgroundPosition = 'top center', + backgroundPositionX = 'none', + backgroundPositionY = 'none', + border = 'none', + borderBottom, + borderLeft, + borderRight, + borderTop, + borderRadius, + direction = 'ltr', + fullWidth = false, + padding = '0 0', + paddingTop, + paddingRight, + paddingBottom = '20px', + paddingLeft, + textAlign = 'center', + cssClass = '' +}) => { + const ctx: EmailContextValue = useEmailContext(); + + const widthNum: number | undefined = Number.isNaN(Number.parseInt(ctx.width)) + ? undefined + : Number.parseInt(ctx.width); + + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const resolvedBackgroundPositionX: string = backgroundPositionX !== 'none' + ? backgroundPositionX + : backgroundPosition.split(' ')[0] ?? 'top'; + const resolvedBackgroundPositionY: string = backgroundPositionY !== 'none' + ? backgroundPositionY + : backgroundPosition.split(' ')[1] ?? 'center'; + const resolvedBackgroundPosition: string = `${resolvedBackgroundPositionX} ${resolvedBackgroundPositionY}`; + const backgroundStyle: string = [ + backgroundUrl + ? `background:url(${backgroundUrl}) ${resolvedBackgroundPosition} / ${backgroundSize} ${backgroundRepeat}` + : '', + backgroundColor ? `background-color:${backgroundColor}` : '' + ].filter(Boolean).join('; '); + + const borderStyle: string = [ + border !== 'none' ? `border:${border}` : '', + borderTop ? `border-top:${borderTop}` : '', + borderRight ? `border-right:${borderRight}` : '', + borderBottom ? `border-bottom:${borderBottom}` : '', + borderLeft ? `border-left:${borderLeft}` : '', + borderRadius ? `border-radius:${borderRadius}` : '' + ].filter(Boolean).join('; '); + + const containerStyle: string = [ + backgroundStyle, + 'margin:0px auto', + `max-width:${ctx.width}`, + borderRadius ? `border-radius:${borderRadius}` : '' + ].filter(Boolean).join('; '); + + const tableStyle: string = [ + backgroundStyle, + borderStyle, + 'width:100%' + ].filter(Boolean).join('; '); + + const tdStyle: string = [ + `direction:${direction}`, + 'font-size:0px', + `padding:${resolvedPadding}`, + `text-align:${textAlign}` + ].join('; '); + + const msoBgColor: string = backgroundColor && fullWidth ? ` bgcolor="${backgroundColor}"` : ''; + + const resolvedColor: string = color ?? ctx.color; + const resolvedFontFamily: string = fontFamily ?? ctx.fontFamily; + const resolvedLineHeight: number = lineHeight ?? ctx.lineHeight; + + return ( + + {/* eslint-disable-next-line stylistic/max-len */} + {``} +
+ + + + + + +
+ {title && <> + + + {title} + + + + } + {children} +
+
+ {''} +
+ ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-spacer.tsx b/sandbox/src/templates/email-components/email-spacer.tsx new file mode 100644 index 0000000..c015a7a --- /dev/null +++ b/sandbox/src/templates/email-components/email-spacer.tsx @@ -0,0 +1,63 @@ +import { PreactEmailComponent } from 'zibri'; + +type Props = { + height?: string, + containerBackgroundColor?: string, + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + cssClass?: string +}; + +export const EmailSpacer: PreactEmailComponent = ({ + height = '20px', + containerBackgroundColor, + padding = '0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + cssClass = '' +}) => { + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const tdStyle: string = [ + containerBackgroundColor ? `background:${containerBackgroundColor}` : '', + `height:${height}`, + `padding:${resolvedPadding}`, + 'word-break:break-word' + ].filter(Boolean).join('; '); + + return <> + {containerBackgroundColor + ? `` + : ''} + + + + + + +
+ {/* eslint-disable-next-line stylistic/max-len */} + {``} +   +
+ {containerBackgroundColor + ? '' + : ''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-text.tsx b/sandbox/src/templates/email-components/email-text.tsx new file mode 100644 index 0000000..e8696f4 --- /dev/null +++ b/sandbox/src/templates/email-components/email-text.tsx @@ -0,0 +1,101 @@ +import { PreactEmailComponent } from 'zibri'; + +import { EmailContextValue, useEmailContext } from './email-context'; + +type Props = { + children: string | string[], + tag?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', + color?: string, + fontFamily?: string, + fontSize?: string, + fontStyle?: 'normal' | 'italic' | 'oblique', + fontWeight?: string, + letterSpacing?: string, + lineHeight?: number, + align?: 'left' | 'right' | 'center' | 'justify', + textDecoration?: string, + textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase', + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + containerBackgroundColor?: string, + height?: string, + cssClass?: string +}; + +export const EmailText: PreactEmailComponent = ({ + children, + color, + fontFamily, + fontSize = '16px', + fontStyle = 'normal', + fontWeight = 'normal', + letterSpacing, + lineHeight, + align = 'left', + textDecoration = 'none', + textTransform = 'none', + padding = '0px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + containerBackgroundColor, + height, + tag = 'div', + cssClass = '' +}) => { + const ctx: EmailContextValue = useEmailContext(); + const TagName: typeof tag = tag; + + const resolvedColor: string = color ?? ctx.color; + const resolvedFontFamily: string = fontFamily ?? ctx.fontFamily; + const resolvedLineHeight: number = lineHeight ?? ctx.lineHeight; + + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const tdStyle: string = [ + containerBackgroundColor ? `background:${containerBackgroundColor}` : '', + height ? `height:${height}` : '', + `padding:${resolvedPadding}`, + 'word-break:break-word' + ].filter(Boolean).join('; '); + + const textStyle: string = [ + `color:${resolvedColor}`, + `font-family:${resolvedFontFamily}`, + `font-size:${fontSize}`, + `font-style:${fontStyle}`, + `font-weight:${fontWeight}`, + `letter-spacing:${letterSpacing ?? 'normal'}`, + `line-height:${resolvedLineHeight}`, + 'margin:0', + `text-align:${align}`, + `text-decoration:${textDecoration}`, + `text-transform:${textTransform}` + ].join('; '); + + return <> + {containerBackgroundColor + ? `` + : ''} +
+ + {children} + +
+ {containerBackgroundColor + ? '' + : ''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/email-wrapper.tsx b/sandbox/src/templates/email-components/email-wrapper.tsx new file mode 100644 index 0000000..93ff970 --- /dev/null +++ b/sandbox/src/templates/email-components/email-wrapper.tsx @@ -0,0 +1,97 @@ +import { ComponentChildren } from 'preact'; +import { PreactEmailComponent } from 'zibri'; + +import { EmailContext, EmailContextValue, useEmailContext } from './email-context'; + +type Props = { + children: ComponentChildren, + backgroundColor?: string, + padding?: string, + paddingTop?: string, + paddingRight?: string, + paddingBottom?: string, + paddingLeft?: string, + borderRadius?: string, + border?: string, + boxShadow?: string, + width?: string, + color?: string, + fontFamily?: string, + lineHeight?: number, + cssClass?: string +}; + +export const EmailWrapper: PreactEmailComponent = ({ + children, + backgroundColor, + padding = '20px', + paddingTop, + paddingRight, + paddingBottom, + paddingLeft, + borderRadius, + border, + boxShadow, + width, + color, + fontFamily, + lineHeight, + cssClass = '' +}) => { + const ctx: EmailContextValue = useEmailContext(); + + const resolvedWidth: string = width ?? ctx.width; + const widthNum: number | undefined = Number.isNaN(Number.parseInt(resolvedWidth)) + ? undefined + : Number.parseInt(resolvedWidth); + + const resolvedPadding: string = [ + paddingTop ?? padding.split(' ')[0], + paddingRight ?? padding.split(' ')[1] ?? padding.split(' ')[0], + paddingBottom ?? padding.split(' ')[2] ?? padding.split(' ')[0], + paddingLeft ?? padding.split(' ')[3] ?? padding.split(' ')[1] ?? padding.split(' ')[0] + ].join(' '); + + const outerTableStyle: string = [ + 'margin:0 auto', + `width:${resolvedWidth}` + ].join('; '); + + const innerTdStyle: string = [ + backgroundColor ? `background-color:${backgroundColor}` : '', + border ? `border:${border}` : '', + borderRadius ? `border-radius:${borderRadius}` : '', + boxShadow ? `box-shadow:${boxShadow}` : '', + `padding:${resolvedPadding}` + ].filter(Boolean).join('; '); + + return <> + {/* eslint-disable-next-line stylistic/max-len */} + {``} + + + + + + + + {''} + ; +}; \ No newline at end of file diff --git a/sandbox/src/templates/email-components/mailing-list-base-email.tsx b/sandbox/src/templates/email-components/mailing-list-base-email.tsx new file mode 100644 index 0000000..750af62 --- /dev/null +++ b/sandbox/src/templates/email-components/mailing-list-base-email.tsx @@ -0,0 +1,25 @@ +import { MailingListBaseEmailTemplate } from 'zibri'; + +import { BaseEmail } from './base-email'; +import { BaseEmailFooter } from './base-email-footer'; +import { BaseEmailHeader } from './base-email-header'; +import { EmailWrapper } from './email-wrapper'; + +export const MailingListBaseEmail: MailingListBaseEmailTemplate = ({ + list, + subscriber, + html, + title +}) => { + return ( + + + {title} + +
+ + + + + ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/emails/base-email.hbs b/sandbox/src/templates/emails/base-email.hbs deleted file mode 100644 index 2617308..0000000 --- a/sandbox/src/templates/emails/base-email.hbs +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - {{base.title}} - - - - - - - - - - -
- - - - - -
- - \ No newline at end of file diff --git a/sandbox/src/templates/emails/log.hbs b/sandbox/src/templates/emails/log.hbs deleted file mode 100644 index fd52ea4..0000000 --- a/sandbox/src/templates/emails/log.hbs +++ /dev/null @@ -1,32 +0,0 @@ -

{{levelName}} from {{appName}}:

- -
-
id: {{log.id}}
-
createdAt: {{createdAtString}}
-
origin: {{log.context.origin}}
-
- - {{#if log.context.request}} -
request:
-
id: {{log.context.request.id}}
-
endpoint: {{log.context.request.method}} {{log.context.request.url}}
-
clientIp: {{log.context.request.clientIp}}
-
userAgent: {{log.context.request.userAgent}}
- - {{#if (and log.context.request.status log.context.request.durationInMs)}} -
response: {{log.context.request.status}} after {{log.context.request.durationInMs}} ms
- {{/if}} -
- {{/if}} - -
message:
-
{{log.message}}
-
- - {{#if log.error}} -
error:
- {{#each log.error.paragraphs}} -
{{this}}
- {{/each}} - {{/if}} -
diff --git a/sandbox/src/templates/emails/log.tsx b/sandbox/src/templates/emails/log.tsx new file mode 100644 index 0000000..62d1f08 --- /dev/null +++ b/sandbox/src/templates/emails/log.tsx @@ -0,0 +1,80 @@ +import { inject, LogLevel, ZIBRI_DI_TOKENS, LogEmailTemplate } from 'zibri'; + +import { BaseEmail } from '../email-components/base-email'; +import { BaseEmailDataListItem } from '../email-components/base-email-data-list-item'; +import { BaseEmailFooter } from '../email-components/base-email-footer'; +import { BaseEmailHeader } from '../email-components/base-email-header'; +import { EmailColumn } from '../email-components/email-column'; +import { EmailSection } from '../email-components/email-section'; +import { EmailText } from '../email-components/email-text'; +import { EmailWrapper } from '../email-components/email-wrapper'; + +const logLevelLabels: Record = { + [LogLevel.DEBUG]: 'Debug Log', + [LogLevel.INFO]: 'Info Log', + [LogLevel.WARN]: 'Warning', + [LogLevel.ERROR]: 'Error', + [LogLevel.CRITICAL]: 'Critical Error' +}; + +const logLevelBgColors: Record = { + [LogLevel.DEBUG]: '#00b4d8', + [LogLevel.INFO]: '#00b4d8', + [LogLevel.WARN]: '#edff4aff', + [LogLevel.ERROR]: '#ff5959ff', + [LogLevel.CRITICAL]: '#cc6cffff' +}; + +export const LogEmail: LogEmailTemplate = ({ log }) => { + const levelName: string = logLevelLabels[log.level]; + const boxBgColor: string = logLevelBgColors[log.level]; + const createdAtString: string = inject(ZIBRI_DI_TOKENS.FORMAT_DATE)(log.createdAt, true); + + return ( + + + {levelName} + + + + There has been a new log: + + + + {/* Content */} + + + + {log.message} + + + + + + + + + + {log.context.request && <> + + + + + + + } + + {log.error && <> + + + + + + } + + + + + + ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/emails/mailing-list-subscribe-confirmation.tsx b/sandbox/src/templates/emails/mailing-list-subscribe-confirmation.tsx new file mode 100644 index 0000000..f719bdb --- /dev/null +++ b/sandbox/src/templates/emails/mailing-list-subscribe-confirmation.tsx @@ -0,0 +1,44 @@ +import { MailingListSubscribeConfirmationEmailTemplate } from 'zibri'; + +import { BaseEmail } from '../email-components/base-email'; +import { BaseEmailFooter } from '../email-components/base-email-footer'; +import { BaseEmailHeader } from '../email-components/base-email-header'; +import { EmailButton } from '../email-components/email-button'; +import { EmailColumn } from '../email-components/email-column'; +import { EmailSection } from '../email-components/email-section'; +import { EmailText } from '../email-components/email-text'; +import { EmailWrapper } from '../email-components/email-wrapper'; + +export const MailingListSubscribeConfirmationEmail: MailingListSubscribeConfirmationEmailTemplate = ({ + subscriber, + mailingList, + confirmEmailLink +}) => { + + const greeting: string = subscriber.name ? `Hello ${subscriber.name}` : 'Hello'; + + return ( + + + Confirm Email + + + + {greeting}, + + Please confirm the registration of this email for the mailing list "{mailingList.name}": + + + Confirm + + + If you did not try to subscribe to this mailing list, you can ignore this mail. + + + + + + + + ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/emails/password-reset.hbs b/sandbox/src/templates/emails/password-reset.hbs deleted file mode 100644 index 77971ac..0000000 --- a/sandbox/src/templates/emails/password-reset.hbs +++ /dev/null @@ -1,26 +0,0 @@ -

Hello {{user.name}},

-

- A password reset was requested for your account. -
- Click the link down below to proceed. -

- - Reset Password - -

- If you did not request to reset your password, you can ignore this mail. -

\ No newline at end of file diff --git a/sandbox/src/templates/emails/password-reset.tsx b/sandbox/src/templates/emails/password-reset.tsx new file mode 100644 index 0000000..eb297e2 --- /dev/null +++ b/sandbox/src/templates/emails/password-reset.tsx @@ -0,0 +1,38 @@ +import { PasswordResetEmailTemplate } from 'zibri'; + +import { Roles, User } from '../../models'; +import { BaseEmail } from '../email-components/base-email'; +import { BaseEmailFooter } from '../email-components/base-email-footer'; +import { BaseEmailHeader } from '../email-components/base-email-header'; +import { EmailButton } from '../email-components/email-button'; +import { EmailColumn } from '../email-components/email-column'; +import { EmailSection } from '../email-components/email-section'; +import { EmailText } from '../email-components/email-text'; +import { EmailWrapper } from '../email-components/email-wrapper'; + +export const PasswordResetEmail: PasswordResetEmailTemplate = ({ + confirmPasswordResetLink, + user +}) => { + return ( + + + Password Reset + + + + Hello {user.name}, + A password reset was requested for your account. + Click the link down below to proceed. + + Reset Password + + If you did not request to reset your password, you can ignore this mail. + + + + + + + ); +}; \ No newline at end of file diff --git a/sandbox/src/templates/pages/error.tsx b/sandbox/src/templates/pages/error.tsx index 7d81555..e6aba8b 100644 --- a/sandbox/src/templates/pages/error.tsx +++ b/sandbox/src/templates/pages/error.tsx @@ -14,7 +14,7 @@ export const ErrorPage: ErrorPageTemplate = ({ error }) => { return
- + diff --git a/sandbox/src/templates/pages/mailing-list-preferences.tsx b/sandbox/src/templates/pages/mailing-list-preferences.tsx index 69d393b..b4124c5 100644 --- a/sandbox/src/templates/pages/mailing-list-preferences.tsx +++ b/sandbox/src/templates/pages/mailing-list-preferences.tsx @@ -1,5 +1,5 @@ /* eslint-disable typescript/no-non-null-assertion */ -import { MailingList, MailingListSubscriber, MaskUtilities, onClient, PreactComponent } from 'zibri'; +import { MailingList, MailingListPreferencesPageTemplate, MaskUtilities, onClient } from 'zibri'; import { Button } from '../components/button'; import { Card } from '../components/card'; @@ -7,19 +7,13 @@ import { Checkbox } from '../components/checkbox'; import { EmptyPage } from '../components/empty-page'; import { Heading } from '../components/heading'; -type Props = { - subscriber: MailingListSubscriber, - mailingLists: MailingList[] -}; - type MailingListDisplayData = MailingList & { isSubscribedTo: boolean }; -export const MailingListPreferencesPage: PreactComponent = ({ subscriber, mailingLists }) => { +export const MailingListPreferencesPage: MailingListPreferencesPageTemplate = ({ subscriber, mailingLists, managePreferencesLink }) => { let updateButton: HTMLButtonElement; let statusBar: HTMLDivElement; - let subscriberId: string; const email: string = MaskUtilities.mask(subscriber.email); const lists: MailingListDisplayData[] = mailingLists.map(l => ({ @@ -33,12 +27,6 @@ export const MailingListPreferencesPage: PreactComponent = ({ subscriber, onClient(() => { updateButton = document.querySelector('#update-button')!; statusBar = document.querySelector('#status-bar')!; - const subscriberIdParam: string | null = new URL(window.location.href).searchParams.get('subscriberId'); - if (!subscriberIdParam) { - location.href = '/'; - return; - } - subscriberId = subscriberIdParam; }); function updateCheckedMailingListIds(l: MailingListDisplayData): void { @@ -69,7 +57,7 @@ export const MailingListPreferencesPage: PreactComponent = ({ subscriber, setIsLoading(); const success: boolean = (await fetch( - `/mailing-lists/preferences?subscriberId=${subscriberId}`, + managePreferencesLink, { method: 'PATCH', body: JSON.stringify({ mailingListIds: currentCheckedMailingListIds }), diff --git a/sandbox/src/templates/pages/mailing-list-unsubscribe-confirmation.tsx b/sandbox/src/templates/pages/mailing-list-unsubscribe-confirmation.tsx index be5ab2b..bc2a62b 100644 --- a/sandbox/src/templates/pages/mailing-list-unsubscribe-confirmation.tsx +++ b/sandbox/src/templates/pages/mailing-list-unsubscribe-confirmation.tsx @@ -1,15 +1,14 @@ -import { MailingList, MailingListSubscriber, MaskUtilities, PreactComponent } from 'zibri'; +import { MailingListUnsubscribeConfirmationPageTemplate, MaskUtilities } from 'zibri'; import { Card } from '../components/card'; import { EmptyPage } from '../components/empty-page'; import { Heading } from '../components/heading'; -type Props = { - subscriber: MailingListSubscriber, - mailingList: MailingList -}; - -export const MailingListUnsubscribeConfirmationPage: PreactComponent = ({ subscriber, mailingList }) => { +export const MailingListUnsubscribeConfirmationPage: MailingListUnsubscribeConfirmationPageTemplate = ({ + subscriber, + mailingList, + managePreferencesLink +}) => { const email: string = MaskUtilities.mask(subscriber.email); return ( @@ -31,8 +30,7 @@ export const MailingListUnsubscribeConfirmationPage: PreactComponent = ({

Unsubscribed by accident?
- {/* TODO */} - Subscribe again or manage other email preferences + Manage your preferences

diff --git a/sandbox/src/templates/pages/subscribe-success.tsx b/sandbox/src/templates/pages/subscribe-success.tsx new file mode 100644 index 0000000..56f5d05 --- /dev/null +++ b/sandbox/src/templates/pages/subscribe-success.tsx @@ -0,0 +1,36 @@ +import { MailingListSubscribeSuccessPageTemplate, MaskUtilities } from 'zibri'; + +import { Card } from '../components/card'; +import { EmptyPage } from '../components/empty-page'; +import { Heading } from '../components/heading'; + +export const SubscribeSuccessPage: MailingListSubscribeSuccessPageTemplate = ({ + mailingList, + subscriber, + managePreferencesLink +}) => { + const email: string = MaskUtilities.mask(subscriber.email); + + return ( + + + +

{email}

+ + You've successfully subscribed to +
+ {mailingList.name} +
+

+ We are happy to have you with us 🙂 +

+
+
+
+

+ You can manage your preferences here +

+
+
+ ); +}; \ No newline at end of file diff --git a/src/__testing__/constants.ts b/src/__testing__/constants.ts index b94470b..2c47bd5 100644 --- a/src/__testing__/constants.ts +++ b/src/__testing__/constants.ts @@ -1,5 +1,9 @@ -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; -export const testFileFolder: Path = FsUtilities.getPath(__dirname, 'file-output'); +export const testFileFolder: FsPath = FsUtilities.getPath(__dirname, 'file-output'); -export const POSTGRES_TEST_IMAGE: string = 'postgres:17.6'; \ No newline at end of file +export const POSTGRES_TEST_IMAGE: string = 'postgres:17.6'; + +export const noOp: () => void = () => {}; + +export const noOpAsync: () => Promise = async () => {}; \ No newline at end of file diff --git a/src/__testing__/mocks/assets/thread-job.worker.cjs b/src/__testing__/mocks/assets/thread-job.worker.cjs new file mode 100644 index 0000000..cb93f23 --- /dev/null +++ b/src/__testing__/mocks/assets/thread-job.worker.cjs @@ -0,0 +1,92 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable no-use-before-define */ +/* eslint-disable jsdoc/require-description */ +const { parentPort } = require('node:worker_threads'); +const { register } = require('ts-node'); +// eslint-disable-next-line stylistic/max-len +const { BaseFunctionThreadJobWorkerData, BaseThreadJobWorkerData, ThreadJobFunction, ThreadJobMessage, reportCompletion, reportError } = require('zibri'); + +if (!parentPort) { + throw new Error('Internal Error with the thread-job-worker: parentPort not available.'); +} + +register(); + +/** @type {ThreadJobMessage} */ +const message = { type: 'initialization' }; + +parentPort.postMessage(message); + +parentPort.on( + 'message', + ( + /** @type {BaseThreadJobWorkerData | BaseFunctionThreadJobWorkerData} */ + wData + ) => { + workerData = wData; + + if (isFunctionWorkerData(wData)) { + void callFunction(wData); + return; + } + + // Clear the module from the cache + if (wData.filePath.endsWith('.ts')) { + /** @type {string[]} */ + const parts = wData.filePath.split('.ts'); + parts.splice(parts.length - 1, 1); + wData.filePath = parts.join('') + '.js'; + } + + if (require.cache[require.resolve(wData.filePath)]) { + delete require.cache[require.resolve(wData.filePath)]; + } + + void importWorkerFile(wData); + } +); + +async function callFunction( + /** @type {BaseFunctionThreadJobWorkerData} */ + wData +) { + try { + /** @type {ThreadJobFunction} */ + const fn = eval(`(${wData.func})`); + const result = await fn(wData.input); + reportCompletion(result); + } + catch (error) { + reportError(toError(error)); + } +} + +async function importWorkerFile( + /** @type {BaseThreadJobWorkerData} */ + workerData +) { + try { + await import(workerData.filePath); + } + catch (error) { + reportError(toError(error)); + } +} + +function toError( + /** @type {unknown} */ + value +) { + if (value instanceof Error) { + return value; + } + return new Error(`${value}`); +} + +function isFunctionWorkerData( + /** @type {unknown} */ + value +) { + return value != undefined && typeof value === 'object' + && !!value.func && typeof value.func === 'string'; +} \ No newline at end of file diff --git a/src/__testing__/mocks/entities/jwt-user.entity.ts b/src/__testing__/mocks/entities/jwt-user.entity.ts new file mode 100644 index 0000000..919039a --- /dev/null +++ b/src/__testing__/mocks/entities/jwt-user.entity.ts @@ -0,0 +1,6 @@ +import { Roles } from './roles.enum'; +import { BaseUserEntity } from '../../../auth/models/base-user.model'; +import { Entity } from '../../../entity/decorators/entity.decorator'; + +@Entity() +export class JwtUser extends BaseUserEntity(Roles) {} \ No newline at end of file diff --git a/src/__testing__/mocks/entities/roles.enum.ts b/src/__testing__/mocks/entities/roles.enum.ts new file mode 100644 index 0000000..eddc4d2 --- /dev/null +++ b/src/__testing__/mocks/entities/roles.enum.ts @@ -0,0 +1,4 @@ +export enum Roles { + ADMIN = 'ADMIN', + USER = 'USER' +} \ No newline at end of file diff --git a/src/__testing__/test-server/create-test-data-source.function.ts b/src/__testing__/test-server/create-test-data-source.function.ts new file mode 100644 index 0000000..205a4d7 --- /dev/null +++ b/src/__testing__/test-server/create-test-data-source.function.ts @@ -0,0 +1,67 @@ +import { OtpCredentials } from '../../auth/2fa/methods/otp/otp-credentials.model'; +import { PasswordResetToken } from '../../auth/models/password-reset-token.model'; +import { JwtCredentials } from '../../auth/strategies/jwt/jwt-credentials.model'; +import { JwtRefreshToken } from '../../auth/strategies/jwt/jwt-refresh-token.model'; +import { ChangeSet } from '../../change-sets/models/change-set.model'; +import { Change } from '../../change-sets/models/change.model'; +import { CronJobEntity } from '../../cron/cron-job-entity.model'; +import { PostgresDataSource, PostgresOptions } from '../../data-source/data-sources/postgres-data-source.model'; +import { DataSource } from '../../data-source/decorators/data-source.decorator'; +import { MigrationEntity } from '../../data-source/migration/migration-entity.model'; +import { Email } from '../../email/models/email.model'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { Log } from '../../logging/log.model'; +import { ThreadJobEntity } from '../../multithreading/models/thread-job-entity.model'; +import { Newable } from '../../types/newable.type'; +import { WebsocketChannel } from '../../websocket/models/websocket-channel.model'; +import { WebsocketMessage } from '../../websocket/models/websocket-message.model'; +import { JwtUser } from '../mocks/entities/jwt-user.entity'; + +export type CreateTestDataSourceOptions = { + entities?: Newable[], + host?: string, + username?: string, + password?: string, + database?: string +}; + +export const defaultTestServerEntities: Newable[] = [ + Change, + ChangeSet, + MigrationEntity, + CronJobEntity, + Email, + ThreadJobEntity, + WebsocketChannel, + WebsocketMessage, + Log, + PasswordResetToken, + JwtUser, + JwtRefreshToken, + JwtCredentials, + OtpCredentials, + ThreadJobEntity +]; + +export function createTestDataSource({ + entities = defaultTestServerEntities, + host = 'localhost', + username = 'postgres', + password = 'password', + database = 'db' +}: CreateTestDataSourceOptions = {}): Newable { + + @DataSource() + class DbDataSource extends PostgresDataSource { + options: PostgresOptions = { + host, + username, + password, + database, + synchronize: true + }; + entities: Newable[] = entities; + } + + return DbDataSource; +} \ No newline at end of file diff --git a/src/__testing__/test-server/plugins.ts b/src/__testing__/test-server/plugins.ts new file mode 100644 index 0000000..3399d33 --- /dev/null +++ b/src/__testing__/test-server/plugins.ts @@ -0,0 +1,3 @@ +import { ZibriPlugin } from '../../plugin/plugin.model'; + +export const defaultTestServerPlugins: ZibriPlugin[] = []; \ No newline at end of file diff --git a/src/__testing__/test-server/providers.ts b/src/__testing__/test-server/providers.ts new file mode 100644 index 0000000..b2bc56b --- /dev/null +++ b/src/__testing__/test-server/providers.ts @@ -0,0 +1,77 @@ +import { PasswordResetEmailTemplate } from '../../auth/strategies/jwt/jwt-auth.controller'; +import { CronServiceInterface } from '../../cron/cron-service.interface'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { defineProvider, DiProvider } from '../../di/models/di-provider.model'; +import { MultithreadingServiceInterface } from '../../multithreading/services/multithreading-service.interface'; +import { noOp, noOpAsync } from '../constants'; + +export const defaultTestServerProviders: DiProvider[] = [ + defineProvider({ + token: ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_SECRET, + useFactory: () => 'test' + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_SECRET, + useFactory: () => 'test' + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL, + useFactory: () => 'http://localhost:4200/confirm-password-reset' + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE, + // eslint-disable-next-line typescript/no-explicit-any + useValue: (() => 'string') as unknown as PasswordResetEmailTemplate + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.EMAIL_CONFIG, + useFactory: () => { + return { + maxEmailsPerHour: 0, + defaultSender: 'Max Mustermann', + host: '', + port: 0, + auth: { + user: '', + pass: '' + } + }; + } + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.CRON_SERVICE, + useFactory: () => { + const res: CronServiceInterface = { + cronJobs: [], + schedule: noOpAsync, + enable: noOpAsync, + disable: noOpAsync, + changeCron: noOpAsync, + update: noOpAsync + }; + return res; + } + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.MULTITHREADING_SERVICE, + useFactory: () => { + const res: MultithreadingServiceInterface = { + requeueThreadJob: noOp, + queueThreadJob: () => '42', + runThreadJob: () => { + throw new Error('mock'); + }, + run: () => { + throw new Error('mock'); + }, + rerunThreadJob: () => { + throw new Error('mock'); + }, + waitForThreadJob: () => { + throw new Error('mock'); + } + }; + return res; + } + }) +]; \ No newline at end of file diff --git a/src/__testing__/test-server/start-test-server.function.ts b/src/__testing__/test-server/start-test-server.function.ts new file mode 100644 index 0000000..079cf9a --- /dev/null +++ b/src/__testing__/test-server/start-test-server.function.ts @@ -0,0 +1,86 @@ +import { jest } from '@jest/globals'; +import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import H from 'handlebars/runtime'; +import { AbstractStartedContainer } from 'testcontainers'; + +import { defaultTestServerPlugins } from './plugins'; +import { defaultTestServerProviders } from './providers'; +// eslint-disable-next-line eslintImport/no-unassigned-import +import './user-repository'; // this import is needed so that the DI system can pick up the user repository. +import { ZibriApplication } from '../../application'; +import { ZibriApplicationOptions } from '../../application-options.model'; +import { PostgresDataSource, PostgresOptions } from '../../data-source/data-sources/postgres-data-source.model'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { DiContainer } from '../../di/di-container'; +import { inject } from '../../di/inject.function'; +import { LoggerInterface } from '../../logging/logger.interface'; +import { Newable } from '../../types/newable.type'; +import { noOp, POSTGRES_TEST_IMAGE } from '../constants'; +import { createTestDataSource } from './create-test-data-source.function'; + +type StartTestServerOptions = Partial> & { + dataSources?: Newable[] +}; + +export class StartedTestServer { + constructor( + private readonly app: ZibriApplication, + private readonly containers: AbstractStartedContainer[], + private readonly exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never) + ) {} + + async shutdown(): Promise { + await this.app.shutdown(); + this.exitSpy.mockRestore(); + await Promise.all(this.containers.map(c => c.stop())); + } +} + +export async function startTestServer( + { + dataSources = [createTestDataSource()], + providers = defaultTestServerProviders, + plugins = defaultTestServerPlugins + }: StartTestServerOptions = {} +): Promise { + // Reset singleton — every test file gets a clean container with no stale instances. + DiContainer['singleton'] = undefined; + + const containers: StartedPostgreSqlContainer[] = await Promise.all(dataSources.map(async ds => { + const dataSource: PostgresDataSource = inject(ds); + const container: StartedPostgreSqlContainer = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) + .withDatabase(dataSource.options.database ?? 'db') + .withUsername(dataSource.options.username ?? 'postgres') + .withPassword(dataSource.options.password?.toString() ?? 'password') + .start(); + // eslint-disable-next-line typescript/no-unnecessary-type-assertion + (dataSource.options as PostgresOptions) = { + ...dataSource.options, + port: container.getMappedPort(5432) + }; + return container; + })); + + const logger: LoggerInterface = inject(ZIBRI_DI_TOKENS.LOGGER); + await logger.info('starts up test server...'); + const info: typeof logger.info = logger.info; + logger.info = noOp; + + const app: ZibriApplication = new ZibriApplication({ + name: 'test', + version: '0.0.1', + baseUrl: 'http://localhost:3000', + controllers: [], + websocketControllers: [], + dataSources, + providers, + plugins + }); + + await app.init(H); + + logger.info = info; + await logger.info('test server started'); + + return new StartedTestServer(app, containers); +} \ No newline at end of file diff --git a/src/__testing__/test-server/user-repository.ts b/src/__testing__/test-server/user-repository.ts new file mode 100644 index 0000000..b0d6c9f --- /dev/null +++ b/src/__testing__/test-server/user-repository.ts @@ -0,0 +1,34 @@ +import { UserRepo } from '../../auth/decorators/user-repo.decorator'; +import { JwtCredentials } from '../../auth/strategies/jwt/jwt-credentials.model'; +import { UserRepositoryInterface } from '../../auth/user/user-repository.interface'; +import { Repository } from '../../data-source/repository'; +import { InjectRepository } from '../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../di/decorators/inject.decorator'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { type LoggerInterface } from '../../logging/logger.interface'; +import { JwtUser } from '../mocks/entities/jwt-user.entity'; +import { Roles } from '../mocks/entities/roles.enum'; + +@UserRepo() +export class DefaultTestServerUserRepository extends Repository + implements UserRepositoryInterface { + + constructor( + @InjectRepository(JwtUser) + repo: Repository, + @Inject(ZIBRI_DI_TOKENS.LOGGER) + logger: LoggerInterface, + @InjectRepository(JwtUser) + private readonly credentialsRepository: Repository + ) { + super(JwtUser, repo, logger); + } + + async findByEmail(email: string): Promise { + return await this.findOne({ where: { email } }); + } + + async resolveCredentialsFor(user: JwtUser): Promise { + return this.credentialsRepository.findOne({ where: { userId: user.id } }); + } +} \ No newline at end of file diff --git a/src/application.ts b/src/application.ts index 092f874..ffb380e 100644 --- a/src/application.ts +++ b/src/application.ts @@ -4,39 +4,52 @@ import cors from 'cors'; import express, { RequestHandler } from 'express'; import { ZibriApplicationOptions } from './application-options.model'; -import { AssetServiceInterface } from './assets/asset-service.interface'; import { OtpTwoFactorMethod } from './auth/2fa/methods/otp/otp.two-factor-method'; -import { TwoFactorServiceInterface } from './auth/2fa/two-factor-service.interface'; -import { AuthServiceInterface } from './auth/auth-service.interface'; +import { isTwoFactorMethod } from './auth/2fa/methods/two-factor-method.interface'; +import { isAuthStrategy } from './auth/strategies/auth-strategy.interface'; import { JwtAuthStrategy } from './auth/strategies/jwt/jwt.auth-strategy'; -import { BackupServiceInterface } from './backup/backup-service.interface'; -import { CronServiceInterface } from './cron/cron-service.interface'; -import { DataSourceServiceInterface } from './data-source/data-source-service.interface'; +import { CronJob } from './cron/cron-job.model'; +import { isDataSource } from './data-source/data-sources/data-source.interface'; import { ZIBRI_DI_TOKENS } from './di/default/zibri-di-tokens.default'; +import { getAllRegisteredTokens } from './di/get-all-registered-tokens.function'; import { inject } from './di/inject.function'; +import { DiToken } from './di/models/di-token.model'; import { register } from './di/register.function'; -import { EmailServiceInterface } from './email/email-service.interface'; -import { MailingListServiceInterface } from './email/mailing-list/mailing-list-service.interface'; import { GlobalErrorHandler } from './error-handling/error-handler.model'; import { UnmatchedRouteError } from './error-handling/errors/unmatched-route.error'; +import { implementsAfterAppInit } from './global/after-app-init.interface'; +import { AfterAppShutdown, implementsAfterAppShutdown } from './global/after-app-shutdown.interface'; +import { AppState } from './global/app-state.enum'; +import { implementsBeforeAppInit } from './global/before-app-init.interface'; +import { BeforeAppShutdown, implementsBeforeAppShutdown } from './global/before-app-shutdown.interface'; import { GlobalRegistry } from './global/global-registry'; +import { implementsOnAppInit } from './global/on-app-init.interface'; +import { implementsOnAppShutdown, OnAppShutdown } from './global/on-app-shutdown.interface'; +import { implementsOnAppStart } from './global/on-app-start.interface'; import { HandlebarUtilities } from './handlebars/handlebar.utilities'; import { LoggerInterface } from './logging/logger.interface'; -import { MetricsServiceInterface } from './metrics/metrics-service.interface'; -import { MultithreadingServiceInterface } from './multithreading/services/multithreading-service.interface'; -import { OpenApiServiceInterface } from './open-api/open-api-service.interface'; import { FormDataBodyParser } from './parsing/form-data/form-data.body-parser'; import { JsonBodyParser } from './parsing/json/json.body-parser'; -import { ParserInterface } from './parsing/parser.interface'; +import { ZibriPlugin } from './plugin/plugin.model'; import { Route } from './routing/controller-route-configuration.model'; -import { RouterInterface } from './routing/router.interface'; import { OmitStrict } from './types/omit-strict.type'; -import { BaseWebsocketConnection } from './websocket/models/connection/base-websocket-connection.model'; -import { WebsocketServiceInterface } from './websocket/services/websocket-service.interface'; +import { FsUtilities } from './utilities/fs.utilities'; +import { Ms } from './utilities/ms'; +import { PromiseUtilities } from './utilities/promise.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc type FullZibriApplicationOptions = Required>; +// eslint-disable-next-line typescript/typedef +const SHUTDOWN_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const; + +const DEFAULT_SHUTDOWN_TIMEOUT_IN_MS: number = Ms.SECOND * 30; + +/** + * A os signal that triggers the shutdown of a Zibri application. + */ +export type ShutdownSignal = typeof SHUTDOWN_SIGNALS[number]; + /** * A Zibri application. */ @@ -48,33 +61,17 @@ export class ZibriApplication { .disable('x-powered-by') .use(cors()); + private readonly signalHandlers: Map void> = new Map void>(); + /** * The underlying http server. */ readonly server: Server = createServer(this.express); - private _router!: RouterInterface; - // eslint-disable-next-line jsdoc/require-returns - /** - * The router used by the application. - */ - get router(): RouterInterface { - return this._router; - } - private logger!: LoggerInterface; - private metricsService!: MetricsServiceInterface; - private assetService!: AssetServiceInterface; - private openApiService!: OpenApiServiceInterface; - private parser!: ParserInterface; - private dataSourceService!: DataSourceServiceInterface; - private authService!: AuthServiceInterface; - private twoFactorService!: TwoFactorServiceInterface; - private cronService!: CronServiceInterface; - private multithreadingService!: MultithreadingServiceInterface; - private websocketService!: WebsocketServiceInterface; - private emailService!: EmailServiceInterface; - private backupService!: BackupServiceInterface; - private mailingListService?: MailingListServiceInterface; + private get logger(): LoggerInterface { + return inject(ZIBRI_DI_TOKENS.LOGGER); + } + /** * The options of which the application was build. */ @@ -82,6 +79,13 @@ export class ZibriApplication { constructor(private readonly providedOptions: ZibriApplicationOptions) { this.options = this.createFullOptions(); + + for (const signal of SHUTDOWN_SIGNALS) { + const handler: () => void = () => void this.shutdown(signal); + this.signalHandlers.set(signal, handler); + process.on(signal, handler); + } + GlobalRegistry.markAppAsCreated(); } @@ -104,66 +108,29 @@ export class ZibriApplication { /** * Initializes the app. * @param H - The global handlebars instance, needed to provide some helpers used in templating. + * @param handlebarComponentsDir - Directory where handlebars components reside. Defaults to assetService.assetsPath/templates/components. */ - async init(H: typeof Handlebars): Promise { - await HandlebarUtilities.init(H); + async init( + H: typeof Handlebars, + handlebarComponentsDir: string = FsUtilities.getPath(inject(ZIBRI_DI_TOKENS.ASSET_SERVICE).assetsPath, 'templates', 'components') + ): Promise { + await HandlebarUtilities.init(H, handlebarComponentsDir); GlobalRegistry.setAppData(this.options); for (const provider of this.options.providers) { register(provider); } - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - await this.logger.attachTo(this); + await this.logDefaults(); - this.metricsService = inject(ZIBRI_DI_TOKENS.METRICS_SERVICE); - await this.metricsService.attachTo(this); + const tokens: DiToken[] = getAllRegisteredTokens(); + await this.beforeAppInit(tokens); - if (!this.providedOptions.authStrategies) { - await this.logger.info('No auth strategies provided, defaults to:'); - for (const strategy of this.options.authStrategies) { - await this.logger.info(` - ${strategy.name}`); - } - } - if (!this.providedOptions.twoFactorMethods) { - await this.logger.info('No two factor methods provided, defaults to:'); - for (const strategy of this.options.twoFactorMethods) { - await this.logger.info(` - ${strategy.name}`); - } - } - if (!this.providedOptions.bodyParsers) { - await this.logger.info('No body parsers provided, defaults to:'); - for (const bodyParser of this.options.bodyParsers) { - await this.logger.info(` - ${bodyParser.name}`); - } - } - - this.dataSourceService = inject(ZIBRI_DI_TOKENS.DATA_SOURCE_SERVICE); - await this.dataSourceService.init(); - - this.twoFactorService = inject(ZIBRI_DI_TOKENS.TWO_FACTOR_SERVICE); - await this.twoFactorService.init(this.options.twoFactorMethods); - - this.authService = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); - await this.authService.init(this.options.authStrategies); - - this.parser = inject(ZIBRI_DI_TOKENS.PARSER); - await this.parser.attachTo(this); + const injectables: unknown[] = tokens.map(t => inject(t)); + this.validateInjectables(injectables); - this._router = inject(ZIBRI_DI_TOKENS.ROUTER); - await this._router.init(this); - - this.assetService = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE); - await this.assetService.attachTo(this); - - this.emailService = inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE); - this.emailService.attachTo(this); - - this.mailingListService = inject(ZIBRI_DI_TOKENS.MAILING_LIST_SERVICE); - this.mailingListService?.attachTo(this); - - this.openApiService = inject(ZIBRI_DI_TOKENS.OPEN_API_SERVICE); - await this.openApiService.attachTo(this); + await this.onAppInit(injectables); + await this.afterAppInit(injectables); for (const controller of this.options.controllers) { inject(controller); @@ -173,18 +140,6 @@ export class ZibriApplication { inject(websocketController); } - this.cronService = inject(ZIBRI_DI_TOKENS.CRON_SERVICE); - await this.cronService.init(this.options.cronJobs); - - this.multithreadingService = inject(ZIBRI_DI_TOKENS.MULTITHREADING_SERVICE); - await this.multithreadingService.init(); - - this.websocketService = inject>(ZIBRI_DI_TOKENS.WEBSOCKET_SERVICE); - await this.websocketService.attachTo(this); - - this.backupService = inject(ZIBRI_DI_TOKENS.BACKUP_SERVICE); - await this.backupService.init(); - for (const plugin of this.providedOptions.plugins ?? []) { await plugin.validate(this); } @@ -198,20 +153,198 @@ export class ZibriApplication { * @throws When the app has already been started. */ async start(port: number): Promise { - if (GlobalRegistry.isAppRunning()) { + if (GlobalRegistry.isAppStarted()) { // We need this check in addition to the one in the registry. - // Because we would otherwise have a wrong state when we call markAppAsRunning + // Because we would otherwise have a wrong state when we call markAppAsStarted // and then this.app.listen fails. throw new Error('The application has already been started'); } - await this.router.attachTo(this); + + const injectables: unknown[] = getAllRegisteredTokens().map(t => inject(t)); + for (const element of injectables.filter(i => implementsOnAppStart(i))) { + await element.onAppStart(this); + } + this.use((req, _, next) => next(new UnmatchedRouteError(req.originalUrl))); this.use(inject(ZIBRI_DI_TOKENS.GLOBAL_ERROR_HANDLER)); this.server.listen(port); - GlobalRegistry.markAppAsRunning(); + GlobalRegistry.markAppAsStarted(); await this.logger.info(`${this.options.name} is running on port ${port}`); } + /** + * Gracefully shuts down the application. + * @param signal - The signal that shuts down the application. Can be left empty if manually called. + */ + async shutdown(signal?: ShutdownSignal): Promise { + switch (GlobalRegistry.getAppData('state')) { + case AppState.OFFLINE: + case AppState.SHUTTING_DOWN: { + // is already offline or shutting down, nothing to do here + return; + } + case AppState.CREATED: { + // nothing has been initialized yet, can simply quit without handling shutdown hooks + await this.logger.info('shutting down...'); + GlobalRegistry.markAppAsShuttingDown(); + for (const [signal, handler] of this.signalHandlers) { + process.off(signal, handler); + } + process.exit(0); + } + case AppState.INITIALIZED: + case AppState.STARTED: { + await this.logger.info('shutting down...'); + GlobalRegistry.markAppAsShuttingDown(); + for (const [signal, handler] of this.signalHandlers) { + process.off(signal, handler); + } + + await this.shutdownHttpServer(); + const injectables: unknown[] = getAllRegisteredTokens().map(t => inject(t)); + await this.beforeAppShutdown(injectables, signal); + await this.onAppShutdown(injectables, signal); + await this.afterAppShutdown(injectables, signal); + + process.exit(0); + } + } + } + + private async beforeAppInit(tokens: DiToken[]): Promise { + const sortedTokens: DiToken[] = tokens.sort((a, b) => { + if ('key' in a && a.key === ZIBRI_DI_TOKENS.DATA_SOURCE_SERVICE.key) { + return -1; + } + if ('key' in b && b.key === ZIBRI_DI_TOKENS.DATA_SOURCE_SERVICE.key) { + return 1; + } + return 0; + }); + + for (const token of sortedTokens) { + const injectable: unknown = inject(token); + if (implementsBeforeAppInit(injectable)) { + await injectable.beforeAppInit(this); + } + } + } + + private async onAppInit(injectables: unknown[]): Promise { + for (const element of injectables.filter(i => implementsOnAppInit(i))) { + await element.onAppInit(this); + } + } + + private async afterAppInit(injectables: unknown[]): Promise { + for (const element of injectables.filter(i => implementsAfterAppInit(i))) { + await element.afterAppInit(this); + } + } + + private async afterAppShutdown(injectables: unknown[], signal: ShutdownSignal | undefined): Promise { + const elements: AfterAppShutdown[] = injectables.filter(i => implementsAfterAppShutdown(i)); + await Promise.all(elements.map(async e => { + try { + const timeoutInMs: number = e.shutdownTimeoutInMs ?? DEFAULT_SHUTDOWN_TIMEOUT_IN_MS; + await PromiseUtilities.withTimeout(e.afterAppShutdown(this, signal), timeoutInMs); + } + catch (error) { + await this.logger.error( + error instanceof Error + ? error + : new Error(`Error running afterAppShutdown for "${e.constructor.name}":`, { cause: error }) + ); + } + })); + } + + private async onAppShutdown(injectables: unknown[], signal: ShutdownSignal | undefined): Promise { + const elements: OnAppShutdown[] = injectables.filter(i => implementsOnAppShutdown(i)); + await Promise.all(elements.map(async e => { + try { + const timeoutInMs: number = e.shutdownTimeoutInMs ?? DEFAULT_SHUTDOWN_TIMEOUT_IN_MS; + await PromiseUtilities.withTimeout(e.onAppShutdown(this, signal), timeoutInMs); + } + catch (error) { + await this.logger.error( + error instanceof Error + ? error + : new Error(`Error running onAppShutdown for "${e.constructor.name}":`, { cause: error }) + ); + } + })); + } + + private async beforeAppShutdown(injectables: unknown[], signal: ShutdownSignal | undefined): Promise { + const elements: BeforeAppShutdown[] = injectables.filter(i => implementsBeforeAppShutdown(i)); + await Promise.all(elements.map(async e => { + try { + const timeoutInMs: number = e.shutdownTimeoutInMs ?? DEFAULT_SHUTDOWN_TIMEOUT_IN_MS; + await PromiseUtilities.withTimeout(e.beforeAppShutdown(this, signal), timeoutInMs); + } + catch (error) { + await this.logger.error( + error instanceof Error + ? error + : new Error(`Error running beforeAppShutdown for "${e.constructor.name}":`, { cause: error }) + ); + } + })); + } + + private async shutdownHttpServer(): Promise { + if (!this.server.listening) { + return; + } + return new Promise((resolve, reject) => { + // eslint-disable-next-line promise/prefer-await-to-callbacks + this.server.close(err => { + if (err) { + reject(err); + } + else { + resolve(); + } + }); + }); + } + + private validateInjectables(injectables: unknown[]): void { + for (const element of injectables) { + if (element instanceof ZibriPlugin) { + throw new Error([ + `Invalid class marked with @Injectable: ${element.constructor.name}`, + 'Plugins interfere with the injection system, making them injectable is forbidden.' + ].join('\n')); + } + if (element instanceof CronJob) { + throw new Error([ + `Invalid class marked with @Injectable: ${element.constructor.name}`, + 'Cron jobs should be registered by the cron service.' + ].join('\n')); + } + if (isTwoFactorMethod(element)) { + throw new Error([ + `Invalid class marked with @Injectable: ${element.constructor.name}`, + 'Two factor methods should be registered by the two factor service.' + ].join('\n')); + } + if (isAuthStrategy(element)) { + throw new Error([ + `Invalid class marked with @Injectable: ${element.constructor.name}`, + 'Auth strategies should be registered by the auth service.' + ].join('\n')); + } + if (isDataSource(element) && !this.options.dataSources.find(ds => ds.name === element.constructor.name)) { + throw new Error([ + `Invalid class marked with @DataSource: ${element.constructor.name}`, + 'The data source has not been included in the application options.' + ].join('\n')); + } + } + } + private createFullOptions(): FullZibriApplicationOptions { const res: FullZibriApplicationOptions = { dataSources: [], @@ -243,4 +376,25 @@ export class ZibriApplication { return res; } + + private async logDefaults(): Promise { + if (!this.providedOptions.authStrategies) { + await this.logger.info('No auth strategies provided, defaults to:'); + for (const strategy of this.options.authStrategies) { + await this.logger.info(` - ${strategy.name}`); + } + } + if (!this.providedOptions.twoFactorMethods) { + await this.logger.info('No two factor methods provided, defaults to:'); + for (const strategy of this.options.twoFactorMethods) { + await this.logger.info(` - ${strategy.name}`); + } + } + if (!this.providedOptions.bodyParsers) { + await this.logger.info('No body parsers provided, defaults to:'); + for (const bodyParser of this.options.bodyParsers) { + await this.logger.info(` - ${bodyParser.name}`); + } + } + } } \ No newline at end of file diff --git a/src/assets/asset-service.interface.ts b/src/assets/asset-service.interface.ts index c258d86..45a5dca 100644 --- a/src/assets/asset-service.interface.ts +++ b/src/assets/asset-service.interface.ts @@ -1,7 +1,6 @@ -import { ZibriApplication } from '../application'; import { TreeNode } from './asset.service'; import { Route } from '../routing/controller-route-configuration.model'; -import { Path } from '../utilities/fs.utilities'; +import { FsPath } from '../utilities/fs.utilities'; /** * Interface for an asset service. @@ -10,32 +9,19 @@ export interface AssetServiceInterface { /** * The path of the assets. */ - readonly assetsPath: Path, + readonly assetsPath: FsPath, /** * The path of the assets which are also publicly registered on the online file explorer. */ - readonly publicAssetsPath: Path, - /** - * The path of the email templates. - */ - readonly emailTemplatePath: Path, - /** - * The path of the page templates. - */ - readonly pageTemplatePath: Path, + readonly publicAssetsPath: FsPath, /** * The path of the component templates. */ - readonly componentTemplatePath: Path, + readonly componentTemplatePath: FsPath, /** * The route under which the file explorer with the public assets is registered. */ readonly assetsRoute: Route, - /** - * Attaches the service to the application. - */ - // eslint-disable-next-line typescript/no-explicit-any - attachTo: (app: ZibriApplication, ...params: any[]) => void | Promise, /** * Builds a file tree. */ diff --git a/src/assets/asset-service.test.ts b/src/assets/asset-service.test.ts index d2f9e61..7387471 100644 --- a/src/assets/asset-service.test.ts +++ b/src/assets/asset-service.test.ts @@ -5,7 +5,7 @@ import { AssetServiceInterface } from './asset-service.interface'; import { TreeNode } from './asset.service'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; describe('AssetService', () => { let assetService: AssetServiceInterface; @@ -16,8 +16,8 @@ describe('AssetService', () => { describe('buildFileTree', () => { it('should build a file tree for a given directory', async () => { - const mockDirPath: Path = FsUtilities.getPath(__dirname, '..', '__testing__', 'mocks', 'tree'); - (assetService.publicAssetsPath as unknown as Path) = mockDirPath; + const mockDirPath: FsPath = FsUtilities.getPath(__dirname, '..', '__testing__', 'mocks', 'tree'); + (assetService.publicAssetsPath as unknown as FsPath) = mockDirPath; const expectedTree: TreeNode[] = [ // Mocked file tree structure { name: 'file1.txt', type: 'file', route: '/assets/file1.txt' }, diff --git a/src/assets/asset.service.ts b/src/assets/asset.service.ts index f330d4b..18c0f69 100644 --- a/src/assets/asset.service.ts +++ b/src/assets/asset.service.ts @@ -5,12 +5,15 @@ import express from 'express'; import { AssetServiceInterface } from './asset-service.interface'; import { ZibriApplication } from '../application'; import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; +import { OnAppInit } from '../global/on-app-init.interface'; import { HttpMethod } from '../http/http-method.enum'; import { type LoggerInterface } from '../logging/logger.interface'; import { FileResponse } from '../parsing/form-data/file-response.model'; import { Route } from '../routing/controller-route-configuration.model'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc @@ -31,17 +34,14 @@ type WalkedPath = { relPath: string, isFile: boolean }; /** * Default asset service implementation of Zibri. */ -export class AssetService implements AssetServiceInterface { +@Injectable({ register: 'onUse' }) +export class AssetService implements AssetServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc - readonly assetsPath: Path = FsUtilities.getPath(__dirname, 'assets'); + readonly assetsPath: FsPath = FsUtilities.getPath(__dirname, 'assets'); // eslint-disable-next-line jsdoc/require-jsdoc - readonly publicAssetsPath: Path = FsUtilities.getPath(this.assetsPath, 'public'); + readonly publicAssetsPath: FsPath = FsUtilities.getPath(this.assetsPath, 'public'); // eslint-disable-next-line jsdoc/require-jsdoc - readonly pageTemplatePath: Path = FsUtilities.getPath(this.assetsPath, 'templates', 'pages'); - // eslint-disable-next-line jsdoc/require-jsdoc - readonly emailTemplatePath: Path = FsUtilities.getPath(this.assetsPath, 'templates', 'emails'); - // eslint-disable-next-line jsdoc/require-jsdoc - readonly componentTemplatePath: Path = FsUtilities.getPath(this.assetsPath, 'templates', 'components'); + readonly componentTemplatePath: FsPath = FsUtilities.getPath(this.assetsPath, 'templates', 'components'); // eslint-disable-next-line jsdoc/require-jsdoc readonly assetsRoute: Route = '/assets'; @@ -51,15 +51,22 @@ export class AssetService implements AssetServiceInterface { ) {} // eslint-disable-next-line jsdoc/require-jsdoc - async attachTo(app: ZibriApplication): Promise { + async onAppInit(app: ZibriApplication): Promise { await this.logger.info(`registers public static assets from folder "${this.publicAssetsPath}" at ${this.assetsRoute}`); app.use(this.assetsRoute, express.static(this.publicAssetsPath)); - await app.router.register({ + + await inject(ZIBRI_DI_TOKENS.ROUTER).registerRoute({ httpMethod: HttpMethod.GET, route: '/favicon.ico', handler: () => FileResponse.fromPath(FsUtilities.getPath(this.publicAssetsPath, 'favicon.png')) }); + // await app.router.registerRoute({ + // httpMethod: HttpMethod.GET, + // route: '/favicon.ico', + // handler: () => FileResponse.fromPath(FsUtilities.getPath(this.publicAssetsPath, 'favicon.png')) + // }); + } // eslint-disable-next-line jsdoc/require-jsdoc @@ -117,14 +124,14 @@ export class AssetService implements AssetServiceInterface { } private async walk( - dir: Path, - base: Path = dir + dir: FsPath, + base: FsPath = dir ): Promise { const entries: Dirent[] = await FsUtilities.readdir(dir); const results: WalkedPath[] = []; for (const entry of entries) { - const abs: Path = FsUtilities.getPath(dir, entry.name); - const rel: Path = FsUtilities.relative(base, abs); + const abs: FsPath = FsUtilities.getPath(dir, entry.name); + const rel: FsPath = FsUtilities.relative(base, abs); if (entry.isDirectory()) { results.push({ relPath: rel, isFile: false }); results.push(...await this.walk(abs, base)); diff --git a/src/auth/2fa/methods/otp/otp-credentials.model.ts b/src/auth/2fa/methods/otp/otp-credentials.model.ts index c210bea..c900fd3 100644 --- a/src/auth/2fa/methods/otp/otp-credentials.model.ts +++ b/src/auth/2fa/methods/otp/otp-credentials.model.ts @@ -6,7 +6,7 @@ import { OmitClass } from '../../../../entity/omit-class.model'; /** * Credentials for a one time password. */ -@Entity() +@Entity({ allowOrphan: true }) export class OtpCredentials extends BaseEntity { /** * The user id that these credentials belong to. diff --git a/src/auth/2fa/methods/otp/otp.two-factor-method.ts b/src/auth/2fa/methods/otp/otp.two-factor-method.ts index 970b447..c177db3 100644 --- a/src/auth/2fa/methods/otp/otp.two-factor-method.ts +++ b/src/auth/2fa/methods/otp/otp.two-factor-method.ts @@ -5,14 +5,12 @@ import { TwoFactorMethod } from '../two-factor-method.interface'; import { OtpCredentials, OtpCredentialsCreateData } from './otp-credentials.model'; import { OtpUtilities } from './otp.utilities'; import { Repository } from '../../../../data-source/repository'; -import { repositoryTokenFor } from '../../../../di/decorators/inject-repository.decorator'; +import { InjectRepository } from '../../../../di/decorators/inject-repository.decorator'; import { Inject } from '../../../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../../../di/default/zibri-di-tokens.default'; -import { inject } from '../../../../di/inject.function'; import { UnauthorizedError } from '../../../../error-handling/errors/unauthorized.error'; import { HttpRequest } from '../../../../http/http-request.model'; import { KnownHeader } from '../../../../http/known-header.enum'; -import { validateEntitiesRegistered } from '../../../../utilities/validate-entities-registered.function'; import { WebsocketRequest } from '../../../../websocket/models/websocket-request.model'; import { BaseUser } from '../../../models/base-user.model'; @@ -31,29 +29,15 @@ export type OtpConfirmRegisterData = { */ export class OtpTwoFactorMethod implements TwoFactorMethod { - private get otpCredentialsRepository(): Repository { - return inject(repositoryTokenFor(OtpCredentials)); - } - constructor( @Inject(ZIBRI_DI_TOKENS.OTP_HEADER) private readonly otpHeader: KnownHeader, @Inject(ZIBRI_DI_TOKENS.OTP_LENGTH) - private readonly otpLength: number + private readonly otpLength: number, + @InjectRepository(OtpCredentials) + private readonly otpCredentialsRepository: Repository ) {} - // eslint-disable-next-line jsdoc/require-jsdoc - init(): void { - if (this.otpHeader == undefined) { - throw new Error('No value provided for ZIBRI_DI_TOKENS.OTP_HEADER'); - } - if (this.otpLength == undefined) { - throw new Error('No value provided for ZIBRI_DI_TOKENS.OTP_LENGTH'); - } - - validateEntitiesRegistered(this.constructor.name, OtpCredentials); - } - // eslint-disable-next-line jsdoc/require-jsdoc async requestRegisterForUser>( user: UserType diff --git a/src/auth/2fa/methods/two-factor-method.interface.ts b/src/auth/2fa/methods/two-factor-method.interface.ts index b15a2e3..90669d6 100644 --- a/src/auth/2fa/methods/two-factor-method.interface.ts +++ b/src/auth/2fa/methods/two-factor-method.interface.ts @@ -6,10 +6,6 @@ import { BaseUser } from '../../models/base-user.model'; * Interface for a two factor method. */ export interface TwoFactorMethod { - /** - * Initializes the two factor method. - */ - init: () => void, /** * Requests to register the two factor method for the given user. */ @@ -35,4 +31,27 @@ export interface TwoFactorMethod void | Promise +} + +/** + * Checks whether or not the given value is a two factor method. + * @param value - The value to check. + * @returns True if all keys of the TwoFactorMethod interface are present, false otherwise. + */ +export function isTwoFactorMethod(value: unknown): value is TwoFactorMethod { + if (value == undefined) { + return false; + } + if (typeof value !== 'object') { + return false; + } + + const keys: (keyof TwoFactorMethod)[] = [ + 'requestRegisterForUser', + 'confirmRegisterForUser', + 'unregisterForUser', + 'validate' + ]; + + return !keys.find(key => !(key in value)); } \ No newline at end of file diff --git a/src/auth/2fa/two-factor-service.interface.ts b/src/auth/2fa/two-factor-service.interface.ts index dd7394c..a655ab2 100644 --- a/src/auth/2fa/two-factor-service.interface.ts +++ b/src/auth/2fa/two-factor-service.interface.ts @@ -12,10 +12,6 @@ export interface TwoFactorServiceInterface { * The different two factor methods provided. */ readonly twoFactorMethods: TwoFactorMethods, - /** - * Initializes the service. - */ - init: (twoFactorMethods: TwoFactorMethods) => Promise, /** * Requests the registration of the given two factor method for the given user. */ diff --git a/src/auth/2fa/two-factor.service.ts b/src/auth/2fa/two-factor.service.ts index c9b4ac0..14aa3df 100644 --- a/src/auth/2fa/two-factor.service.ts +++ b/src/auth/2fa/two-factor.service.ts @@ -1,8 +1,12 @@ +import { ZibriApplication } from '../../application'; +import { Inject } from '../../di/decorators/inject.decorator'; +import { Injectable } from '../../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; import { register } from '../../di/register.function'; +import { OnAppInit } from '../../global/on-app-init.interface'; import { HttpRequest } from '../../http/http-request.model'; -import { LoggerInterface } from '../../logging/logger.interface'; +import { type LoggerInterface } from '../../logging/logger.interface'; import { WebsocketRequest } from '../../websocket/models/websocket-request.model'; import { BaseUser } from '../models/base-user.model'; import { TwoFactorMethod } from './methods/two-factor-method.interface'; @@ -12,21 +16,19 @@ import { TwoFactorServiceInterface } from './two-factor-service.interface'; /** * Default implementation of the two factor service. */ -export class TwoFactorService implements TwoFactorServiceInterface { +@Injectable({ register: 'onUse' }) +export class TwoFactorService implements TwoFactorServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc readonly twoFactorMethods: TwoFactorMethods = []; - /** - * A logger. - */ - protected readonly logger: LoggerInterface; - - constructor() { - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - } + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface + ) { } // eslint-disable-next-line jsdoc/require-jsdoc - async init(twoFactorMethods: TwoFactorMethods): Promise { + async onAppInit({ options }: ZibriApplication): Promise { + const { twoFactorMethods } = options; for (const method of twoFactorMethods) { register({ token: method, useClass: method }); this.twoFactorMethods.push(method); @@ -37,7 +39,6 @@ export class TwoFactorService implements TwoFactorServiceInterface { ); for (const method of twoFactorMethods) { await this.logger.info(` - ${method.name}`); - inject(method).init(); } } } diff --git a/src/auth/auth-service.interface.ts b/src/auth/auth-service.interface.ts index b67212c..7bbfd61 100644 --- a/src/auth/auth-service.interface.ts +++ b/src/auth/auth-service.interface.ts @@ -19,10 +19,6 @@ export interface AuthServiceInterface { * The different auth strategies provided. */ readonly strategies: AuthStrategies, - /** - * Initializes the service. - */ - init: (strategies: AuthStrategies) => void | Promise, /** * Checks if the provided method on the provided controller can be accessed by the current user. */ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c474def..4381566 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,7 +1,8 @@ import { AuthServiceInterface } from './auth-service.interface'; +import { ZibriApplication } from '../application'; import { register } from '../di/register.function'; import { BaseEntity } from '../entity/base-entity.model'; -import { TwoFactorServiceInterface } from './2fa/two-factor-service.interface'; +import { type TwoFactorServiceInterface } from './2fa/two-factor-service.interface'; import { BaseUser } from './models/base-user.model'; import { BelongsToMetadata, SkipBelongsToMetadata } from './models/belongs-to-metadata.model'; import { HasRoleMetadata, SkipHasRoleMetadata } from './models/has-role-metadata.model'; @@ -11,11 +12,14 @@ import { Require2faMetadata, SkipRequire2faMetadata } from './models/require-2fa import { SkipAuthMetadata } from './models/skip-auth-metadata.model'; import { AuthStrategies } from './strategies/auth-strategies.model'; import { AuthStrategyInterface } from './strategies/auth-strategy.interface'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { UnauthorizedError } from '../error-handling/errors/unauthorized.error'; +import { OnAppInit } from '../global/on-app-init.interface'; import { HttpRequest } from '../http/http-request.model'; -import { LoggerInterface } from '../logging/logger.interface'; +import { type LoggerInterface } from '../logging/logger.interface'; import { Newable } from '../types/newable.type'; import { MetadataUtilities } from '../utilities/metadata.utilities'; import { PromiseUtilities } from '../utilities/promise.utilities'; @@ -24,26 +28,21 @@ import { WebsocketRequest } from '../websocket/models/websocket-request.model'; /** * Default auth service implementation of Zibri. */ -export class AuthService implements AuthServiceInterface { - /** - * A logger. - */ - protected readonly logger: LoggerInterface; - +@Injectable({ register: 'onUse' }) +export class AuthService implements AuthServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc readonly strategies: AuthStrategies = []; - // eslint-disable-next-line jsdoc/require-jsdoc - get twoFactorService(): TwoFactorServiceInterface { - return inject(ZIBRI_DI_TOKENS.TWO_FACTOR_SERVICE); - } - - constructor() { - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - } + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.TWO_FACTOR_SERVICE) + private readonly twoFactorService: TwoFactorServiceInterface + ) {} // eslint-disable-next-line jsdoc/require-jsdoc - async init(authStrategies: AuthStrategies): Promise { + async onAppInit({ options }: ZibriApplication): Promise { + const { authStrategies } = options; for (const strategy of authStrategies) { register({ token: strategy, useClass: strategy }); this.strategies.push(strategy); @@ -54,7 +53,6 @@ export class AuthService implements AuthServiceInterface { ); for (const strategy of authStrategies) { await this.logger.info(` - ${strategy.name}`); - inject(strategy).init(); } } } diff --git a/src/auth/models/password-reset-token.model.ts b/src/auth/models/password-reset-token.model.ts index 3890fe8..2c7f0a4 100644 --- a/src/auth/models/password-reset-token.model.ts +++ b/src/auth/models/password-reset-token.model.ts @@ -6,7 +6,7 @@ import { OmitClass } from '../../entity/omit-class.model'; /** * A short lived token used to confirm a password reset. */ -@Entity() +@Entity({ allowOrphan: true }) export class PasswordResetToken extends BaseEntity { /** * The expiration date of the password reset token. diff --git a/src/auth/strategies/auth-strategy.interface.ts b/src/auth/strategies/auth-strategy.interface.ts index d55093f..92c881b 100644 --- a/src/auth/strategies/auth-strategy.interface.ts +++ b/src/auth/strategies/auth-strategy.interface.ts @@ -1,3 +1,4 @@ +import { AuthStrategies } from './auth-strategies.model'; import { BaseEntity } from '../../entity/base-entity.model'; import { HttpRequest } from '../../http/http-request.model'; import { OpenApiSecuritySchemeObject } from '../../open-api/open-api.model'; @@ -18,10 +19,6 @@ export interface AuthStrategyInterface< RefreshLoginDataType, LogoutData > { - /** - * Initializes the strategy. - */ - init: () => void, /** * Resolves the current user. */ @@ -71,4 +68,34 @@ export interface AuthStrategyInterface< * The name of the auth strategy. */ name: string +} + +/** + * Checks whether or not the given value is a auth strategy. + * @param value - The value to check. + * @returns True if all keys of the AuthStrategyInterface are present, false otherwise. + */ +export function isAuthStrategy(value: unknown): value is InstanceType { + if (value == undefined) { + return false; + } + if (typeof value !== 'object') { + return false; + } + + const keys: (keyof InstanceType)[] = [ + 'belongsTo', + 'confirmPasswordReset', + 'hasRole', + 'isLoggedIn', + 'login', + 'logout', + 'name', + 'refreshLogin', + 'requestPasswordReset', + 'resolveUser', + 'securityScheme' + ]; + + return !keys.find(key => !(key in value)); } \ No newline at end of file diff --git a/src/auth/strategies/jwt/jwt-auth.controller.ts b/src/auth/strategies/jwt/jwt-auth.controller.ts index 8fabaa5..fe3109e 100644 --- a/src/auth/strategies/jwt/jwt-auth.controller.ts +++ b/src/auth/strategies/jwt/jwt-auth.controller.ts @@ -9,6 +9,7 @@ import { Inject } from '../../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; import { Property } from '../../../entity/decorators/property.decorator'; import { Response } from '../../../open-api/decorators/response.decorator'; +import { PreactEmailComponent } from '../../../preact/preact-email-component.model'; import { Body } from '../../../routing/decorators/body.decorator'; import { Controller } from '../../../routing/decorators/controller.decorator'; import { Post } from '../../../routing/decorators/post.decorator'; @@ -18,6 +19,21 @@ import { BaseUser } from '../../models/base-user.model'; import { PasswordResetToken } from '../../models/password-reset-token.model'; import { type UserServiceInterface } from '../../user/user-service.interface'; +/** + * Properties of a password reset email. + */ +type PasswordResetEmailTemplateProps> = { + confirmPasswordResetLink: string, + user: UserType +}; + +/** + * Definition for a password reset email template. + */ +export type PasswordResetEmailTemplate< + Role extends string, UserType extends BaseUser +> = PreactEmailComponent>; + class JwtRequestPasswordResetInput { @Property.string({ format: 'email' }) email!: string; @@ -33,7 +49,7 @@ class JwtVerifyPasswordResetTokenResponse { isValid!: boolean; } -@Controller('/auth') +@Controller('/auth', { allowOrphan: true }) export class JwtAuthController implements AuthControllerInterface< JwtCredentialsDto, JwtAuthData, diff --git a/src/auth/strategies/jwt/jwt-credentials.model.ts b/src/auth/strategies/jwt/jwt-credentials.model.ts index e5a9b2f..9a5a3d0 100644 --- a/src/auth/strategies/jwt/jwt-credentials.model.ts +++ b/src/auth/strategies/jwt/jwt-credentials.model.ts @@ -7,7 +7,7 @@ import { BaseUser } from '../../models/base-user.model'; /** * The credentials used by the jwt auth strategy. */ -@Entity() +@Entity({ allowOrphan: true }) export class JwtCredentials extends BaseEntity implements Pick, 'id' | 'email'> { /** * The id of the user that this credentials belong to. diff --git a/src/auth/strategies/jwt/jwt-refresh-token.model.ts b/src/auth/strategies/jwt/jwt-refresh-token.model.ts index 1762b5f..9234c82 100644 --- a/src/auth/strategies/jwt/jwt-refresh-token.model.ts +++ b/src/auth/strategies/jwt/jwt-refresh-token.model.ts @@ -6,7 +6,7 @@ import { OmitClass } from '../../../entity/omit-class.model'; /** * The jwt refresh token that gets stored in the data source. */ -@Entity() +@Entity({ allowOrphan: true }) export class JwtRefreshToken extends BaseEntity { /** * The id of the user that this token belongs to. diff --git a/src/auth/strategies/jwt/jwt.auth-strategy.ts b/src/auth/strategies/jwt/jwt.auth-strategy.ts index 2bd73e3..79e24df 100644 --- a/src/auth/strategies/jwt/jwt.auth-strategy.ts +++ b/src/auth/strategies/jwt/jwt.auth-strategy.ts @@ -11,30 +11,30 @@ import { JwtRefreshToken, JwtRefreshTokenCreateDto } from './jwt-refresh-token.m import { JwtRequestPasswordResetData } from './jwt-request-password-reset-data.model'; import { JwtUtilities } from './jwt.utilities'; import { Repository } from '../../../data-source/repository'; -import { repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; +import { InjectRepository, repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; import { NoProviderError } from '../../../di/errors/no-provider.error'; import { inject } from '../../../di/inject.function'; -import { EmailServiceInterface } from '../../../email/email-service.interface'; +import { type EmailServiceInterface } from '../../../email/email-service.interface'; import { EmailPriority } from '../../../email/models/email-priority.enum'; import { BaseEntity } from '../../../entity/base-entity.model'; import { TooManyRequestsError } from '../../../error-handling/errors/too-many-requests.error'; import { UnauthorizedError } from '../../../error-handling/errors/unauthorized.error'; import { GlobalRegistry } from '../../../global/global-registry'; -import { renderEmailTemplate } from '../../../handlebars/render-template.function'; import { HttpRequest } from '../../../http/http-request.model'; import { OpenApiSecuritySchemeObject } from '../../../open-api/open-api.model'; import { Newable } from '../../../types/newable.type'; import { Ms } from '../../../utilities/ms'; import { UUIDUtilities } from '../../../utilities/uuid.utilities'; -import { validateEntitiesRegistered } from '../../../utilities/validate-entities-registered.function'; import { WebsocketRequest } from '../../../websocket/models/websocket-request.model'; import { HashUtilities } from '../../hash.utilities'; import { BaseUser } from '../../models/base-user.model'; import { PasswordResetToken, PasswordResetTokenCreateData } from '../../models/password-reset-token.model'; -import { UserServiceInterface } from '../../user/user-service.interface'; -import { NO_USER_REPOSITORIES_PROVIDED_ERROR_MESSAGE } from '../../user/user.service'; +import { type UserServiceInterface } from '../../user/user-service.interface'; import { AuthStrategyInterface } from '../auth-strategy.interface'; +import { PasswordResetEmailTemplate } from './jwt-auth.controller'; +import { PreactUtilities } from '../../../preact/preact.utilities'; /** * Jwt auth strategy implementation of Zibri. @@ -65,65 +65,51 @@ implements AuthStrategyInterface< }; private readonly accessTokenSecret: string; - private readonly accessTokenExpiresInMs: number; private readonly refreshTokenSecret: string; - private readonly refreshTokenExpiresInMs: number; - private readonly passwordResetTokenExpiresInMs: number; - private readonly userService: UserServiceInterface; - private readonly emailService: EmailServiceInterface; private readonly confirmPasswordResetUrl: string; - - private get refreshTokenRepository(): Repository { - return inject(repositoryTokenFor(JwtRefreshToken)); - } - - private get passwordResetTokenRepository(): Repository { - return inject(repositoryTokenFor(PasswordResetToken)); - } - - private get credentialsRepository(): Repository { - return inject(repositoryTokenFor(JwtCredentials)); - } - - constructor() { + private readonly PasswordResetEmail: PasswordResetEmailTemplate; + + constructor( + @InjectRepository(JwtRefreshToken) + private readonly refreshTokenRepository: Repository, + @InjectRepository(PasswordResetToken) + private readonly passwordResetTokenRepository: Repository, + @InjectRepository(JwtCredentials) + private readonly credentialsRepository: Repository, + @Inject(ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_EXPIRES_IN_MS) + private readonly accessTokenExpiresInMs: number, + @Inject(ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_EXPIRES_IN_MS) + private readonly refreshTokenExpiresInMs: number, + @Inject(ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_TOKEN_EXPIRES_IN_MS) + private readonly passwordResetTokenExpiresInMs: number, + @Inject(ZIBRI_DI_TOKENS.USER_SERVICE) + private readonly userService: UserServiceInterface, + @Inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE) + private readonly emailService: EmailServiceInterface + ) { const accessTokenSecret: string | undefined = inject(ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_SECRET); if (!accessTokenSecret) { - throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_SECRET, [JwtAuthStrategy]); + throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_SECRET, []); } const refreshTokenSecret: string | undefined = inject(ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_SECRET); if (!refreshTokenSecret) { - throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_SECRET, [JwtAuthStrategy]); + throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_SECRET, []); } const confirmPasswordResetUrl: string | undefined = inject(ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL); if (!confirmPasswordResetUrl) { - throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL, [JwtAuthStrategy]); + throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL, []); + } + const PasswordResetEmail: PasswordResetEmailTemplate | undefined = inject( + ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE + ); + if (!PasswordResetEmail) { + throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE, []); } + this.accessTokenSecret = accessTokenSecret; - this.accessTokenExpiresInMs = inject(ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_EXPIRES_IN_MS); this.refreshTokenSecret = refreshTokenSecret; - this.refreshTokenExpiresInMs = inject(ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_EXPIRES_IN_MS); - this.passwordResetTokenExpiresInMs = inject(ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_TOKEN_EXPIRES_IN_MS); - this.userService = inject(ZIBRI_DI_TOKENS.USER_SERVICE); - this.emailService = inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE); this.confirmPasswordResetUrl = confirmPasswordResetUrl; - } - - // eslint-disable-next-line jsdoc/require-jsdoc - init(): void { - if (!this.accessTokenSecret) { - throw new Error('No value provided for ZIBRI_DI_TOKENS.JWT_ACCESS_TOKEN_SECRET'); - } - if (!this.refreshTokenSecret) { - throw new Error('No value provided for ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_SECRET'); - } - if (!this.confirmPasswordResetUrl) { - throw new Error('No value provided for ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL'); - } - if (!GlobalRegistry.userRepositories.length) { - throw new Error(NO_USER_REPOSITORIES_PROVIDED_ERROR_MESSAGE); - } - - validateEntitiesRegistered(this.constructor.name, JwtRefreshToken, JwtCredentials, PasswordResetToken); + this.PasswordResetEmail = PasswordResetEmail; } // eslint-disable-next-line jsdoc/require-jsdoc @@ -250,20 +236,18 @@ implements AuthStrategyInterface< }; const resetToken: PasswordResetToken = await this.passwordResetTokenRepository.create(resetTokenData); + const html: string = PreactUtilities.renderEmail( + this.PasswordResetEmail, + { + user: data.user, + confirmPasswordResetLink: `${data.emailData?.confirmPasswordResetUrl ?? this.confirmPasswordResetUrl}/${resetToken.value}` + } + ); + await this.emailService.queue({ recipients: [data.user.email], subject: 'Password Reset', - html: await renderEmailTemplate( - 'password-reset.hbs', - { - user: data.user, - confirmPasswordResetUrl: data.emailData?.confirmPasswordResetUrl ?? this.confirmPasswordResetUrl, - resetToken, - base: { - title: 'Password Reset' - } - } - ), + html, priority: EmailPriority.HIGH, ...data }); diff --git a/src/auth/user/user.service.ts b/src/auth/user/user.service.ts index 5d33b35..80a29bc 100644 --- a/src/auth/user/user.service.ts +++ b/src/auth/user/user.service.ts @@ -1,4 +1,5 @@ import { UserServiceInterface } from './user-service.interface'; +import { Injectable } from '../../di/decorators/injectable.decorator'; import { inject } from '../../di/inject.function'; import { NotFoundError } from '../../error-handling/errors/not-found.error'; import { GlobalRegistry } from '../../global/global-registry'; @@ -10,6 +11,7 @@ export const NO_USER_REPOSITORIES_PROVIDED_ERROR_MESSAGE: string = 'No user repo /** * Default user service implementation of Zibri. */ +@Injectable({ register: 'onUse' }) export class UserService implements UserServiceInterface { // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/backup/backup-entity.model.ts b/src/backup/backup-entity.model.ts index a40136b..019c50c 100644 --- a/src/backup/backup-entity.model.ts +++ b/src/backup/backup-entity.model.ts @@ -10,7 +10,7 @@ import { OmitStrict } from '../types/omit-strict.type'; /** * The entity of a single backup. */ -@Entity() +@Entity({ allowOrphan: true }) export class BackupEntity extends BaseEntity { /** * The name of the backup. Should be unique. diff --git a/src/backup/backup-resource-entity.model.ts b/src/backup/backup-resource-entity.model.ts index 8eef060..4c3de3f 100644 --- a/src/backup/backup-resource-entity.model.ts +++ b/src/backup/backup-resource-entity.model.ts @@ -7,7 +7,7 @@ import { OmitStrict } from '../types/omit-strict.type'; /** * A single resource entity. */ -@Entity() +@Entity({ allowOrphan: true }) export class BackupResourceEntity extends BaseEntity { /** * The name of the resource. diff --git a/src/backup/backup-service.interface.ts b/src/backup/backup-service.interface.ts index a81c533..edd5e46 100644 --- a/src/backup/backup-service.interface.ts +++ b/src/backup/backup-service.interface.ts @@ -26,10 +26,6 @@ export interface BackupServiceInterface { * A subject that contains whether or not a backup is currently being restored. */ isRestoringBackup: BehaviorSubject, - /** - * Initializes the service. - */ - init: () => void | Promise, /** * Synchronizes backup entities with the data from all transports. */ diff --git a/src/backup/backup-service.test.ts b/src/backup/backup-service.test.ts index dbbbf71..279bc61 100644 --- a/src/backup/backup-service.test.ts +++ b/src/backup/backup-service.test.ts @@ -1,25 +1,25 @@ import { beforeAll, afterAll, describe, it, expect } from '@jest/globals'; -import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import { BackupEntity } from './backup-entity.model'; import { BackupResourceEntity } from './backup-resource-entity.model'; -import { BackupServiceInterface } from './backup-service.interface'; -import { POSTGRES_TEST_IMAGE, testFileFolder } from '../__testing__/constants'; +import { BackupService } from './backup.service'; +import { testFileFolder } from '../__testing__/constants'; import { Backup } from './decorators/backup-resource.decorator'; import { FsBackupTransport } from './transports/fs.backup-transport'; +import { defaultTestServerEntities } from '../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../__testing__/test-server/start-test-server.function'; import { PostgresDataSource, PostgresOptions } from '../data-source/data-sources/postgres-data-source.model'; import { DataSource } from '../data-source/decorators/data-source.decorator'; -import { MigrationEntity } from '../data-source/migration/migration-entity.model'; import { Repository } from '../data-source/repository'; -import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; import { inject } from '../di/inject.function'; import { BaseEntity } from '../entity/base-entity.model'; import { Entity } from '../entity/decorators/entity.decorator'; import { Property } from '../entity/decorators/property.decorator'; import { Newable } from '../types/newable.type'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; -const backupFsFolder: Path = FsUtilities.getPath(testFileFolder, 'backups'); +const backupFsFolder: FsPath = FsUtilities.getPath(testFileFolder, 'backups'); @Entity() class Item { @@ -49,40 +49,30 @@ class DbDataSource extends PostgresDataSource { database: 'db', synchronize: true }; - entities: Newable[] = [Item, MigrationEntity, BackupResourceEntity, BackupEntity]; + entities: Newable[] = [...defaultTestServerEntities, Item, BackupResourceEntity, BackupEntity]; } describe('Create and restore postgres backup', () => { - let dataSource: DbDataSource; + let server: StartedTestServer; + let backupService: BackupService; let itemRepository: Repository; let backupRepository: Repository; - let container: StartedPostgreSqlContainer; - let backupService: BackupServiceInterface; beforeAll(async () => { await FsUtilities.rm(backupFsFolder); - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - - dataSource = inject(DbDataSource); - dataSource.options = { - ...dataSource.options, - port: container.getMappedPort(5432) - }; - await dataSource.init(); - itemRepository = dataSource.getRepository(Item); - backupRepository = dataSource.getRepository(BackupEntity); + server = await startTestServer({ dataSources: [DbDataSource] }); + itemRepository = inject(repositoryTokenFor(Item)); + backupRepository = inject(repositoryTokenFor(BackupEntity)); + backupService = inject(BackupService); // seed one row without `value` await itemRepository.create({ value: '42' }); - - backupService = inject(ZIBRI_DI_TOKENS.BACKUP_SERVICE); - await backupService.init(); }, 15000); + afterAll(async () => { + await server.shutdown(); + }); + it('should create and restore a backup', async () => { expect((await itemRepository.findAll()).length).toEqual(1); await backupService.createBackup(); @@ -97,8 +87,4 @@ describe('Create and restore postgres backup', () => { await backupService.restore(backup); expect((await itemRepository.findAll()).length).toEqual(1); }, 15000); - - afterAll(async () => { - await container.stop(); - }); }); \ No newline at end of file diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts index 9eebcbf..bf27212 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -1,17 +1,20 @@ import { Readable } from 'node:stream'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, first, firstValueFrom } from 'rxjs'; import { BackupEntity, BackupEntityCreateData } from './backup-entity.model'; import { BackupResourceEntity, BackupResourceEntityCreateData } from './backup-resource-entity.model'; import { BackupResourceInterface } from './backup-resource.interface'; import { BackupCreateData, BackupServiceInterface } from './backup-service.interface'; +import { ZibriApplication } from '../application'; import { PostgresDataSource } from '../data-source/data-sources/postgres-data-source.model'; import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { GlobalRegistry } from '../global/global-registry'; -import { LoggerInterface } from '../logging/logger.interface'; +import { type LoggerInterface } from '../logging/logger.interface'; import { Newable } from '../types/newable.type'; import { MetadataUtilities } from '../utilities/metadata.utilities'; import { PromiseUtilities } from '../utilities/promise.utilities'; @@ -19,12 +22,15 @@ import { validateEntitiesRegistered } from '../utilities/validate-entities-regis import { BackupResourceMetadata } from './decorators/backup-resource-metadata.model'; import { BackupTransportInterface } from './transports/backup-transport.interface'; import { Repository } from '../data-source/repository'; +import { OnAppInit } from '../global/on-app-init.interface'; +import { OnAppShutdown } from '../global/on-app-shutdown.interface'; +import { Ms } from '../utilities/ms'; /** * Default implementation of the backup service. */ -export class BackupService implements BackupServiceInterface { - private readonly logger: LoggerInterface; +@Injectable({ register: 'onUse' }) +export class BackupService implements BackupServiceInterface, OnAppInit, OnAppShutdown { private readonly backupResources: Newable[] = []; private get backupRepository(): Repository { @@ -37,16 +43,20 @@ export class BackupService implements BackupServiceInterface { // eslint-disable-next-line jsdoc/require-jsdoc readonly isRestoringBackup: BehaviorSubject = new BehaviorSubject(false); - constructor() { - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - } + // eslint-disable-next-line jsdoc/require-jsdoc + readonly shutdownTimeoutInMs: number = Ms.MINUTE * 15; + + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface + ) {} // eslint-disable-next-line jsdoc/require-jsdoc - async init(): Promise { + async onAppInit(app: ZibriApplication): Promise { if (GlobalRegistry.backupResources.length) { // eslint-disable-next-line stylistic/max-len await this.logger.info(`configures ${GlobalRegistry.backupResources.length} ${GlobalRegistry.backupResources.length > 1 ? 'resources' : 'resource'} to be backed up:`); - validateEntitiesRegistered(BackupService.name, BackupResourceEntity, BackupEntity); + validateEntitiesRegistered(BackupService.name, app, BackupResourceEntity, BackupEntity); } for (const resourceClass of GlobalRegistry.backupResources) { @@ -67,6 +77,14 @@ export class BackupService implements BackupServiceInterface { await this.syncBackupEntities(); } + // eslint-disable-next-line jsdoc/require-jsdoc + async onAppShutdown(): Promise { + await Promise.all([ + firstValueFrom(this.isCreatingBackup.pipe(first(v => !v))), + firstValueFrom(this.isRestoringBackup.pipe(first(v => !v))) + ]); + } + // eslint-disable-next-line jsdoc/require-jsdoc async syncBackupEntities(): Promise { if (!this.backupResources.length) { @@ -254,6 +272,6 @@ export class BackupService implements BackupServiceInterface { } } - throw new Error('Could not resolve data for'); + throw new Error(`Could not resolve backup data for resource "${resource.name}".`); } } \ No newline at end of file diff --git a/src/backup/transports/fs.backup-transport.ts b/src/backup/transports/fs.backup-transport.ts index 1e104f4..9cc5e5f 100644 --- a/src/backup/transports/fs.backup-transport.ts +++ b/src/backup/transports/fs.backup-transport.ts @@ -6,7 +6,7 @@ import { BackupTransportInterface } from './backup-transport.interface'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; import { LoggerInterface } from '../../logging/logger.interface'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; import { BackupEntity } from '../backup-entity.model'; import { BackupResourceEntity } from '../backup-resource-entity.model'; @@ -21,7 +21,7 @@ export class FsBackupTransport implements BackupTransportInterface { return inject(ZIBRI_DI_TOKENS.LOGGER); } - constructor(readonly name: string, protected readonly backupBasePath: Path) {} + constructor(readonly name: string, protected readonly backupBasePath: FsPath) {} // eslint-disable-next-line jsdoc/require-jsdoc async resolveBackups(existingEntities: BackupEntity[]): Promise { @@ -37,7 +37,7 @@ export class FsBackupTransport implements BackupTransportInterface { if (existingEntities.map(e => e.name).includes(node.name)) { return; } - const p: Path = FsUtilities.getPath(node.parentPath, node.name, METADATA_FILENAME); + const p: FsPath = FsUtilities.getPath(node.parentPath, node.name, METADATA_FILENAME); if (!await FsUtilities.exists(p)) { await this.logger.warn(`Could not find the metadata file needed to resolve a backup: "${p}"`); return; @@ -50,7 +50,7 @@ export class FsBackupTransport implements BackupTransportInterface { // eslint-disable-next-line jsdoc/require-jsdoc async storeData(data: Readable, backup: BackupEntity, resource: BackupResourceEntity): Promise { - const p: Path = this.getResourcePath(backup, resource); + const p: FsPath = this.getResourcePath(backup, resource); await FsUtilities.mkdir(this.getBackupPath(backup)); await FsUtilities.createFile(this.getBackupMetadataPath(backup), JSON.stringify(backup)); @@ -62,25 +62,25 @@ export class FsBackupTransport implements BackupTransportInterface { // eslint-disable-next-line jsdoc/require-jsdoc retrieveData(backup: BackupEntity, resource: BackupResourceEntity): Readable | Promise { - const p: Path = this.getResourcePath(backup, resource); + const p: FsPath = this.getResourcePath(backup, resource); return FsUtilities.createReadStream(p); } // eslint-disable-next-line jsdoc/require-jsdoc async deleteData(backup: BackupEntity, resource: BackupResourceEntity): Promise { - const p: Path = this.getResourcePath(backup, resource); + const p: FsPath = this.getResourcePath(backup, resource); await FsUtilities.rm(p); } - private getResourcePath(backup: BackupEntity, resource: BackupResourceEntity): Path { + private getResourcePath(backup: BackupEntity, resource: BackupResourceEntity): FsPath { return FsUtilities.getPath(this.getBackupPath(backup), resource.name); } - private getBackupPath(backup: BackupEntity): Path { + private getBackupPath(backup: BackupEntity): FsPath { return FsUtilities.getPath(this.backupBasePath, backup.name); } - private getBackupMetadataPath(backup: BackupEntity): Path { + private getBackupMetadataPath(backup: BackupEntity): FsPath { return FsUtilities.getPath(this.getBackupPath(backup), METADATA_FILENAME); } } \ No newline at end of file diff --git a/src/change-sets/change-set-repository.ts b/src/change-sets/change-set-repository.ts index 8da0afc..a9b8790 100644 --- a/src/change-sets/change-set-repository.ts +++ b/src/change-sets/change-set-repository.ts @@ -1,4 +1,4 @@ -import { setTimeout } from 'timers/promises'; +import { setTimeout } from 'node:timers/promises'; import { isDeepStrictEqual } from 'util'; import { Repository as TORepository } from 'typeorm'; @@ -57,17 +57,15 @@ export class ChangeSetRepository< */ protected readonly keysToExcludeFromChangeSets: (keyof T)[]; - private get changeSetRepository(): Repository { - return inject(repositoryTokenFor(ChangeSet)); - } - - private get authService(): AuthServiceInterface { - return inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); - } + private readonly changeSetRepository: Repository; + private readonly authService: AuthServiceInterface; constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface) { super(entityClass, repo, logger); + this.authService = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); + this.changeSetRepository = inject(repositoryTokenFor(ChangeSet)); + this.keysToExcludeFromChangeSets = ['changeSets']; const props: Record = MetadataUtilities.getModelProperties(entityClass); for (const [key, m] of ObjectUtilities.entries(props)) { @@ -425,7 +423,10 @@ export class ChangeSetRepository< * @returns The id of the currently logged in user or undefined if that didn't work. */ protected async getCreatedBy(): Promise { - const currentRequest: HttpRequest = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST); + const currentRequest: HttpRequest | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST); + if (!currentRequest) { + throw new Error('No request in context'); + } const user: BaseUser | undefined = await this.authService.getCurrentUser( currentRequest, this.authService.strategies, diff --git a/src/cron/cron-job.model.ts b/src/cron/cron-job.model.ts index d7e0c59..875c119 100644 --- a/src/cron/cron-job.model.ts +++ b/src/cron/cron-job.model.ts @@ -8,7 +8,7 @@ import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { unknownToErrorString } from '../error-handling/unknown-to-error-string.function'; -import { LoggerInterface } from '../logging/logger.interface'; +import { type LoggerInterface } from '../logging/logger.interface'; import { OmitStrict } from '../types/omit-strict.type'; import { Ms } from '../utilities/ms'; import { UUIDUtilities } from '../utilities/uuid.utilities'; @@ -117,6 +117,14 @@ export abstract class CronJob { await this.initTask(); } + /** + * Shuts down the cron job. + * Should be called from the cron service BeforeAppShutdown hook. + */ + async shutdown(): Promise { + await this.task?.stop(); + } + /** * Initializes the node cron task. */ diff --git a/src/cron/cron-service.interface.ts b/src/cron/cron-service.interface.ts index f377877..ed3bc81 100644 --- a/src/cron/cron-service.interface.ts +++ b/src/cron/cron-service.interface.ts @@ -1,6 +1,5 @@ import { CronJob } from './cron-job.model'; import { CronUpdateData } from './cron.service'; -import { Newable } from '../types/newable.type'; /** * Interface for a cron service. @@ -10,10 +9,6 @@ export interface CronServiceInterface { * The cron jobs that are registered. */ readonly cronJobs: CronJob[], - /** - * Initializes all cron jobs. - */ - init: (cronJobs: Newable[]) => Promise, /** * Schedules the given cron job. */ diff --git a/src/cron/cron.service.ts b/src/cron/cron.service.ts index 8cbccef..eba27f3 100644 --- a/src/cron/cron.service.ts +++ b/src/cron/cron.service.ts @@ -1,10 +1,15 @@ import { CronJobEntity } from './cron-job-entity.model'; import { CronJob } from './cron-job.model'; import { CronServiceInterface } from './cron-service.interface'; +import { ZibriApplication } from '../application'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; -import { LoggerInterface } from '../logging/logger.interface'; -import { Newable } from '../types/newable.type'; +import { register } from '../di/register.function'; +import { AfterAppInit } from '../global/after-app-init.interface'; +import { BeforeAppShutdown } from '../global/before-app-shutdown.interface'; +import { type LoggerInterface } from '../logging/logger.interface'; import { OmitStrict } from '../types/omit-strict.type'; /** @@ -15,21 +20,19 @@ export type CronUpdateData = Partial[]): Promise { + async afterAppInit({ options }: ZibriApplication): Promise { + const { cronJobs } = options; if (this.cronJobs.length) { throw new Error('has already been initialized'); } @@ -37,6 +40,7 @@ export class CronService implements CronServiceInterface { await this.logger.info(`registers ${cronJobs.length} ${cronJobs.length > 1 ? 'cron jobs' : 'cron job'}`); } for (const cronJobClass of cronJobs) { + register({ token: cronJobClass, useClass: cronJobClass }); const cronJob: CronJob = inject(cronJobClass); await cronJob.init(); await this.logger.info(` - ${cronJobClass.name} (${cronJob.active ? 'active' : 'not active'})`); @@ -44,6 +48,11 @@ export class CronService implements CronServiceInterface { } } + // eslint-disable-next-line jsdoc/require-jsdoc + async beforeAppShutdown(): Promise { + await Promise.all(this.cronJobs.map(j => j.shutdown())); + } + // eslint-disable-next-line jsdoc/require-jsdoc async schedule(cronJob: CronJob): Promise { await cronJob.init(); diff --git a/src/data-source/data-source-service.interface.ts b/src/data-source/data-source-service.interface.ts index daf09dc..66cf199 100644 --- a/src/data-source/data-source-service.interface.ts +++ b/src/data-source/data-source-service.interface.ts @@ -1,9 +1,7 @@ +import { AfterAppShutdown } from '../global/after-app-shutdown.interface'; +import { BeforeAppInit } from '../global/before-app-init.interface'; + /** * Interface for a data source service. */ -export interface DataSourceServiceInterface { - /** - * Initializes all data sources. - */ - init: () => Promise -} \ No newline at end of file +export interface DataSourceServiceInterface extends BeforeAppInit, AfterAppShutdown {} \ No newline at end of file diff --git a/src/data-source/data-source.service.ts b/src/data-source/data-source.service.ts index ba70d7d..21fcd71 100644 --- a/src/data-source/data-source.service.ts +++ b/src/data-source/data-source.service.ts @@ -1,66 +1,53 @@ import { DataSourceServiceInterface } from './data-source-service.interface'; -import { BaseEntity } from '../entity/base-entity.model'; +import { ZibriApplication } from '../application'; import { DataSourceInterface } from './data-sources/data-source.interface'; -import { OtpCredentials } from '../auth/2fa/methods/otp/otp-credentials.model'; -import { PasswordResetToken } from '../auth/models/password-reset-token.model'; -import { JwtCredentials } from '../auth/strategies/jwt/jwt-credentials.model'; -import { JwtRefreshToken } from '../auth/strategies/jwt/jwt-refresh-token.model'; -import { BackupEntity } from '../backup/backup-entity.model'; -import { BackupResourceEntity } from '../backup/backup-resource-entity.model'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; -import { MailingListSubscriber } from '../email/mailing-list/models/mailing-list-subscriber.model'; -import { MailingList } from '../email/mailing-list/models/mailing-list.model'; import { GlobalRegistry } from '../global/global-registry'; -import { Log } from '../logging/log.model'; -import { LoggerInterface } from '../logging/logger.interface'; -import { Invoice } from '../plugin/invoicing/models/invoice.model'; -import { NumberInvoices } from '../plugin/invoicing/models/number-invoices.model'; -import { Payment } from '../plugin/payment/models/payment.model'; -import { Newable } from '../types/newable.type'; +import { type LoggerInterface } from '../logging/logger.interface'; +import { MetadataUtilities } from '../utilities/metadata.utilities'; import { validateEntitiesRegistered } from '../utilities/validate-entities-registered.function'; /** * Default data source service implementation of Zibri. */ +@Injectable({ register: 'onUse' }) export class DataSourceService implements DataSourceServiceInterface { - private readonly logger: LoggerInterface; + private readonly dataSources: DataSourceInterface[] = []; - private readonly allowedOrphans: Newable[] = [ - JwtRefreshToken, - JwtCredentials, - PasswordResetToken, - OtpCredentials, - MailingList, - MailingListSubscriber, - Log, - BackupResourceEntity, - BackupEntity, - NumberInvoices, - Invoice, - Payment - ]; - - constructor() { - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - } + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface + ) { } // eslint-disable-next-line jsdoc/require-jsdoc - async init(): Promise { - if (GlobalRegistry.dataSourceClasses.length) { - // eslint-disable-next-line stylistic/max-len - await this.logger.info(`initializes ${GlobalRegistry.dataSourceClasses.length} ${GlobalRegistry.dataSourceClasses.length > 1 ? 'data sources' : 'data source'}`); + async beforeAppInit(app: ZibriApplication): Promise { + const { dataSources } = app.options; + if (dataSources.length) { + await this.logger.info(`initializes ${dataSources.length} ${dataSources.length > 1 ? 'data sources' : 'data source'}`); } - for (const dataSourceClass of GlobalRegistry.dataSourceClasses) { + for (const dataSourceClass of dataSources) { const dataSource: DataSourceInterface = inject(dataSourceClass); + if (!MetadataUtilities.getFilePath(dataSourceClass)) { + throw new Error(`The data source ${dataSourceClass.name} is not decorated with @DataSource.`); + } + this.dataSources.push(dataSource); await this.logger.info(` - ${dataSourceClass.name} (${dataSource.entities.length} entities)`); await dataSource.init(); } validateEntitiesRegistered( this.constructor.name, - ...GlobalRegistry.entityClasses.filter(e => !this.allowedOrphans.includes(e)) + app, + ...GlobalRegistry.entityClasses.filter(e => !(MetadataUtilities.getEntityMetadata(e)?.allowOrphan ?? false)) ); } + + // eslint-disable-next-line jsdoc/require-jsdoc + async afterAppShutdown(): Promise { + await Promise.all(this.dataSources.map(ds => ds.shutDown())); + } } \ No newline at end of file diff --git a/src/data-source/data-sources/data-source.interface.ts b/src/data-source/data-sources/data-source.interface.ts index c241189..cefce79 100644 --- a/src/data-source/data-sources/data-source.interface.ts +++ b/src/data-source/data-sources/data-source.interface.ts @@ -29,6 +29,12 @@ export interface DataSourceInterface extends BackupResourceInterface { */ init: () => Promise, + /** + * Shuts down the data source. + * Should be called from the data source service AfterAppShutdown hook. + */ + shutDown: () => Promise, + /** * Gets a repository to manage the provided entity class in the data source. * @param cls - The entity class to get the repository for. @@ -78,4 +84,34 @@ export interface DataSourceInterface extends BackupResourceInterface { }, transaction: Transaction ) => Promise +} + +/** + * Checks whether or not the given value is a data source. + * @param value - The value to check. + * @returns True if all keys of the DataSourceInterface are present, false otherwise. + */ +export function isDataSource(value: unknown): value is DataSourceInterface { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + + const keys: (keyof DataSourceInterface)[] = [ + 'addPropertyToEntity', + 'changePropertyOfEntity', + 'createBackupData', + 'entities', + 'getRepository', + 'init', + 'migrations', + 'restoreBackup', + 'runMigrations', + 'shutDown', + 'startTransaction' + ]; + + return !keys.find(key => !(key in value)); } \ No newline at end of file diff --git a/src/data-source/data-sources/postgres-data-source.model.ts b/src/data-source/data-sources/postgres-data-source.model.ts index 025c60d..190de1f 100644 --- a/src/data-source/data-sources/postgres-data-source.model.ts +++ b/src/data-source/data-sources/postgres-data-source.model.ts @@ -14,6 +14,7 @@ import { isChangeSetEntityNewable, ChangeSetEntity } from '../../change-sets/mod import { isSoftDeleteEntityNewable, SoftDeleteEntity } from '../../change-sets/models/soft-delete-entity.model'; import { SoftDeleteRepository } from '../../change-sets/soft-delete-repository'; import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; import { register } from '../../di/register.function'; @@ -24,7 +25,7 @@ import { FilePropertyMetadata } from '../../entity/models/file-property-metadata import { Relation } from '../../entity/models/relation.enum'; import { StringPropertyMetadata } from '../../entity/models/string-property-metadata.model'; import { GlobalRegistry } from '../../global/global-registry'; -import { LoggerInterface } from '../../logging/logger.interface'; +import { type LoggerInterface } from '../../logging/logger.interface'; import { ExcludeStrict } from '../../types/exclude-strict.type'; import { Newable } from '../../types/newable.type'; import { OmitStrict } from '../../types/omit-strict.type'; @@ -92,14 +93,11 @@ export abstract class PostgresDataSource implements DataSourceInterface { * The internal typeorm data source. */ protected ds?: TODataSource; - /** - * A logger. - */ - protected readonly logger: LoggerInterface; - constructor() { - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - } + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface + ) { } // eslint-disable-next-line jsdoc/require-jsdoc createBackupData(): Readable { @@ -166,6 +164,12 @@ export abstract class PostgresDataSource implements DataSourceInterface { throw new Error('The postgres data source has already been initialized.'); } + if (this.options.username === 'postgres' && this.options.password === 'password') { + await this.logger.warn( + `The data source "${this.constructor.name}" uses the default credentials, you probably want to change that.` + ); + } + for (const entityClass of this.entities) { register({ token: repositoryTokenFor(entityClass), @@ -190,6 +194,11 @@ export abstract class PostgresDataSource implements DataSourceInterface { } } + // eslint-disable-next-line jsdoc/require-jsdoc + async shutDown(): Promise { + await this.ds?.destroy(); + } + /** * Gets entity schemas for the entities of this data source. * @returns Typeorm entity schemas. @@ -435,6 +444,7 @@ export abstract class PostgresDataSource implements DataSourceInterface { async runMigrations(): Promise { await this.createMigrationTableIfNotExists(); + // we need to dynamically inject here because the repositories aren't ready in the constructor. const migrationsRepository: Repository = inject(repositoryTokenFor(MigrationEntity)); const finishedMigrationVersions: string[] = (await migrationsRepository.findAll()).map(m => m.version); const allMigrations: MigrationWithName[] = this.migrations.map(m => ({ migration: inject(m), name: m.name })); diff --git a/src/data-source/decorators/data-source.decorator.ts b/src/data-source/decorators/data-source.decorator.ts index acf80ec..b39713a 100644 --- a/src/data-source/decorators/data-source.decorator.ts +++ b/src/data-source/decorators/data-source.decorator.ts @@ -1,7 +1,6 @@ import { GlobalRegistry } from '../../global/global-registry'; import { Newable } from '../../types/newable.type'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; -import { DataSourceInterface } from '../data-sources/data-source.interface'; /** * Marks a class to be a data source. @@ -11,10 +10,10 @@ export function DataSource(): ClassDecorator { // eslint-disable-next-line unicorn/error-message const stack: string = new Error().stack ?? ''; MetadataUtilities.setFilePath(target, stack); + GlobalRegistry.injectables.push({ token: target as unknown as Newable, useClass: target as unknown as Newable }); - GlobalRegistry.dataSourceClasses.push(target as unknown as Newable); }; } \ No newline at end of file diff --git a/src/data-source/migration/migration.test.ts b/src/data-source/migration/migration.test.ts index 45a780b..5585a2f 100644 --- a/src/data-source/migration/migration.test.ts +++ b/src/data-source/migration/migration.test.ts @@ -19,7 +19,7 @@ import { DataSource } from '../decorators/data-source.decorator'; import { Repository } from '../repository'; import { Transaction } from '../transaction/transaction.model'; -@Entity('item') +@Entity({ tableName: 'item' }) class LegacyItem { @Property.string({ primary: true }) id!: string; @@ -37,7 +37,7 @@ class LegacyDbDataSource extends PostgresDataSource { entities: Newable[] = [MigrationEntity, LegacyItem]; } -@Entity('item') +@Entity({ tableName: 'item' }) class Item { @Property.string({ primary: true }) id!: string; diff --git a/src/data-source/repository.ts b/src/data-source/repository.ts index adf1c11..73550d1 100644 --- a/src/data-source/repository.ts +++ b/src/data-source/repository.ts @@ -84,7 +84,6 @@ export class Repository< } case Relation.MANY_TO_MANY: case Relation.ONE_TO_MANY: { - // TODO await this.setDefaultValuesForArray(data[key as keyof Data] as unknown[], { ...property, type: 'array', diff --git a/src/data-source/transaction/transaction.test.ts b/src/data-source/transaction/transaction.test.ts index 2d3c2cb..097df53 100644 --- a/src/data-source/transaction/transaction.test.ts +++ b/src/data-source/transaction/transaction.test.ts @@ -1,17 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import { StartedTestContainer } from 'testcontainers'; -import { POSTGRES_TEST_IMAGE } from '../../__testing__/constants'; -import { BaseEntity } from '../../entity/base-entity.model'; +import { createTestDataSource, defaultTestServerEntities } from '../../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../../__testing__/test-server/start-test-server.function'; +import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; +import { inject } from '../../di/inject.function'; import { Repository } from '../repository'; import { Transaction } from './transaction.model'; import { Entity } from '../../entity/decorators/entity.decorator'; import { Property } from '../../entity/decorators/property.decorator'; import { Newable } from '../../types/newable.type'; -import { PostgresDataSource, PostgresOptions } from '../data-sources/postgres-data-source.model'; -import { DataSource } from '../decorators/data-source.decorator'; -import { MigrationEntity } from '../migration/migration-entity.model'; +import { DataSourceInterface } from '../data-sources/data-source.interface'; +import { PostgresDataSource } from '../data-sources/postgres-data-source.model'; @Entity() class Item { @@ -22,36 +21,16 @@ class Item { value!: string; } -@DataSource() -class DbDataSource extends PostgresDataSource { - options: PostgresOptions = { - host: 'localhost', - username: 'postgres', - password: 'password', - database: 'db', - synchronize: true - }; - entities: Newable[] = [MigrationEntity, Item]; -} - -let container: StartedTestContainer; -let dataSource: DbDataSource; +let server: StartedTestServer; +let dataSource: DataSourceInterface; let repo: Repository; describe('transaction', () => { beforeAll(async () => { - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - dataSource = new DbDataSource(); - dataSource.options = { - ...dataSource.options, - port: container.getMappedPort(5432) - }; - await dataSource.init(); - repo = dataSource.getRepository(Item); + const dataSourceClass: Newable = createTestDataSource({ entities: [...defaultTestServerEntities, Item] }); + server = await startTestServer({ dataSources: [dataSourceClass] }); + dataSource = inject(dataSourceClass); + repo = inject(repositoryTokenFor(Item)); }, 15000); it('should see changes inside transaction, but not outside until committed', async () => { @@ -71,6 +50,6 @@ describe('transaction', () => { }); afterAll(async () => { - await container?.stop(); + await server.shutdown(); }); }); \ No newline at end of file diff --git a/src/di/decorators/injectable.decorator.ts b/src/di/decorators/injectable.decorator.ts index 22e4f83..9bbe8e8 100644 --- a/src/di/decorators/injectable.decorator.ts +++ b/src/di/decorators/injectable.decorator.ts @@ -1,21 +1,44 @@ import { GlobalRegistry } from '../../global/global-registry'; import { Newable } from '../../types/newable.type'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { DiProvider } from '../models/di-provider.model'; import { DiToken } from '../models/di-token.model'; +/** + * Options for the \@injectable decorator. + */ +export type InjectableOptions = { + /** + * An optional token where the marked class should be registered under instead of the class. + */ + token?: DiToken, + /** + * When the injectable should be registered. Defaults to 'immediately'. + */ + register?: 'immediately' | 'onUse' +}; + /** * Marks a class to be injectable. - * @param token - An optional token where the marked class should be registered under instead of the class. + * @param options - Options for the injectable. */ -export function Injectable(token?: DiToken): ClassDecorator { +export function Injectable(options: InjectableOptions = {}): ClassDecorator { + const { register = 'immediately', token } = options; + return target => { MetadataUtilities.setDiToken(target, token); // eslint-disable-next-line unicorn/error-message const stack: string = new Error().stack ?? ''; MetadataUtilities.setFilePath(target, stack); - GlobalRegistry.injectables.push({ + + const provider: DiProvider = { token: (token ?? target) as DiToken, - useClass: target as unknown as Newable - }); + useClass: target as unknown as Newable + }; + if (register === 'immediately') { + GlobalRegistry.injectables.push(provider); + return; + } + GlobalRegistry.lazyInjectables.push(provider); }; } \ No newline at end of file diff --git a/src/di/default/zibri-di-providers.default.ts b/src/di/default/zibri-di-providers.default.ts index 3cc1862..72c1ea5 100644 --- a/src/di/default/zibri-di-providers.default.ts +++ b/src/di/default/zibri-di-providers.default.ts @@ -14,7 +14,6 @@ import { BackupService } from '../../backup/backup.service'; import { CronService } from '../../cron/cron.service'; import { DataSourceService } from '../../data-source/data-source.service'; import { EmailService } from '../../email/email.service'; -import { MailingListService } from '../../email/mailing-list/mailing-list.service'; import { errorHandler } from '../../error-handling/error-handler'; import { HttpClient } from '../../http-client/http-client'; import { LocalizeOptionsInput } from '../../localization/models/localize-options.model'; @@ -70,19 +69,18 @@ export const ZIBRI_DI_PROVIDERS: DiTokenProviderRecord = PARSER: { useClass: Parser }, VALIDATION_SERVICE: { useClass: ValidationService }, DATA_SOURCE_SERVICE: { useClass: DataSourceService }, - AUTH_SERVICE: { useFactory: () => new AuthService() }, - TWO_FACTOR_SERVICE: { useFactory: () => new TwoFactorService() }, + AUTH_SERVICE: { useClass: AuthService }, + TWO_FACTOR_SERVICE: { useClass: TwoFactorService }, OTP_HEADER: { useFactory: () => 'X-Authorization-OTP' }, OTP_LENGTH: { useFactory: () => 6 }, - USER_SERVICE: { useFactory: () => new UserService() }, + USER_SERVICE: { useClass: UserService }, JWT_ACCESS_TOKEN_SECRET: { useFactory: () => undefined }, JWT_REFRESH_TOKEN_SECRET: { useFactory: () => undefined }, + JWT_PASSWORD_RESET_EMAIL_TEMPLATE: { useFactory: () => undefined }, JWT_ACCESS_TOKEN_EXPIRES_IN_MS: { useFactory: () => Ms.HOUR }, JWT_REFRESH_TOKEN_EXPIRES_IN_MS: { useFactory: () => 100 * Ms.DAY }, CRON_SERVICE: { useClass: CronService }, EMAIL_SERVICE: { useClass: EmailService }, - MAILING_LIST_SERVICE: { useClass: MailingListService }, - MAILING_LIST_SUBSCRIPTION_CONFIRMATION_TOKEN_EXPIRES_IN_MS: { useFactory: () => Ms.DAY }, FILE_UPLOAD_TEMP_FOLDER: { useFactory: () => FsUtilities.getPath(__dirname, 'temp') }, LOCALIZE_OPTIONS_INPUT: { useFactory: () => ({}) }, LOCALIZE_OPTIONS: { diff --git a/src/di/default/zibri-di-tokens.default.ts b/src/di/default/zibri-di-tokens.default.ts index aefc5da..dded6b1 100644 --- a/src/di/default/zibri-di-tokens.default.ts +++ b/src/di/default/zibri-di-tokens.default.ts @@ -1,12 +1,12 @@ import { AssetServiceInterface } from '../../assets/asset-service.interface'; import { TwoFactorServiceInterface } from '../../auth/2fa/two-factor-service.interface'; import { AuthServiceInterface } from '../../auth/auth-service.interface'; +import { PasswordResetEmailTemplate } from '../../auth/strategies/jwt/jwt-auth.controller'; import { UserServiceInterface } from '../../auth/user/user-service.interface'; import { BackupServiceInterface } from '../../backup/backup-service.interface'; import { CronServiceInterface } from '../../cron/cron-service.interface'; import { DataSourceServiceInterface } from '../../data-source/data-source-service.interface'; import { EmailServiceInterface } from '../../email/email-service.interface'; -import { MailingListServiceInterface } from '../../email/mailing-list/mailing-list-service.interface'; import { EmailConfigInput } from '../../email/models/email-config.model'; import { GlobalErrorHandler, ErrorPageTemplate } from '../../error-handling/error-handler.model'; import { HttpRequest } from '../../http/http-request.model'; @@ -24,7 +24,7 @@ import { MultithreadingServiceInterface } from '../../multithreading/services/mu import { OpenApiServiceInterface } from '../../open-api/open-api-service.interface'; import { ParserInterface } from '../../parsing/parser.interface'; import { RouterInterface } from '../../routing/router.interface'; -import { Path } from '../../utilities/fs.utilities'; +import { FsPath } from '../../utilities/fs.utilities'; import { ValidationServiceInterface } from '../../validation/validation-service.interface'; import { WebsocketOptions } from '../../websocket/models/websocket-options.model'; import { WebsocketServiceInterface } from '../../websocket/services/websocket-service.interface'; @@ -64,12 +64,11 @@ export const ZIBRI_DI_TOKENS = { JWT_REFRESH_TOKEN_EXPIRES_IN_MS: ziToken('zi.jwt_refresh_token_expires_in_ms'), JWT_PASSWORD_RESET_TOKEN_EXPIRES_IN_MS: ziToken('zi.jwt_password_reset_token_expires_in_ms'), JWT_CONFIRM_PASSWORD_RESET_URL: ziToken('zi.jwt_confirm_password_reset_url'), - MAILING_LIST_SUBSCRIPTION_CONFIRMATION_TOKEN_EXPIRES_IN_MS: ziToken( - 'zi.mailing_list_subscription_confirmation_token_expires_in_ms' - ), + // eslint-disable-next-line typescript/no-explicit-any + JWT_PASSWORD_RESET_EMAIL_TEMPLATE: ziToken | undefined>('zi.jwt_password_reset_email_template'), USER_SERVICE: ziToken('zi.user_service'), CRON_SERVICE: ziToken('zi.cron_service'), - FILE_UPLOAD_TEMP_FOLDER: ziToken('zi.file_upload_temp_folder'), + FILE_UPLOAD_TEMP_FOLDER: ziToken('zi.file_upload_temp_folder'), LOCALIZE_OPTIONS_INPUT: ziToken('zi.localize_options_input'), LOCALIZE_OPTIONS: ziToken('zi.localize_options'), FORMAT_DATE: ziToken('zi.format_date'), @@ -77,8 +76,7 @@ export const ZIBRI_DI_TOKENS = { FORMAT_PERCENT: ziToken('zi.format_percent'), EMAIL_SERVICE: ziToken('zi.email_service'), EMAIL_CONFIG: ziToken('zi.email_config'), - MAILING_LIST_SERVICE: ziToken('zi.mailing_list_service'), - CURRENT_REQUEST: ziToken('zi.current_request'), + CURRENT_REQUEST: ziToken('zi.current_request'), MULTITHREADING_SERVICE: ziToken('zi.multithreading_service'), MULTITHREADING_OPTIONS: ziToken('zi.multithreading_options'), // eslint-disable-next-line typescript/no-explicit-any diff --git a/src/di/di-container.ts b/src/di/di-container.ts index 9549cfe..795e7a3 100644 --- a/src/di/di-container.ts +++ b/src/di/di-container.ts @@ -26,6 +26,25 @@ export class DiContainer { } } + /** + * Gets all registered tokens of the DI Container. + * @returns The tokens as an array. + */ + getAllRegisteredTokens(): DiToken[] { + const seen: Set = new Set(); + const unique: DiToken[] = []; + + for (const [token, provider] of this.providers) { + const identity: unknown = provider.useClass ?? provider.useFactory ?? provider.useValue ?? token; + if (!seen.has(identity)) { + seen.add(identity); + unique.push(token); + } + } + + return unique; + } + /** * Gets the DI Container instance. * @returns The instance. @@ -47,7 +66,7 @@ export class DiContainer { /** * Removes the provided token from the dependency injection system. * @param token - The token to unregister. - * @throws When the app is initialized or running. + * @throws When the app is initialized or started. */ unregister(token: DiToken): void { this.providers.delete(token); @@ -65,11 +84,26 @@ export class DiContainer { return this.instances.get(token) as T; } - const provider: DiProvider | undefined = this.providers.get(token) as DiProvider | undefined; + let provider: DiProvider | undefined = this.providers.get(token) as DiProvider | undefined; + if (!provider) { + const lazy: DiProvider | undefined = GlobalRegistry.lazyInjectables.find(p => p.token === token); + if (lazy) { + this.register(lazy); // promote into providers so future lookups are O(1) + provider = lazy as DiProvider; + } + } + if (!provider) { throw new NoProviderError(token, resolvingStack); } + // If useClass, check if we already have an instance of that class cached under the class itself + if (provider.useClass && this.instances.has(provider.useClass as unknown as DiToken)) { + const existing: T = this.instances.get(provider.useClass as unknown as DiToken) as T; + this.instances.set(token, existing); // cache under this token too for next time + return existing; + } + if (provider.useClass || provider.useFactory) { resolvingStack.push(provider.useClass ?? provider.useFactory); } @@ -78,6 +112,11 @@ export class DiContainer { resolvingStack.pop(); this.instances.set(provider.token, instance); + + if (provider.useClass) { + this.instances.set(provider.useClass as unknown as DiToken, instance); + } + return instance; } diff --git a/src/di/errors/no-provider.error.ts b/src/di/errors/no-provider.error.ts index bee13ea..91977c7 100644 --- a/src/di/errors/no-provider.error.ts +++ b/src/di/errors/no-provider.error.ts @@ -19,8 +19,10 @@ function getNoProviderMessage(token: DiToken, resolvingStack: Function[ return `No provider for token "${token.key}". Did you forget to decorate it with @Inject()?`; } const currentClass: Function = resolvingStack[resolvingStack.length - 1]; - const paramTypes: unknown[] = MetadataUtilities.getParamTypes(currentClass); - const index: number = paramTypes.findIndex(param => param === token); + const injectTokens: Record> = MetadataUtilities.getInjectParamTokens(currentClass); + const index: number = Number( + Object.entries(injectTokens).find(([, t]) => t === token)?.[0] ?? -1 + ); return `No provider for the token at index ${index} of class "${currentClass.name}". Did you forget to decorate it with @Inject()?`; } return `No provider for class "${token.name}". Did you forget to decorate it with @Injectable()?`; diff --git a/src/di/get-all-registered-tokens.function.ts b/src/di/get-all-registered-tokens.function.ts new file mode 100644 index 0000000..b5b7137 --- /dev/null +++ b/src/di/get-all-registered-tokens.function.ts @@ -0,0 +1,10 @@ +import { DiContainer } from './di-container'; +import { DiToken } from './models/di-token.model'; + +/** + * Gets all registered DI tokens. + * @returns The tokens as an array. + */ +export function getAllRegisteredTokens(): DiToken[] { + return DiContainer.getInstance().getAllRegisteredTokens(); +} \ No newline at end of file diff --git a/src/di/models/di-provider.model.ts b/src/di/models/di-provider.model.ts index 047ca67..afbb657 100644 --- a/src/di/models/di-provider.model.ts +++ b/src/di/models/di-provider.model.ts @@ -38,7 +38,7 @@ type ClassDiProvider = BaseDiProvider & { /** * A class to register for the token. */ - useClass: Newable>, + useClass: Newable, // eslint-disable-next-line jsdoc/require-jsdoc useFactory?: never, // eslint-disable-next-line jsdoc/require-jsdoc @@ -50,7 +50,7 @@ type FactoryDiProvider = BaseDiProvider & { /** * A factory function that resolves the value to register for the token. */ - useFactory: (...deps: unknown[]) => NoInfer, + useFactory: (...deps: unknown[]) => T, // eslint-disable-next-line jsdoc/require-jsdoc useClass?: never, // eslint-disable-next-line jsdoc/require-jsdoc @@ -62,7 +62,7 @@ type ValueDiProvider = BaseDiProvider & { /** * A value to register for the token. */ - useValue: NoInfer, + useValue: T, // eslint-disable-next-line jsdoc/require-jsdoc useFactory?: never, // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/di/register.function.ts b/src/di/register.function.ts index 50710dd..5a5bc42 100644 --- a/src/di/register.function.ts +++ b/src/di/register.function.ts @@ -5,10 +5,10 @@ import { DiProvider } from './models/di-provider.model'; /** * Registers a new DI provider. * @param provider - The provider to register. - * @throws When the app is initialized or running. + * @throws When the app is initialized or started. */ export function register(provider: DiProvider): void { - if (GlobalRegistry.isAppInitialized() || GlobalRegistry.isAppRunning()) { + if (GlobalRegistry.isAppInitialized() || GlobalRegistry.isAppStarted()) { throw new Error('You can only register providers before the app has been initialized'); } const di: DiContainer = DiContainer.getInstance(); diff --git a/src/di/unregister.function.ts b/src/di/unregister.function.ts index 238609b..5a1f858 100644 --- a/src/di/unregister.function.ts +++ b/src/di/unregister.function.ts @@ -5,10 +5,10 @@ import { DiToken } from './models/di-token.model'; /** * Removes the provided token from the dependency injection system. * @param token - The token to unregister. - * @throws When the app is initialized or running. + * @throws When the app is initialized or started. */ export function unregister(token: DiToken): void { - if (GlobalRegistry.isAppInitialized() || GlobalRegistry.isAppRunning()) { + if (GlobalRegistry.isAppInitialized() || GlobalRegistry.isAppStarted()) { throw new Error('You can only unregister providers before the app has been initialized'); } const di: DiContainer = DiContainer.getInstance(); diff --git a/src/email/email-service.interface.ts b/src/email/email-service.interface.ts index 4c70063..1bbf640 100644 --- a/src/email/email-service.interface.ts +++ b/src/email/email-service.interface.ts @@ -1,14 +1,9 @@ -import { ZibriApplication } from '../application'; import { QueueEmailData } from './models/create-email-data.model'; /** * Interface for a email service. */ export interface EmailServiceInterface { - /** - * Attaches the service to the Zibri application. - */ - attachTo: (app: ZibriApplication) => void, /** * Queues a new email. */ diff --git a/src/email/email.service.ts b/src/email/email.service.ts index b90372c..6b23ac7 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -7,29 +7,30 @@ import { CreateEmailData, QueueEmailData } from './models/create-email-data.mode import { SendQueuedEmailsCronJob } from './send-queued-emails.cron-job'; import { Repository } from '../data-source/repository'; import { Email } from './models/email.model'; -import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; +import { InjectRepository } from '../di/decorators/inject-repository.decorator'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; -import { LoggerInterface } from '../logging/logger.interface'; +import { type LoggerInterface } from '../logging/logger.interface'; import { RateLimiter } from '../rate-limiting/rate-limiter'; import { FsUtilities } from '../utilities/fs.utilities'; import { EmailAttachment } from './models/email-attachment.model'; import { EmailConfig, EmailConfigInput } from './models/email-config.model'; import { EmailPriority } from './models/email-priority.enum'; import { EmailStatus } from './models/email-status.enum'; +import { OnAppInit } from '../global/on-app-init.interface'; +import { OnAppShutdown } from '../global/on-app-shutdown.interface'; /** * Default email service implementation of Zibri. */ -export class EmailService implements EmailServiceInterface { +@Injectable({ register: 'onUse' }) +export class EmailService implements EmailServiceInterface, OnAppInit, OnAppShutdown { /** * The internal nodemailer transporter. */ protected readonly transporter: Transporter; - /** - * The repository that handles storing and receiving emails from the db. - */ - protected readonly emailRepository: Repository; /** * The email configuration. */ @@ -38,13 +39,13 @@ export class EmailService implements EmailServiceInterface { * A rate limiter to prevent overloading the email provider. */ protected readonly rateLimiter: RateLimiter; - /** - * A logger. - */ - protected readonly logger: LoggerInterface; - constructor() { - this.emailRepository = inject(repositoryTokenFor(Email)); + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface, + @InjectRepository(Email) + private readonly emailRepository: Repository + ) { const config: EmailConfigInput | undefined = inject(ZIBRI_DI_TOKENS.EMAIL_CONFIG); if (!config) { throw new Error('no email config was provided for the token "ZIBRI_DI_TOKENS.MAIL_CONFIG"'); @@ -55,14 +56,18 @@ export class EmailService implements EmailServiceInterface { }; this.rateLimiter = RateLimiter.perHour(this.config.maxEmailsPerHour); this.transporter = createTransport({ ...this.config, secure: config?.port === 465 }); - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); } // eslint-disable-next-line jsdoc/require-jsdoc - attachTo(app: ZibriApplication): void { + onAppInit(app: ZibriApplication): void { app.options.cronJobs.push(SendQueuedEmailsCronJob); } + // eslint-disable-next-line jsdoc/require-jsdoc + onAppShutdown(): void { + this.transporter.close(); + } + // eslint-disable-next-line jsdoc/require-jsdoc async queue(data: QueueEmailData): Promise { await this.emailRepository.create({ diff --git a/src/email/mailing-list/mailing-list-service.interface.ts b/src/email/mailing-list/mailing-list-service.interface.ts deleted file mode 100644 index cedd17b..0000000 --- a/src/email/mailing-list/mailing-list-service.interface.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ZibriApplication } from '../../application'; -import { MailingListSubscriber } from './models/mailing-list-subscriber.model'; -import { OmitClass } from '../../entity/omit-class.model'; -import { BaseEmailTemplateData } from '../../handlebars/render-template.function'; -import { Route } from '../../routing/controller-route-configuration.model'; -import { OmitStrict } from '../../types/omit-strict.type'; -import { QueueEmailData } from '../models/create-email-data.model'; - -/** - * The data required to queue a new mailing list email. - */ -export type MailingListQueueEmailData> = OmitStrict< - QueueEmailData, - 'bcc' | 'cc' | 'recipients' | 'userId' | 'priority' | 'html' -> & { - /** - * The template string in handlebars format. - */ - templateString: string, - /** - * The data to use inside the template string. - */ - templateData: T -}; - -/** - * The required data to create a new mailing list subscriber. - */ -export class MailingListSubscriberCreateData extends OmitClass(MailingListSubscriber, ['id']) {} - -// eslint-disable-next-line jsdoc/require-jsdoc -export type BaseMailingListEmailTemplateData = OmitStrict & { - /** - * The base data shared by all mailing list email templates. - */ - base: OmitStrict -}; - -/** - * Interface for a mailing list service. - */ -export interface MailingListServiceInterface { - /** - * The base route for everything regarding mailing lists. - */ - readonly mailingListBaseRoute: Route, - /** - * Attaches the service to the Zibri application. - */ - attachTo: (app: ZibriApplication) => void, - /** - * Queues a new email for the mailing list with the provided id. - */ - queueEmailForList: (listId: string, data: MailingListQueueEmailData) => Promise, - /** - * Requests for a new subscriber to the be added to the mailing list with the provided id. - * This should initialize a two step process required by the GDPR. - */ - requestSubscribeToList: ( - listId: string, - subscriber: MailingListSubscriberCreateData, - emailData: MailingListQueueEmailData - ) => Promise, - /** - * Confirms that a new subscriber is added to the mailing list. - */ - confirmSubscribeToList: (confirmationTokenValue: string) => Promise, - /** - * Removes a subscriber with the given id from the mailing list with the provided id. - */ - unsubscribeFromList: (listId: string, subscriberId: string) => Promise -} \ No newline at end of file diff --git a/src/email/mailing-list/mailing-list.service.ts b/src/email/mailing-list/mailing-list.service.ts deleted file mode 100644 index 51394d3..0000000 --- a/src/email/mailing-list/mailing-list.service.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { randomBytes } from 'crypto'; - -import { MailingListSubscriberCreateData, MailingListQueueEmailData, MailingListServiceInterface, BaseMailingListEmailTemplateData } from './mailing-list-service.interface'; -import { AssetServiceInterface } from '../../assets/asset-service.interface'; -import { Repository } from '../../data-source/repository'; -import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; -import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; -import { inject } from '../../di/inject.function'; -import { GlobalRegistry } from '../../global/global-registry'; -import { BaseEmailTemplateData, renderTemplateString, renderTemplate } from '../../handlebars/render-template.function'; -import { Route } from '../../routing/controller-route-configuration.model'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; -import { PromiseUtilities } from '../../utilities/promise.utilities'; -import { validateEntitiesRegistered } from '../../utilities/validate-entities-registered.function'; -import { EmailServiceInterface } from '../email-service.interface'; -import { EmailPriority } from '../models/email-priority.enum'; -import { MailingListSubscriber } from './models/mailing-list-subscriber.model'; -import { MailingListSubscriptionConfirmationToken, MailingListSubscriptionConfirmationTokenCreateData } from './models/mailing-list-subscription-confirmation-token.model'; -import { MailingList } from './models/mailing-list.model'; - -/** - * Default mailing list service implementation of Zibri. - */ -export class MailingListService implements MailingListServiceInterface { - // eslint-disable-next-line jsdoc/require-jsdoc - readonly mailingListBaseRoute: Route = '/mailing-lists'; - /** - * The email service. - */ - protected readonly emailService: EmailServiceInterface; - /** - * The asset service. - */ - protected readonly assetService: AssetServiceInterface; - /** - * The time in ms after which the token to confirm a new mailing list subscription expires. - */ - protected readonly mailingListSubscriptionConfirmationTokenExpiresInMs: number; - - // eslint-disable-next-line jsdoc/require-jsdoc - protected get mailingListRepository(): Repository { - return inject(repositoryTokenFor(MailingList)); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - protected get subscriberRepository(): Repository { - return inject(repositoryTokenFor(MailingListSubscriber)); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - protected get confirmationTokenRepository(): Repository< - MailingListSubscriptionConfirmationToken, - MailingListSubscriptionConfirmationTokenCreateData - > { - return inject(repositoryTokenFor(MailingListSubscriptionConfirmationToken)); - } - - constructor() { - this.emailService = inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE); - this.assetService = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE); - this.mailingListSubscriptionConfirmationTokenExpiresInMs = inject( - ZIBRI_DI_TOKENS.MAILING_LIST_SUBSCRIPTION_CONFIRMATION_TOKEN_EXPIRES_IN_MS - ); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - attachTo(): void { - validateEntitiesRegistered(this.constructor.name, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async queueEmailForList(listId: string, data: MailingListQueueEmailData): Promise { - const list: MailingList = await this.mailingListRepository.findById(listId); - await PromiseUtilities.allChunked( - list.subscribers, - async s => { - const base: BaseEmailTemplateData['base'] = { - ...data.templateData.base, - baseUrl: GlobalRegistry.getAppData('baseUrl') ?? '', - mailingListData: { - list, - subscriber: s, - mailingListBaseRoute: this.mailingListBaseRoute - } - }; - const content: string = renderTemplateString(data.templateString, { - ...data.templateData, - base - }); - const html: string = await renderTemplate( - FsUtilities.getPath(this.assetService.emailTemplatePath, 'base-email.hbs') as `${Path}.hbs`, - { content, base } - ); - await this.emailService.queue({ - html, - priority: EmailPriority.LOW, - recipients: [s.email], - persist: false, - ...data - }); - } - ); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async requestSubscribeToList( - listId: string, - subscriber: MailingListSubscriberCreateData, - emailData: MailingListQueueEmailData - ): Promise { - - const foundSubscriber: MailingListSubscriber | undefined = await this.subscriberRepository.findOne( - { where: { email: subscriber.email } }, - false - ); - if (foundSubscriber) { - const list: MailingList = await this.mailingListRepository.findById(listId); - await this.subscriberRepository.updateById(foundSubscriber.id, { mailingLists: [...foundSubscriber.mailingLists, list] }); - return; - } - - await this.confirmationTokenRepository.create({ - email: subscriber.email, - value: randomBytes(16).toString('hex'), - expirationDate: new Date(Date.now() + this.mailingListSubscriptionConfirmationTokenExpiresInMs), - name: subscriber.name, - listId - }); - - const content: string = renderTemplateString(emailData.templateString, { - ...emailData.templateData, - base: emailData.templateData.base - }); - const html: string = await renderTemplate( - FsUtilities.getPath(this.assetService.emailTemplatePath, 'base-email.hbs') as `${Path}.hbs`, - { content, base: emailData.templateData.base } - ); - await this.emailService.queue({ ...emailData, html, recipients: [subscriber.email] }); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async confirmSubscribeToList(confirmationTokenValue: string): Promise { - const foundToken: MailingListSubscriptionConfirmationToken = await this.confirmationTokenRepository.findOne( - { where: { value: confirmationTokenValue } } - ); - const mailingList: MailingList = await this.mailingListRepository.findById(foundToken.listId); - const foundSubscriber: MailingListSubscriber | undefined = await this.subscriberRepository.findOne( - { where: { email: foundToken.email }, relations: ['mailingLists'] }, - false - ); - if (!foundSubscriber) { - await this.subscriberRepository.create({ email: foundToken.email, name: foundToken.name, mailingLists: [mailingList] }); - return; - } - if (foundSubscriber.mailingLists.find(l => l.id === mailingList.id)) { - // already subscribed, do nothing - return; - } - await this.subscriberRepository.updateById(foundSubscriber.id, { mailingLists: [...foundSubscriber.mailingLists, mailingList] }); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - async unsubscribeFromList(listId: string, subscriberId: string): Promise { - await this.mailingListRepository.findById(listId); - const foundSubscriber: MailingListSubscriber = await this.subscriberRepository.findOne( - { where: { id: subscriberId }, relations: ['mailingLists'] } - ); - if (!foundSubscriber.mailingLists.find(l => l.id === listId)) { - // Is not subscribed to the mailing list. - return; - } - const newMailingLists: MailingList[] = foundSubscriber.mailingLists.filter(l => l.id !== listId); - await this.subscriberRepository.updateById(subscriberId, { mailingLists: newMailingLists }); - } -} \ No newline at end of file diff --git a/src/email/models/email-attachment.model.ts b/src/email/models/email-attachment.model.ts index f5f52c1..f770a2d 100644 --- a/src/email/models/email-attachment.model.ts +++ b/src/email/models/email-attachment.model.ts @@ -1,5 +1,5 @@ import { Property } from '../../entity/decorators/property.decorator'; -import { type Path } from '../../utilities/fs.utilities'; +import { type FsPath } from '../../utilities/fs.utilities'; /** * An email attachment, consisting of filename and path. @@ -15,5 +15,5 @@ export class EmailAttachment { * The path of the attachment. */ @Property.string() - path!: Path; + path!: FsPath; } \ No newline at end of file diff --git a/src/email/send-queued-emails.cron-job.ts b/src/email/send-queued-emails.cron-job.ts index e5c1bbd..c10e0f8 100644 --- a/src/email/send-queued-emails.cron-job.ts +++ b/src/email/send-queued-emails.cron-job.ts @@ -1,13 +1,11 @@ -import { EmailServiceInterface } from './email-service.interface'; +import { type EmailServiceInterface } from './email-service.interface'; import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; -import { Injectable } from '../di/decorators/injectable.decorator'; +import { Inject } from '../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; /** * Cron Job for sending out queued emails. */ -@Injectable() export class SendQueuedEmailsCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { @@ -16,12 +14,18 @@ export class SendQueuedEmailsCronJob extends CronJob { runOnInit: false }; + constructor( + @Inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE) + private readonly emailService: EmailServiceInterface + ) { + super(); + } + // eslint-disable-next-line jsdoc/require-jsdoc async onTick(): Promise { - const emailService: EmailServiceInterface = inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE); let goOn: boolean = true; while (goOn) { - goOn = await emailService.sendQueuedEmails(); + goOn = await this.emailService.sendQueuedEmails(); } } } \ No newline at end of file diff --git a/src/entity/decorators/entity.decorator.ts b/src/entity/decorators/entity.decorator.ts index 1d6a356..86afc83 100644 --- a/src/entity/decorators/entity.decorator.ts +++ b/src/entity/decorators/entity.decorator.ts @@ -11,17 +11,23 @@ export type EntityMetadata = { /** * The name of the table in the db. */ - tableName: string + tableName: string, + /** + * Whether or not this entity is allowed to exist without belonging to a data source. + */ + allowOrphan: boolean }; /** * Marks an entity. - * @param tableName - The name of the table to generate for the entity. + * @param options - Configuration options for the entity. */ -export function Entity(tableName?: string): ClassDecorator { +export function Entity(options: Partial = {}): ClassDecorator { + const { tableName, allowOrphan = false } = options; return target => { const metadata: EntityMetadata = { - tableName: tableName ?? toSnakeCase(target.name) + tableName: tableName ?? toSnakeCase(target.name), + allowOrphan }; MetadataUtilities.setEntityMetadata(target as unknown as Newable, metadata); GlobalRegistry.entityClasses.push(target as unknown as Newable); diff --git a/src/entity/generation/generate-entity-files-for-provider.function.ts b/src/entity/generation/generate-entity-files-for-provider.function.ts index c936de4..36f300a 100644 --- a/src/entity/generation/generate-entity-files-for-provider.function.ts +++ b/src/entity/generation/generate-entity-files-for-provider.function.ts @@ -4,7 +4,7 @@ import { getEntityFileName } from './get-entity-file-name.function'; import { EntityGenerationProvider } from './providers/entity-generation-provider.interface'; import { warn } from '../../logging/logger.helpers'; import { OpenApiDefinition, OpenApiSchemas, OpenApiOperation, OpenApiResponseObject, OpenApiReferenceObject, OpenApiSchemaObject } from '../../open-api/open-api.model'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; import { ObjectUtilities } from '../../utilities/object.utilities'; import { toKebabCase } from '../../utilities/to-kebab-case.function'; import { toPascalCase } from '../../utilities/to-pascal-case.function'; @@ -16,7 +16,7 @@ export type FileToGenerate = { /** * The path where the file should be generated. */ - path: Path, + path: FsPath, /** * The actual content of the file in lines. */ @@ -123,7 +123,7 @@ export async function generateEntityFilesForProvider( const fileName: string = getEntityFileName(provider.prefix, key); // eslint-disable-next-line sonar/no-duplicate-string - const filePath: Path = FsUtilities.getPath(cwd, 'src/models/generated', toKebabCase(provider.prefix), fileName); + const filePath: FsPath = FsUtilities.getPath(cwd, 'src/models/generated', toKebabCase(provider.prefix), fileName); if (await FsUtilities.exists(filePath)) { processedSchemas.add(key); continue; diff --git a/src/entity/generation/generate-entity-files.function.ts b/src/entity/generation/generate-entity-files.function.ts index c6e8a9a..f473b27 100644 --- a/src/entity/generation/generate-entity-files.function.ts +++ b/src/entity/generation/generate-entity-files.function.ts @@ -2,14 +2,14 @@ import { register } from 'ts-node'; import { FileToGenerate, generateEntityFilesForProvider, GenerateEntityFilesForProviderResult } from './generate-entity-files-for-provider.function'; import { EntityGenerationProvider } from './providers/entity-generation-provider.interface'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; /** * Resolves providers from the src/models/generated/providers.ts file and generates entities from them. */ export async function generateEntityFiles(): Promise { const cwd: string = process.cwd(); - const providersPath: Path = await resolveProvidersPath(cwd); + const providersPath: FsPath = await resolveProvidersPath(cwd); const ext: string = FsUtilities.extensionName(providersPath).toLowerCase(); if (ext !== '.ts') { return; @@ -44,14 +44,14 @@ export async function generateEntityFiles(): Promise { } // eslint-disable-next-line jsdoc/require-jsdoc -async function resolveProvidersPath(cwd: string): Promise { - const candidates: Path[] = [ +async function resolveProvidersPath(cwd: string): Promise { + const candidates: FsPath[] = [ FsUtilities.getPath(cwd, 'src/models/generated/providers.js'), FsUtilities.getPath(cwd, 'src/models/generated/providers.cjs'), FsUtilities.getPath(cwd, 'src/models/generated/providers.mjs'), FsUtilities.getPath(cwd, 'src/models/generated/providers.ts') ]; - const providersPath: Path = await Promise.any(candidates.map(async p => { + const providersPath: FsPath = await Promise.any(candidates.map(async p => { if (await FsUtilities.exists(p)) { return p; } diff --git a/src/entity/generation/providers/open-api-file.provider.ts b/src/entity/generation/providers/open-api-file.provider.ts index 87a7e9f..e58c53d 100644 --- a/src/entity/generation/providers/open-api-file.provider.ts +++ b/src/entity/generation/providers/open-api-file.provider.ts @@ -1,7 +1,7 @@ import { EntityGenerationProvider } from './entity-generation-provider.interface'; import { openApiToV3 } from './open-api-to-v3.function'; import { OpenApiDefinition } from '../../../open-api/open-api.model'; -import { FsUtilities, Path } from '../../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../../utilities/fs.utilities'; /** * An entity generation provider using a local open api file. @@ -9,7 +9,7 @@ import { FsUtilities, Path } from '../../../utilities/fs.utilities'; export class OpenApiFileProvider implements EntityGenerationProvider { constructor( readonly prefix: string, - protected readonly filePath: Path, + protected readonly filePath: FsPath, readonly generateSchemasFromPaths: boolean = false, readonly markAsEntities: boolean = false ) {} diff --git a/src/error-handling/error-handler.ts b/src/error-handling/error-handler.ts index c2912e8..fbe2100 100644 --- a/src/error-handling/error-handler.ts +++ b/src/error-handling/error-handler.ts @@ -61,7 +61,7 @@ export const errorHandler: GlobalErrorHandler = async (error: unknown, req: Http } try { - const html: string = await PreactUtilities.render(template, { error: httpError }); + const html: string = await PreactUtilities.renderPage(template, { error: httpError }); res.setHeader(KnownHeader.CONTENT_TYPE, MimeType.HTML); res.status(httpError.status).send(html); } diff --git a/src/error-handling/errors/http.error.ts b/src/error-handling/errors/http.error.ts index 8ef3493..ffbd3f8 100644 --- a/src/error-handling/errors/http.error.ts +++ b/src/error-handling/errors/http.error.ts @@ -27,11 +27,7 @@ export abstract class HttpError extends Error { } } -/** - * Check whether or not the given value is a http error. - * @param value - The value to check. - * @returns True when value is an instance of HttpError, false otherwise. - */ +// eslint-disable-next-line jsdoc/require-jsdoc export function isHttpError(value: unknown): value is HttpError { return value instanceof HttpError; } \ No newline at end of file diff --git a/src/error-handling/errors/missing-tokens.error.ts b/src/error-handling/errors/missing-tokens.error.ts new file mode 100644 index 0000000..f3017de --- /dev/null +++ b/src/error-handling/errors/missing-tokens.error.ts @@ -0,0 +1,19 @@ +import { DiToken } from '../../di/models/di-token.model'; +import { InjectionToken } from '../../di/models/injection-token.model'; + +/** + * An error to throw when there are tokens that are not injectable. + */ +export class MissingTokensError extends Error { + constructor(context: string, tokens: DiToken[]) { + const messages: string[] = [ + `Error initializing ${context}`, + 'Could not inject the following tokens:' + ]; + for (const token of tokens) { + messages.push(` - ${token instanceof InjectionToken ? token.key : token.name}`); + } + super(messages.join('\n')); + this.name = 'MissingTokensError'; + } +} \ No newline at end of file diff --git a/src/global/after-app-init.interface.ts b/src/global/after-app-init.interface.ts new file mode 100644 index 0000000..a951772 --- /dev/null +++ b/src/global/after-app-init.interface.ts @@ -0,0 +1,43 @@ +import { ZibriApplication } from '../application'; + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given value implements the AfterAppInit interface. + * @param value - The value to check. + */ +export function implementsAfterAppInit(value: unknown): value is AfterAppInit { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + if (!('afterAppInit' in value)) { + return false; + } + + if (typeof value.afterAppInit !== 'function') { + return false; + } + return true; +} + +/** + * Runs just after the app initializes. + * This is useful for things like the cron or websocket service, + * which creates new work that might rely on other services already being operational. + * + * In most cases you probably want to implement OnAppInit. + * Most relevant things like eg. The data source service are already operational there, as they implement the BeforeAppInit interface. + */ +export interface AfterAppInit { + /** + * Runs just after the app initializes. + * This is useful for things like the cron or websocket service, + * which creates new work that might rely on other services already being operational. + * + * In most cases you probably want to implement OnAppInit. + * Most relevant things like eg. The data source service are already operational there, as they implement the BeforeAppInit interface. + */ + afterAppInit: (app: ZibriApplication) => void | Promise +} \ No newline at end of file diff --git a/src/global/after-app-shutdown.interface.ts b/src/global/after-app-shutdown.interface.ts new file mode 100644 index 0000000..111e33b --- /dev/null +++ b/src/global/after-app-shutdown.interface.ts @@ -0,0 +1,46 @@ +import { ShutdownSignal, ZibriApplication } from '../application'; + +/** + * Runs just after the app shuts down. + * This is useful for things like the data source service, + * which should still be available at the OnAppShutdown hooks. + * + * In most cases you probably want to implement OnAppShutdown. + */ +export interface AfterAppShutdown { + /** + * Runs just after the app shuts down. + * This is useful for things like the data source service, + * which should still be available at the OnAppShutdown hooks. + * + * In most cases you probably want to implement OnAppShutdown. + */ + afterAppShutdown: (app: ZibriApplication, signal: ShutdownSignal | undefined) => void | Promise, + /** + * The timeout for after which the graceful shutdown should fail. + * @default 30 seconds + */ + readonly shutdownTimeoutInMs?: number +} + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given value implements the AfterAppShutdown interface. + * @param value - The value to check. + */ +export function implementsAfterAppShutdown(value: unknown): value is AfterAppShutdown { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + if (!('afterAppShutdown' in value)) { + return false; + } + + if (typeof value.afterAppShutdown !== 'function') { + return false; + } + return true; +} \ No newline at end of file diff --git a/src/global/app-state.enum.ts b/src/global/app-state.enum.ts new file mode 100644 index 0000000..5a10fe4 --- /dev/null +++ b/src/global/app-state.enum.ts @@ -0,0 +1,10 @@ +/** + * The possible state that the app can be in. + */ +export enum AppState { + OFFLINE = 'offline', + CREATED = 'created', + INITIALIZED = 'initialized', + STARTED = 'started', + SHUTTING_DOWN = 'shutting down' +} \ No newline at end of file diff --git a/src/global/before-app-init.interface.ts b/src/global/before-app-init.interface.ts new file mode 100644 index 0000000..f9dadb6 --- /dev/null +++ b/src/global/before-app-init.interface.ts @@ -0,0 +1,39 @@ +import { ZibriApplication } from '../application'; + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given value implements the BeforeAppInit interface. + * @param value - The value to check. + */ +export function implementsBeforeAppInit(value: unknown): value is BeforeAppInit { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + if (!('beforeAppInit' in value)) { + return false; + } + + if (typeof value.beforeAppInit !== 'function') { + return false; + } + return true; +} + +/** + * Runs just before the app initializes. + * This is useful for things like the data source service, which other services might rely on being available on initialization. + * + * In most cases you probably want to implement OnAppInit, as your service probably does not fall in that category. + */ +export interface BeforeAppInit { + /** + * Runs just before the app initializes. + * This is useful for things like the data source service, which other services might rely on being available on initialization. + * + * In most cases you probably want to implement OnAppInit, as your service probably does not fall in that category. + */ + beforeAppInit: (app: ZibriApplication) => void | Promise +} \ No newline at end of file diff --git a/src/global/before-app-shutdown.interface.ts b/src/global/before-app-shutdown.interface.ts new file mode 100644 index 0000000..6323309 --- /dev/null +++ b/src/global/before-app-shutdown.interface.ts @@ -0,0 +1,48 @@ +import { ShutdownSignal, ZibriApplication } from '../application'; + +/** + * Runs just before the app shuts down. + * This is useful for things like the cron or websocket service, + * which creates new work that might rely on other services still being operational. + * + * In most cases you probably want to implement OnAppShutdown. + * Most relevant things like eg. The data source service are still operational there, as they implement the AfterAppShutdown interface. + */ +export interface BeforeAppShutdown { + /** + * Runs just before the app shuts down. + * This is useful for things like the cron or websocket service, + * which creates new work that might rely on other services still being operational. + * + * In most cases you probably want to implement OnAppShutdown. + * Most relevant things like eg. The data source service are still operational there, as they implement the AfterAppShutdown interface. + */ + beforeAppShutdown: (app: ZibriApplication, signal: ShutdownSignal | undefined) => void | Promise, + /** + * The timeout for after which the graceful shutdown should fail. + * @default 30 seconds + */ + readonly shutdownTimeoutInMs?: number +} + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given value implements the BeforeAppShutdown interface. + * @param value - The value to check. + */ +export function implementsBeforeAppShutdown(value: unknown): value is BeforeAppShutdown { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + if (!('beforeAppShutdown' in value)) { + return false; + } + + if (typeof value.beforeAppShutdown !== 'function') { + return false; + } + return true; +} \ No newline at end of file diff --git a/src/global/global-registry.ts b/src/global/global-registry.ts index aca312a..d040a2f 100644 --- a/src/global/global-registry.ts +++ b/src/global/global-registry.ts @@ -1,23 +1,13 @@ import { ZibriApplicationOptions } from '../application-options.model'; +import { AppState } from './app-state.enum'; import { UserRepositories } from '../auth/models/user-repositories.model'; import { BackupResourceInterface } from '../backup/backup-resource.interface'; -import { DataSourceInterface } from '../data-source/data-sources/data-source.interface'; import { DiProvider } from '../di/models/di-provider.model'; import { BaseEntity } from '../entity/base-entity.model'; import { BodyParserInterface } from '../parsing/body-parser.interface'; import { Newable } from '../types/newable.type'; import { Version } from '../types/version.type'; -/** - * The possible state that the app can be in. - */ -export enum AppState { - OFFLINE = 'offline', - CREATED = 'created', - INITIALIZED = 'initialized', - RUNNING = 'running' -} - /** * The data of the app. */ @@ -51,6 +41,10 @@ export abstract class GlobalRegistry { * All injectables registered eg. Via \@Injectable. */ static readonly injectables: DiProvider[] = []; + /** + * All injectables registered via \@Injectable but with the { register: 'onUse' } flag. + */ + static readonly lazyInjectables: DiProvider[] = []; /** * All controllers registered with \@Controller. */ @@ -59,10 +53,6 @@ export abstract class GlobalRegistry { * All websocket controllers registered with \@WebsocketController. */ static readonly websocketControllerClasses: Newable[] = []; - /** - * All datasources registered with \@DataSource. - */ - static readonly dataSourceClasses: Newable[] = []; /** * All entities registered with \@Entity. */ @@ -90,45 +80,67 @@ export abstract class GlobalRegistry { return; } case AppState.CREATED: { - throw new Error('The app has already been marked as created.'); + throw new Error(`The app has already been marked as "${AppState.CREATED}".`); } case AppState.INITIALIZED: { - throw new Error('The app has already been marked as initialized.'); + throw new Error(`The app has already been marked as "${AppState.INITIALIZED}".`); } - case AppState.RUNNING: { - throw new Error('The app has already been marked as running.'); + case AppState.STARTED: { + throw new Error(`The app has already been marked as "${AppState.STARTED}".`); + } + case AppState.SHUTTING_DOWN: { + throw new Error(`The app has already been marked as "${AppState.SHUTTING_DOWN}".`); } } }, [AppState.INITIALIZED]: () => { switch (this.appData.state) { case AppState.OFFLINE: { - throw new Error('The app has not been marked as created yet.'); + throw new Error(`The app has not been marked as "${AppState.CREATED}" yet.`); } case AppState.CREATED: { return; } case AppState.INITIALIZED: { - throw new Error('The app has already been marked as initialized.'); + throw new Error(`The app has already been marked as "${AppState.INITIALIZED}".`); + } + case AppState.STARTED: { + throw new Error(`The app has already been marked as "${AppState.STARTED}".`); } - case AppState.RUNNING: { - throw new Error('The app has already been marked as running'); + case AppState.SHUTTING_DOWN: { + throw new Error(`The app has already been marked as "${AppState.SHUTTING_DOWN}".`); } } }, - [AppState.RUNNING]: () => { + [AppState.STARTED]: () => { switch (this.appData.state) { + case AppState.CREATED: case AppState.OFFLINE: { - throw new Error('The app has not been marked as initialized yet.'); - } - case AppState.CREATED: { - throw new Error('The app has not been marked as initialized yet.'); + throw new Error(`The app has not been marked as "${AppState.INITIALIZED}" yet.`); } case AppState.INITIALIZED: { return; } - case AppState.RUNNING: { - throw new Error('The app has already been marked as running.'); + case AppState.STARTED: { + throw new Error(`The app has already been marked as "${AppState.STARTED}".`); + } + case AppState.SHUTTING_DOWN: { + throw new Error(`The app has already been marked as "${AppState.SHUTTING_DOWN}".`); + } + } + }, + [AppState.SHUTTING_DOWN]: () => { + switch (this.appData.state) { + case AppState.CREATED: + case AppState.INITIALIZED: + case AppState.STARTED: { + return; + } + case AppState.OFFLINE: { + throw new Error(`The app has not been marked as "${AppState.CREATED}" yet.`); + } + case AppState.SHUTTING_DOWN: { + throw new Error(`The app has already been marked as "${AppState.SHUTTING_DOWN}".`); } } } @@ -168,23 +180,30 @@ export abstract class GlobalRegistry { } /** - * Marks the app as running. + * Marks the app as started. + */ + static markAppAsStarted(): void { + this.changeAppState(AppState.STARTED); + } + + /** + * Marks the app as shutting down. */ - static markAppAsRunning(): void { - this.changeAppState(AppState.RUNNING); + static markAppAsShuttingDown(): void { + this.changeAppState(AppState.SHUTTING_DOWN); } /** - * Checks if the app is running. - * @returns True when the app has the state of running, false otherwise. + * Checks if the app has been started. + * @returns True when the app has the state of AppState.STARTED, false otherwise. */ - static isAppRunning(): boolean { - return this.appData.state === AppState.RUNNING; + static isAppStarted(): boolean { + return this.appData.state === AppState.STARTED; } /** * Checks if the app is initialized. - * @returns True when the app has the state of initialized, false otherwise. + * @returns True when the app has the state of AppState.INITIALIZED, false otherwise. */ static isAppInitialized(): boolean { return this.appData.state === AppState.INITIALIZED; @@ -192,12 +211,20 @@ export abstract class GlobalRegistry { /** * Checks if the app is created. - * @returns True when the app has the state of created, false otherwise. + * @returns True when the app has the state of AppState.CREATED, false otherwise. */ static isAppCreated(): boolean { return this.appData.state === AppState.CREATED; } + /** + * Checks if the app is shutting down. + * @returns True when the app has the state of AppState.SHUTTING_DOWN, false otherwise. + */ + static isAppShuttingDown(): boolean { + return this.appData.state === AppState.SHUTTING_DOWN; + } + private static changeAppState(state: AppState): void { this.validateAppStateChange[state](); this.appData.state = state; diff --git a/src/global/on-app-init.interface.ts b/src/global/on-app-init.interface.ts new file mode 100644 index 0000000..a75837c --- /dev/null +++ b/src/global/on-app-init.interface.ts @@ -0,0 +1,33 @@ +import { ZibriApplication } from '../application'; + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given value implements the OnAppInit interface. + * @param value - The value to check. + */ +export function implementsOnAppInit(value: unknown): value is OnAppInit { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + if (!('onAppInit' in value)) { + return false; + } + + if (typeof value.onAppInit !== 'function') { + return false; + } + return true; +} + +/** + * Runs when the app initializes. + */ +export interface OnAppInit { + /** + * Runs when the app initializes. + */ + onAppInit: (app: ZibriApplication) => void | Promise +} \ No newline at end of file diff --git a/src/global/on-app-shutdown.interface.ts b/src/global/on-app-shutdown.interface.ts new file mode 100644 index 0000000..a87aed2 --- /dev/null +++ b/src/global/on-app-shutdown.interface.ts @@ -0,0 +1,38 @@ +import { ShutdownSignal, ZibriApplication } from '../application'; + +/** + * Runs when the app shuts down. + */ +export interface OnAppShutdown { + /** + * Runs when the app shuts down. + */ + onAppShutdown: (app: ZibriApplication, signal: ShutdownSignal | undefined) => void | Promise, + /** + * The timeout for after which the graceful shutdown should fail. + * @default 30 seconds + */ + readonly shutdownTimeoutInMs?: number +} + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given value implements the OnAppShutdown interface. + * @param value - The value to check. + */ +export function implementsOnAppShutdown(value: unknown): value is OnAppShutdown { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + if (!('onAppShutdown' in value)) { + return false; + } + + if (typeof value.onAppShutdown !== 'function') { + return false; + } + return true; +} \ No newline at end of file diff --git a/src/global/on-app-start.interface.ts b/src/global/on-app-start.interface.ts new file mode 100644 index 0000000..d0d4e32 --- /dev/null +++ b/src/global/on-app-start.interface.ts @@ -0,0 +1,33 @@ +import { ZibriApplication } from '../application'; + +// eslint-disable-next-line jsdoc/require-returns +/** + * Checks if the given value implements the OnAppStart interface. + * @param value - The value to check. + */ +export function implementsOnAppStart(value: unknown): value is OnAppStart { + if (typeof value !== 'object') { + return false; + } + if (value == undefined) { + return false; + } + if (!('onAppStart' in value)) { + return false; + } + + if (typeof value.onAppStart !== 'function') { + return false; + } + return true; +} + +/** + * Runs when the app starts. + */ +export interface OnAppStart { + /** + * Runs when the app starts. + */ + onAppStart: (app: ZibriApplication) => void | Promise +} \ No newline at end of file diff --git a/src/handlebars/generate-handlebar-type-files.function.ts b/src/handlebars/generate-handlebar-type-files.function.ts index e682155..97aefcc 100644 --- a/src/handlebars/generate-handlebar-type-files.function.ts +++ b/src/handlebars/generate-handlebar-type-files.function.ts @@ -2,7 +2,7 @@ import { AstProgram } from './ast.model'; import { HandlebarUtilities } from './handlebar.utilities'; import { resolveAllArrayKeys } from './resolve-all-array-keys.function'; import { resolveTree } from './resolve-tree.function'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc @@ -10,11 +10,14 @@ export type PathTree = { [key: string]: PathTree }; +const defaultGlobs: string[] = ['src/templates/**/*.hbs']; + /** - * Generate type files for handlebar files (.hbs), so that they expose a correctly typed "renderTemplate" function. + * Generate type files for handlebar files, so that they expose a correctly typed "renderTemplate" function. + * @param glob - The glob(s) to find your handlebar files from. */ -export async function generateHandlebarTypeFiles(): Promise { - const templateFiles: Path[] = await FsUtilities.glob('src/templates/**/*.hbs'); +export async function generateHandlebarTypeFiles(glob: string | string[] = defaultGlobs): Promise { + const templateFiles: FsPath[] = await FsUtilities.glob(glob); for (const file of templateFiles) { if (await canBeSkipped(file)) { @@ -34,7 +37,7 @@ export async function generateHandlebarTypeFiles(): Promise { } // eslint-disable-next-line jsdoc/require-jsdoc -export async function generateHandlebarType(ast: AstProgram, file: Path): Promise { +export async function generateHandlebarType(ast: AstProgram, file: FsPath): Promise { const arrayKeys: string[] = [...new Set(resolveAllArrayKeys(ast, undefined))]; const tree: PathTree = resolveTree(ast, arrayKeys); const arrayKeysWithoutThis: string[] = arrayKeys.map(k => k.replaceAll('this.', '')); @@ -42,7 +45,7 @@ export async function generateHandlebarType(ast: AstProgram, file: Path): Promis } // eslint-disable-next-line jsdoc/require-jsdoc -async function generateTypeFile(tree: PathTree, arrayKeys: string[], file: Path): Promise { +async function generateTypeFile(tree: PathTree, arrayKeys: string[], file: FsPath): Promise { const typeLines: string[] = generateInterfaceLines(tree, arrayKeys); const type: string[] = typeLines.length ? [ @@ -64,7 +67,7 @@ async function generateTypeFile(tree: PathTree, arrayKeys: string[], file: Path) .join('\n') .replace('mailingListData:', 'mailingListData?:'); - const outFile: Path = FsUtilities.getPath(FsUtilities.dirName(file), `${FsUtilities.baseName(file)}.ts`); + const outFile: FsPath = FsUtilities.getPath(FsUtilities.dirName(file), `${FsUtilities.baseName(file)}.ts`); await FsUtilities.upsertFile(outFile, content); return content.split('\n'); @@ -105,8 +108,8 @@ function generateInterfaceLines( } // eslint-disable-next-line jsdoc/require-jsdoc -async function canBeSkipped(hbsFile: Path): Promise { - const tsFile: Path = FsUtilities.getPath(`${hbsFile}.ts`); +async function canBeSkipped(hbsFile: FsPath): Promise { + const tsFile: FsPath = FsUtilities.getPath(`${hbsFile}.ts`); if (!await FsUtilities.exists(tsFile)) { return false; diff --git a/src/handlebars/handlebar.utilities.ts b/src/handlebars/handlebar.utilities.ts index a4346fb..f837e3f 100644 --- a/src/handlebars/handlebar.utilities.ts +++ b/src/handlebars/handlebar.utilities.ts @@ -1,9 +1,7 @@ import handlebars, { ParseOptions } from 'handlebars'; import { AstProgram } from './ast.model'; -import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; import { MaskUtilities } from '../utilities/mask.utilities'; import { toCamelCase } from '../utilities/to-camel-case.function'; @@ -16,8 +14,9 @@ export abstract class HandlebarUtilities { /** * Initializes the handlebar utilities. * @param H - The external handlebar object. Is needed so helpers can be registered both inside and outside of Zibri. + * @param componentsDir - The directory where components reside. */ - static async init(H: typeof Handlebars): Promise { + static async init(H: typeof Handlebars, componentsDir: string): Promise { this.H = H; this.registerHelper('json', (context) => JSON.stringify(context)); this.registerHelper('concat', (...args: unknown[]) => { @@ -44,8 +43,7 @@ export abstract class HandlebarUtilities { return new handlebars.SafeString(MaskUtilities.mask(value)); }); - const componentsDir: string = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE).componentTemplatePath; - const files: Path[] = await FsUtilities.glob(FsUtilities.getPath(componentsDir, '*.hbs')); + const files: FsPath[] = await FsUtilities.glob(FsUtilities.getPath(componentsDir, '*.hbs')); for (const file of files) { const src: string = await FsUtilities.readFile(file); const base: string = FsUtilities.baseName(file).split('.hbs')[0]; @@ -67,6 +65,32 @@ export abstract class HandlebarUtilities { return this.H.compile(input, options); } + /** + * Renders the template at the given path with the given data. + * @param path - The path of the handlebars template file. + * @param data - The data to fill into the template. + * @returns The rendered html string. + */ + static async renderTemplate>(path: `${FsPath}.hbs`, data: T): Promise { + const source: string = await FsUtilities.readFile(path as FsPath); + return this.renderTemplateString(source, data); + } + + /** + * Renders the given handlebars template string as html, using the provided data as variables. + * @param templateString - The handlebars template string. + * @param data - The data to use inside the template. + * @returns The rendered html content. + */ + static renderTemplateString>( + templateString: string, + data: T + ): string { + const template: HandlebarsTemplateDelegate = this.render(templateString); + const html: string = template(data); + return html; + } + /** * Parses the given handlebars string into an AST. * @param input - The handlebars template in form of a string. diff --git a/src/handlebars/render-template.function.ts b/src/handlebars/render-template.function.ts deleted file mode 100644 index e229d54..0000000 --- a/src/handlebars/render-template.function.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { HandlebarUtilities } from './handlebar.utilities'; -import { AssetServiceInterface } from '../assets/asset-service.interface'; -import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; -import { MailingListSubscriber } from '../email/mailing-list/models/mailing-list-subscriber.model'; -import { MailingList } from '../email/mailing-list/models/mailing-list.model'; -import { GlobalRegistry } from '../global/global-registry'; -import { OmitStrict } from '../types/omit-strict.type'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; - -// eslint-disable-next-line jsdoc/require-jsdoc -export type BaseEmailTemplateData = { - /** - * The base data shared by all email templates. - */ - base: { - /** - * The title of the email. - */ - title: string, - /** - * The base url of the app. - */ - baseUrl: string, - /** - * Data about the mailing list that this email belongs to. If any. - */ - mailingListData?: { - /** - * The base route for everything regarding mailing lists. - */ - mailingListBaseRoute: string, - /** - * The subscriber if the email belongs to a mailing list. - */ - subscriber: MailingListSubscriber, - /** - * The mailing list that the email belongs to, if any. - */ - list: MailingList - } - } -}; - -// eslint-disable-next-line jsdoc/require-jsdoc -export type BaseEmailTemplateDataInput = { - /** - * The base data shared by all email templates. - */ - base: OmitStrict -}; - -// eslint-disable-next-line jsdoc/require-jsdoc -export type BasePageTemplateData = { - /** - * The base data shared by all page templates. - */ - base: { - /** - * The base url of the app. - */ - baseUrl: string, - /** - * The title of the page. - */ - title: string - } -}; - -// eslint-disable-next-line jsdoc/require-jsdoc -export type BasePageTemplateDataInput = { - /** - * The base data shared by all page templates. - */ - base: OmitStrict -}; - -/** - * Renders the email template with the given name. - * @param templateName - The name of the template. - * @param data - The data to fill into the template. - * @returns The rendered html. - */ -export async function renderEmailTemplate(templateName: `${string}.hbs`, data: T): Promise { - (data.base as BasePageTemplateData['base']).baseUrl = GlobalRegistry.getAppData('baseUrl') ?? ''; - const assetService: AssetServiceInterface = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE); - const content: string = await renderTemplate(FsUtilities.getPath(assetService.emailTemplatePath, templateName) as `${Path}.hbs`, data); - return await renderTemplate( - FsUtilities.getPath(assetService.emailTemplatePath, 'base-email.hbs') as `${Path}.hbs`, - { content, base: data.base } - ); -} - -/** - * Renders the template at the given path with the given data. - * @param path - The path of the handlebars template file. - * @param data - The data to fill into the template. - * @returns The rendered html string. - */ -export async function renderTemplate>(path: `${Path}.hbs`, data: T): Promise { - const source: string = await FsUtilities.readFile(path as Path); - return renderTemplateString(source, data); -} - -/** - * Renders the given handlebars template string as html, using the provided data as variables. - * @param templateString - The handlebars template string. - * @param data - The data to use inside the template. - * @returns The rendered html content. - */ -export function renderTemplateString>( - templateString: string, - data: T -): string { - const template: HandlebarsTemplateDelegate = HandlebarUtilities.render(templateString); - const html: string = template(data); - return html; -} \ No newline at end of file diff --git a/src/http-client/http-client.error.ts b/src/http-client/http-client.error.ts new file mode 100644 index 0000000..b23de4e --- /dev/null +++ b/src/http-client/http-client.error.ts @@ -0,0 +1,94 @@ +import { HttpClientResponse } from './http-client-response.model'; +import { HttpMethod } from '../http/http-method.enum'; +import { OmitStrict } from '../types/omit-strict.type'; + +/** + * Data of the http request that was sent. + */ +export type HttpClientErrorRequestData = { + /** + * The request method. + */ + method: HttpMethod, + /** + * The body of the request. + */ + body: unknown, + /** + * The headers of the request. + */ + headers: Record, + /** + * The full url of the request. + */ + url: string +}; + +/** + * Data of the http response that was received. + */ +export type HttpClientErrorResponseData = OmitStrict>, 'rawBody'>; + +/** + * Options for creating a http client error. + */ +export type HttpClientErrorOptions = { + /** + * The data of the response, if one could be received. + */ + responseData: HttpClientErrorResponseData | undefined, + /** + * The data of the request, if one could be made. + */ + requestData: HttpClientErrorRequestData | undefined +}; + +/** + * Error that is thrown when a request from the http client didn't work. + */ +export class HttpClientError extends Error { + /** + * The data of the response, if one could be received. + */ + readonly responseData?: HttpClientErrorResponseData; + /** + * The data of the request, if one could be made. + */ + readonly requestData?: HttpClientErrorRequestData; + + constructor(message: string, data: HttpClientErrorOptions, options?: ErrorOptions | undefined) { + super(getErrorMessage(message, data), options); + this.name = 'HttpClientError'; + this.responseData = data.responseData; + this.requestData = data.requestData; + + Object.defineProperty(this, 'message', { enumerable: true }); + } +} + +/** + * Check whether or not the given value is a http client error. + * @param value - The value to check. + * @returns True when the values name is 'HttpClientError', false otherwise. + */ +export function isHttpClientError(value: unknown): value is HttpClientError { + return typeof value === 'object' && value != undefined && 'name' in value && value.name === 'HttpClientError'; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function getErrorMessage(message: string, data: HttpClientErrorOptions): string { + if (!data.responseData) { + if (data.requestData) { + return `No response received for ${data.requestData.method.toUpperCase()} ${data.requestData.url}:\n${message}`; + } + return `Could not send the request:\n${message}`; + } + + if (!data.requestData) { + return `Could not send the request:\n${message}`; + } + + const fullUrl: string = `${data.requestData.method.toUpperCase()} ${data.requestData.url}`; + + return `Request ${fullUrl} failed with ${data.responseData.status} ${data.responseData.statusText}`; +} \ No newline at end of file diff --git a/src/http-client/http-client.ts b/src/http-client/http-client.ts index 08d9710..ad5e8d9 100644 --- a/src/http-client/http-client.ts +++ b/src/http-client/http-client.ts @@ -1,8 +1,11 @@ -import axios, { AxiosInstance, AxiosResponse, RawAxiosRequestConfig, ResponseType } from 'axios'; + +import axios, { AxiosInstance, AxiosResponse, isAxiosError, RawAxiosRequestConfig, ResponseType } from 'axios'; import { HttpClientResponse, HttpClientResponseForBodyType } from './http-client-response.model'; +import { HttpClientError } from './http-client.error'; import { HttpClientHeaderValue, HttpClientInterface, HttpOptionsInput } from './http-client.interface'; import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { HttpMethod } from '../http/http-method.enum'; import { KnownHeader } from '../http/known-header.enum'; @@ -39,6 +42,7 @@ const responseTypeForMimeType: Record = { /** * Default http client implementation of Zibri. */ +@Injectable({ register: 'onUse' }) export class HttpClient implements HttpClientInterface { private readonly axios: AxiosInstance; @@ -236,6 +240,29 @@ export class HttpClient implements HttpClientInterface { } if (!axiosResponse) { + if (isAxiosError(error)) { + throw new HttpClientError( + error.message, + { + responseData: error.response + ? { + body: error.response.data, + headers: error.response.headers as Record, + status: error.response.status, + statusText: error.response.statusText + } + : undefined, + requestData: error.config + ? { + method, + url, + body: requestBody, + headers: error.config.headers + } + : undefined + } + ); + } throw error instanceof Error ? error : new Error('Could not get a response'); } @@ -249,7 +276,7 @@ export class HttpClient implements HttpClientInterface { body: undefined as unknown as T, status: axiosResponse.status, statusText: axiosResponse.statusText, - headers: axiosResponse.headers as HeaderParamsObject + headers: axiosResponse.headers } as HttpClientResponseForBodyType< T, HeaderMetaObjectToParamsObject>, diff --git a/src/index.ts b/src/index.ts index 13cc769..819f556 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ export * from './di/models/injection-token.model'; export * from './di/default/zibri-di-tokens.default'; export * from './di/inject.function'; +export * from './di/get-all-registered-tokens.function'; export * from './di/errors/get-dependency-stack-trace.function'; export * from './di/errors/no-provider.error'; @@ -109,6 +110,7 @@ export * from './error-handling/errors/unauthorized.error'; export * from './error-handling/errors/too-many-requests.error'; export * from './error-handling/errors/conflict.error'; export * from './error-handling/errors/missing-entities.error'; +export * from './error-handling/errors/missing-tokens.error'; export * from './error-handling/errors/content-too-large.error'; // assets @@ -116,6 +118,15 @@ export * from './assets/asset-service.interface'; export * from './assets/asset.service'; // global +export * from './global/app-state.enum'; +// export * from './global/on-app-creation.interface'; +export * from './global/before-app-init.interface'; +export * from './global/on-app-init.interface'; +export * from './global/after-app-init.interface'; +export * from './global/on-app-start.interface'; +export * from './global/before-app-shutdown.interface'; +export * from './global/on-app-shutdown.interface'; +export * from './global/after-app-shutdown.interface'; export * from './global/global-registry'; // logging @@ -252,27 +263,19 @@ export * from './email/models/email-priority.enum'; export * from './email/models/create-email-data.model'; export * from './email/models/email-config.model'; -// mailing list -export * from './email/mailing-list/mailing-list-service.interface'; -export * from './email/mailing-list/mailing-list.service'; - -export * from './email/mailing-list/models/mailing-list-subscriber.model'; -export * from './email/mailing-list/models/mailing-list.model'; -export * from './email/mailing-list/models/mailing-list-subscription-confirmation-token.model'; -export * from './email/mailing-list/models/update-mailing-list-preferences.model'; - // rate limiting export * from './rate-limiting/rate-limiter'; // handlebars export * from './handlebars/generate-handlebar-type-files.function'; -export * from './handlebars/render-template.function'; export * from './handlebars/handlebar.utilities'; // preact export * from './preact/preact.utilities'; export * from './preact/preact-component.model'; +export * from './preact/preact-email-component.model'; export * from './preact/generate-client-scripts.function'; +export * from './preact/validate-email-templates.function'; export * from './preact/hooks/on-client.hook'; export * from './preact/hooks/on-server.hook'; @@ -310,6 +313,24 @@ export * from './document/xml.utilities'; // plugin export * from './plugin/plugin.model'; +// mailing list +export * from './plugin/mailing-list/mailing-list.plugin'; +export * from './plugin/mailing-list/mailing-list.tokens'; + +export * from './plugin/mailing-list/services/mailing-list-service.interface'; +export * from './plugin/mailing-list/services/mailing-list.service'; +export * from './plugin/mailing-list/mailing-list.controller'; + +export * from './plugin/mailing-list/models/mailing-list-base-email-template.model'; +export * from './plugin/mailing-list/models/mailing-list-preferences-page-template.model'; +export * from './plugin/mailing-list/models/mailing-list-subscribe-confirmation-email-template.model'; +export * from './plugin/mailing-list/models/mailing-list-subscribe-success-page-template.model'; +export * from './plugin/mailing-list/models/mailing-list-subscriber.model'; +export * from './plugin/mailing-list/models/mailing-list-subscription-confirmation-token.model'; +export * from './plugin/mailing-list/models/mailing-list-unsubscribe-confirmation-page-template.model'; +export * from './plugin/mailing-list/models/mailing-list.model'; +export * from './plugin/mailing-list/models/update-mailing-list-preferences.model'; + // plugin invoicing export * from './plugin/invoicing/invoicing.plugin'; export * from './plugin/invoicing/invoicing.tokens'; @@ -416,6 +437,7 @@ export * from './backup/decorators/backup-resource.decorator'; export * from './http-client/http-client'; export * from './http-client/http-client.interface'; export * from './http-client/http-client-response.model'; +export * from './http-client/http-client.error'; // types export * from './types/newable.type'; @@ -428,6 +450,7 @@ export * from './utilities/promise.utilities'; export * from './utilities/ms'; export * from './utilities/big-number.utilities'; export * from './utilities/validate-entities-registered.function'; +export * from './utilities/validate-tokens-registered.function'; export * from './utilities/uuid.utilities'; export * from './utilities/mask.utilities'; export * from './utilities/fs.utilities'; \ No newline at end of file diff --git a/src/logging/log-cleanup.cron-job.ts b/src/logging/log-cleanup.cron-job.ts index 3da68a3..20d7d07 100644 --- a/src/logging/log-cleanup.cron-job.ts +++ b/src/logging/log-cleanup.cron-job.ts @@ -1,14 +1,11 @@ import { Log } from './log.model'; import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; import { Repository } from '../data-source/repository'; -import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; -import { Injectable } from '../di/decorators/injectable.decorator'; -import { inject } from '../di/inject.function'; +import { InjectRepository } from '../di/decorators/inject-repository.decorator'; /** * CronJob that cleans up the temp folder of the form data body parser. */ -@Injectable() export class LogCleanupCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { @@ -17,11 +14,11 @@ export class LogCleanupCronJob extends CronJob { runOnInit: false }; - private readonly logRepository: Repository; - - constructor() { + constructor( + @InjectRepository(Log) + private readonly logRepository: Repository + ) { super(); - this.logRepository = inject(repositoryTokenFor(Log)); } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/logging/log.model.ts b/src/logging/log.model.ts index f1b7ba2..b4efe5e 100644 --- a/src/logging/log.model.ts +++ b/src/logging/log.model.ts @@ -8,7 +8,7 @@ import { Property } from '../entity/decorators/property.decorator'; /** * The data saved for a log entry. */ -@Entity() +@Entity({ allowOrphan: true }) export class Log extends BaseEntity { /** * The log level. diff --git a/src/logging/logger.interface.ts b/src/logging/logger.interface.ts index 9cc9098..de587e5 100644 --- a/src/logging/logger.interface.ts +++ b/src/logging/logger.interface.ts @@ -1,4 +1,3 @@ -import { ZibriApplication } from '../application'; import { LogContextInput } from './log-context.model'; import { BaseLoggerTransportConfig, LoggerTransport } from './transport/logger-transport.model'; @@ -10,10 +9,6 @@ export interface LoggerInterface { * The transports to be used by the logger. */ transports: LoggerTransport[], - /** - * Attaches the service to the Zibri application. - */ - attachTo: (app: ZibriApplication) => void | Promise, /** * Logs a debug message. */ diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 80496b3..f034678 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -7,14 +7,17 @@ import { LoggerInterface } from './logger.interface'; import { ZibriApplication } from '../application'; import { BaseLoggerTransportConfig, LoggerTransport } from './transport/logger-transport.model'; import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { GlobalRegistry } from '../global/global-registry'; +import { OnAppInit } from '../global/on-app-init.interface'; import { UUIDUtilities } from '../utilities/uuid.utilities'; /** * Default logger implementation of Zibri. */ -export class Logger implements LoggerInterface { +@Injectable({ register: 'onUse' }) +export class Logger implements LoggerInterface, OnAppInit { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER_TRANSPORTS) @@ -24,7 +27,7 @@ export class Logger implements LoggerInterface { ) {} // eslint-disable-next-line jsdoc/require-jsdoc - attachTo(app: ZibriApplication): void { + onAppInit(app: ZibriApplication): void { app.options.cronJobs.push(LogCleanupCronJob); } @@ -76,7 +79,7 @@ export class Logger implements LoggerInterface { if (transport.config.level > level) { return; } - if (transport.config.register !== 'directly' && !GlobalRegistry.isAppInitialized() && !GlobalRegistry.isAppRunning()) { + if (transport.config.register !== 'directly' && !GlobalRegistry.isAppInitialized() && !GlobalRegistry.isAppStarted()) { return; } diff --git a/src/logging/transport/log-to-email.function.ts b/src/logging/transport/log-to-email.function.ts index 802972a..5380f12 100644 --- a/src/logging/transport/log-to-email.function.ts +++ b/src/logging/transport/log-to-email.function.ts @@ -4,20 +4,23 @@ import { EmailServiceInterface } from '../../email/email-service.interface'; import { EmailPriority } from '../../email/models/email-priority.enum'; import { Email } from '../../email/models/email.model'; import { GlobalRegistry } from '../../global/global-registry'; -import { renderEmailTemplate } from '../../handlebars/render-template.function'; -import { FormatDateFn } from '../../localization/formatting/format-date-fn.model'; +import { PreactUtilities } from '../../preact/preact.utilities'; import { OmitStrict } from '../../types/omit-strict.type'; import { LogLevel } from '../log-level.enum'; import { Log } from '../log.model'; -import { BaseLoggerTransportConfig, LoggerTransportSend } from './logger-transport.model'; +import { BaseLoggerTransportConfig, LogEmailTemplate, LoggerTransportSend } from './logger-transport.model'; /** * The input for creating a email logger transport. */ export type EmailLoggerTransportConfigInput = OmitStrict & Partial<{ [K in keyof Email]: (log: Log) => Email[K] }> - // eslint-disable-next-line jsdoc/require-jsdoc - & { recipients: (log: Log) => string[] }; + & { + // eslint-disable-next-line jsdoc/require-jsdoc + recipients: (log: Log) => string[], + // eslint-disable-next-line jsdoc/require-jsdoc + emailTemplate: LogEmailTemplate + }; /** * The configuration of a email logger transport. @@ -32,14 +35,6 @@ const subjectLabelForLogLevel: Record = { [LogLevel.CRITICAL]: 'Critical Error' }; -const bgColorForLogLevel: Record = { - [LogLevel.DEBUG]: '#00b4d8', - [LogLevel.INFO]: '#00b4d8', - [LogLevel.WARN]: '#edff4aff', - [LogLevel.ERROR]: '#ff5959ff', - [LogLevel.CRITICAL]: '#cc6cffff' -}; - /** * Sends the given log via email based on the given configuration. * @param log - The log to send. @@ -51,7 +46,8 @@ export const logToEmail: LoggerTransportSend = async ) => { const emailService: EmailServiceInterface = inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE); const subject: string = (config.subject ?? getSubject)(log); - const formatDate: FormatDateFn = inject(ZIBRI_DI_TOKENS.FORMAT_DATE); + const html: string = PreactUtilities.renderEmail(config.emailTemplate, { log }); + await emailService.queue({ recipients: config.recipients(log), subject, @@ -62,17 +58,7 @@ export const logToEmail: LoggerTransportSend = async persist: config.persist?.(log), sender: config.sender?.(log), userId: config.userId?.(log), - html: await renderEmailTemplate( - 'log.hbs', - { - base: { title: subject }, - log, - levelName: subjectLabelForLogLevel[log.level], - appName: GlobalRegistry.getAppData('name') ?? '', - boxBgColor: bgColorForLogLevel[log.level], - createdAtString: formatDate(log.createdAt, true) - } - ) + html }); }; diff --git a/src/logging/transport/logger-transport.model.ts b/src/logging/transport/logger-transport.model.ts index 66e5224..603d04d 100644 --- a/src/logging/transport/logger-transport.model.ts +++ b/src/logging/transport/logger-transport.model.ts @@ -1,9 +1,25 @@ +import { PreactEmailComponent } from '../../preact/preact-email-component.model'; import { LogLevel } from '../log-level.enum'; import { Log } from '../log.model'; import { logToConsole } from './log-to-console.function'; import { logToDb } from './log-to-db.function'; import { EmailLoggerTransportConfig, EmailLoggerTransportConfigInput, logToEmail } from './log-to-email.function'; +/** + * Properties for a log email template. + */ +type LogEmailTemplateProps = { + /** + * The log. + */ + log: Log +}; + +/** + * Definition for a log email template. + */ +export type LogEmailTemplate = PreactEmailComponent; + /** * The base configuration options shared by all logger transports. */ diff --git a/src/metrics/metrics-service.interface.ts b/src/metrics/metrics-service.interface.ts index c0627e2..48db20e 100644 --- a/src/metrics/metrics-service.interface.ts +++ b/src/metrics/metrics-service.interface.ts @@ -1,4 +1,3 @@ -import { ZibriApplication } from '../application'; import { CounterInterface } from './counter.interface'; import { GaugeInterface } from './gauge.interface'; import { HistogramInterface } from './histogram.interface'; @@ -39,10 +38,6 @@ export type MetricsSnapshot = { * Interface for a metrics service. */ export interface MetricsServiceInterface { - /** - * Attaches the service to the Zibri application. - */ - attachTo: (app: ZibriApplication) => void | Promise, /** * Collects metrics about a finished request. */ diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index f93aed2..418ffb4 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -11,9 +11,11 @@ import { HistogramInterface } from './histogram.interface'; import { MetricType } from './metric-type.enum'; import { Metric } from './metric.model'; import { ScrapeMetricsCronJob } from './scrape-metrics.cron-job'; -import { AssetServiceInterface } from '../assets/asset-service.interface'; +import { type AssetServiceInterface } from '../assets/asset-service.interface'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; +import { OnAppInit } from '../global/on-app-init.interface'; import { HttpRequest } from '../http/http-request.model'; import { HttpResponse } from '../http/http-response.model'; @@ -60,14 +62,18 @@ class PromHistogram implements HistogramInterface { /** * Default metrics service implementation of Zibri. */ -export class PrometheusMetricsService implements MetricsServiceInterface { +@Injectable({ register: 'onUse' }) +export class PrometheusMetricsService implements MetricsServiceInterface, OnAppInit { private readonly registry: Registry; private readonly counters: Map> = new Map>(); private readonly gauges: Map> = new Map>(); private readonly histograms: Map> = new Map>(); private readonly metricSnapshots: MetricsSnapshot[] = []; - constructor() { + constructor( + @Inject(ZIBRI_DI_TOKENS.ASSET_SERVICE) + private readonly assetService: AssetServiceInterface + ) { this.registry = new Registry(); collectDefaultMetrics({ register: this.registry, eventLoopMonitoringPrecision: 10 }); @@ -79,7 +85,7 @@ export class PrometheusMetricsService implements MetricsServiceInterface { } // eslint-disable-next-line jsdoc/require-jsdoc - attachTo(app: ZibriApplication): void { + onAppInit(app: ZibriApplication): void { app.options.cronJobs.push(ScrapeMetricsCronJob); app.use((req, res, next) => { const start: number = performance.now(); @@ -95,9 +101,8 @@ export class PrometheusMetricsService implements MetricsServiceInterface { measureFinishedRequest(req: HttpRequest, res: HttpResponse, durationInMs: number): void { // eslint-disable-next-line typescript/no-unsafe-member-access const route: string = req.originalUrl ?? req.route?.path ?? req.path; - const assetService: AssetServiceInterface = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE); - if (route.startsWith(assetService.assetsRoute)) { + if (route.startsWith(this.assetService.assetsRoute)) { return; } diff --git a/src/metrics/scrape-metrics.cron-job.ts b/src/metrics/scrape-metrics.cron-job.ts index a2c3cfe..ed380be 100644 --- a/src/metrics/scrape-metrics.cron-job.ts +++ b/src/metrics/scrape-metrics.cron-job.ts @@ -1,13 +1,11 @@ -import { MetricsServiceInterface } from './metrics-service.interface'; +import { type MetricsServiceInterface } from './metrics-service.interface'; import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; -import { Injectable } from '../di/decorators/injectable.decorator'; +import { Inject } from '../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; /** * CronJob that cleans up the temp folder of the form data body parser. */ -@Injectable() export class ScrapeMetricsCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { @@ -16,11 +14,11 @@ export class ScrapeMetricsCronJob extends CronJob { runOnInit: false }; - private readonly metricsService: MetricsServiceInterface; - - constructor() { + constructor( + @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) + private readonly metricsService: MetricsServiceInterface + ) { super(); - this.metricsService = inject(ZIBRI_DI_TOKENS.METRICS_SERVICE); } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/multithreading/services/multithreading-service.interface.ts b/src/multithreading/services/multithreading-service.interface.ts index df2c687..2b49526 100644 --- a/src/multithreading/services/multithreading-service.interface.ts +++ b/src/multithreading/services/multithreading-service.interface.ts @@ -1,4 +1,3 @@ -// TODO: add functionality to initialize functions in a registry that should be precompiled to avoid eval and improve performance. import { BaseThreadJobWorkerData } from '../models/base-thread-job-worker-data.model'; import { ThreadJobData, ThreadJobDataFunctions } from '../models/thread-job-data.model'; import { ThreadJobEntity } from '../models/thread-job-entity.model'; @@ -8,10 +7,6 @@ import { ThreadJobFunction } from '../models/thread-job-function.model'; * Definition for a service that handles multithreading. */ export interface MultithreadingServiceInterface { - /** - * Initializes the service. - */ - init: () => void | Promise, /** * Creates and queues a thread job with the given data. * @param threadJobData - The data to create the thread job from. @@ -70,9 +65,5 @@ export interface MultithreadingServiceInterface { */ waitForThreadJob: ( jobId: string - ) => Promise> | ThreadJobEntity, - /** - * Terminates all the workers. - */ - shutdown: () => Promise | void + ) => Promise> | ThreadJobEntity } \ No newline at end of file diff --git a/src/multithreading/services/multithreading.service.test.ts b/src/multithreading/services/multithreading.service.test.ts index b52bf58..3e87158 100644 --- a/src/multithreading/services/multithreading.service.test.ts +++ b/src/multithreading/services/multithreading.service.test.ts @@ -6,6 +6,8 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; import { MultithreadingService } from './multithreading.service'; import { AssetService } from '../../assets/asset.service'; import { Repository } from '../../data-source/repository'; +import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; +import { register } from '../../di/register.function'; import { LogLevel } from '../../logging/log-level.enum'; import { Logger } from '../../logging/logger'; import { LoggerInterface } from '../../logging/logger.interface'; @@ -93,14 +95,15 @@ describe('MultithreadingService - performance vs main event loop', () => { } // eslint-disable-next-line no-console console.debug('allThreads', allThreads); - multithreadingService = new MultithreadingService(options, repo, assetService, logger); - await multithreadingService.init(); + register({ token: repositoryTokenFor(ThreadJobEntity), useFactory: () => repo }); + multithreadingService = new MultithreadingService(options, assetService, logger); + await multithreadingService.onAppInit(); }, 30000); afterAll(async () => { if (allThreads <= 2) { return; } - await multithreadingService.shutdown(); + await multithreadingService.onAppShutdown(); }); it('runs CPU heavy tasks significantly faster via worker threads', async () => { diff --git a/src/multithreading/services/multithreading.service.ts b/src/multithreading/services/multithreading.service.ts index f8ae1e7..8ae5551 100644 --- a/src/multithreading/services/multithreading.service.ts +++ b/src/multithreading/services/multithreading.service.ts @@ -10,10 +10,13 @@ import { type AssetServiceInterface } from '../../assets/asset-service.interface import { Repository } from '../../data-source/repository'; import { InjectRepository } from '../../di/decorators/inject-repository.decorator'; import { Inject } from '../../di/decorators/inject.decorator'; +import { Injectable } from '../../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { OnAppInit } from '../../global/on-app-init.interface'; +import { OnAppShutdown } from '../../global/on-app-shutdown.interface'; import { type LoggerInterface } from '../../logging/logger.interface'; import { OmitStrict } from '../../types/omit-strict.type'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; import { UUIDUtilities } from '../../utilities/uuid.utilities'; import { BaseFunctionThreadJobWorkerData, BaseThreadJobWorkerData } from '../models/base-thread-job-worker-data.model'; import { type MultithreadingOptions } from '../models/multithreading-options.model'; @@ -26,7 +29,8 @@ import { ThreadJobStatus } from '../models/thread-job-status.enum'; /** * A service that handles multithreading. */ -export class MultithreadingService implements MultithreadingServiceInterface { +@Injectable({ register: 'onUse' }) +export class MultithreadingService implements MultithreadingServiceInterface, OnAppInit, OnAppShutdown { /** * All thread jobs. */ @@ -39,23 +43,23 @@ export class MultithreadingService implements MultithreadingServiceInterface { * The workers that are currently idle. */ private idleWorkers: ThreadJobWorker[] = []; - private readonly threadJobWorkerFilePath: Path; + private readonly threadJobWorkerFilePath: FsPath; constructor( @Inject(ZIBRI_DI_TOKENS.MULTITHREADING_OPTIONS) private readonly options: MultithreadingOptions, - @InjectRepository(ThreadJobEntity) - private readonly threadJobEntityRepository: Repository>, @Inject(ZIBRI_DI_TOKENS.ASSET_SERVICE) private readonly assetService: AssetServiceInterface, @Inject(ZIBRI_DI_TOKENS.LOGGER) - private readonly logger: LoggerInterface + private readonly logger: LoggerInterface, + @InjectRepository(ThreadJobEntity) + private readonly threadJobEntityRepository: Repository> ) { this.threadJobWorkerFilePath = FsUtilities.getPath(this.assetService.assetsPath, 'thread-job.worker.cjs'); } // eslint-disable-next-line jsdoc/require-jsdoc - async init(): Promise { + async onAppInit(): Promise { await this.validateInputs(); await this.logger.info('initializes worker pool for multithreading'); @@ -226,7 +230,7 @@ export class MultithreadingService implements MultithreadingServiceInterface { } // eslint-disable-next-line jsdoc/require-jsdoc - async shutdown(): Promise { + async onAppShutdown(): Promise { await Promise.all([ ...this.workers.map(w => w.worker.terminate()), ...this.idleWorkers.map(w => w.worker.terminate()) diff --git a/src/open-api/open-api-service.interface.ts b/src/open-api/open-api-service.interface.ts index b4c8874..64621b5 100644 --- a/src/open-api/open-api-service.interface.ts +++ b/src/open-api/open-api-service.interface.ts @@ -11,10 +11,6 @@ export interface OpenApiServiceInterface { * The route where the open api explorer should be reached under. */ readonly openApiRoute: Route, - /** - * Attaches the service to the Zibri application. - */ - attachTo: (app: ZibriApplication) => void | Promise, /** * Creates the open api definition. */ diff --git a/src/open-api/open-api.service.ts b/src/open-api/open-api.service.ts index 70aae57..7ba66a6 100644 --- a/src/open-api/open-api.service.ts +++ b/src/open-api/open-api.service.ts @@ -3,11 +3,13 @@ import swaggerUi from 'swagger-ui-express'; import { ZibriApplication } from '../application'; import { OpenApiServiceInterface } from './open-api-service.interface'; import { OpenApiDefinition, OpenApiTagObject, OpenApiSecuritySchemeObject, OpenApiPaths, OpenApiResponse, OpenApiOperation, OpenApiResponsesObject, OpenApiResponseObject, OpenApiContentObject, OpenApiSchemaObject, OpenApiSecurityRequirementObject, OpenApiRequestBodyObject, OpenApiParameterLocation, OpenApiParameter } from './open-api.model'; -import { AssetServiceInterface } from '../assets/asset-service.interface'; -import { AuthServiceInterface } from '../auth/auth-service.interface'; +import { type AssetServiceInterface } from '../assets/asset-service.interface'; +import { type AuthServiceInterface } from '../auth/auth-service.interface'; import { BelongsToMetadata } from '../auth/models/belongs-to-metadata.model'; import { HasRoleMetadata } from '../auth/models/has-role-metadata.model'; import { IsLoggedInMetadata } from '../auth/models/is-logged-in-metadata.model'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { BaseEntity } from '../entity/base-entity.model'; @@ -19,19 +21,22 @@ import { OneToOnePropertyMetadata } from '../entity/models/one-to-one-property-m import { Relation } from '../entity/models/relation.enum'; import { OmitClass } from '../entity/omit-class.model'; import { GlobalRegistry } from '../global/global-registry'; +import { OnAppInit } from '../global/on-app-init.interface'; import { HttpMethod } from '../http/http-method.enum'; import { HttpStatus } from '../http/http-status.enum'; import { KnownHeader } from '../http/known-header.enum'; import { MimeType } from '../http/mime-type.enum'; -import { LoggerInterface } from '../logging/logger.interface'; +import { type LoggerInterface } from '../logging/logger.interface'; import { FileResponse } from '../parsing/form-data/file-response.model'; import { Route, ControllerRouteConfiguration } from '../routing/controller-route-configuration.model'; import { BodyMetadata } from '../routing/decorators/body.decorator'; +import { ControllerData } from '../routing/decorators/controller.decorator'; import { PathParamMetadata, QueryParamMetadata, HeaderParamMetadata } from '../routing/decorators/param.decorator'; import { MissingBaseRouteError } from '../routing/missing-base-route.error'; import { RouteHandler } from '../routing/route-configuration.model'; +import { type RouterInterface } from '../routing/router.interface'; import { Newable } from '../types/newable.type'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; import { MetadataUtilities } from '../utilities/metadata.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; @@ -79,45 +84,48 @@ const defaultDescriptionForHttpStatus: Record = /** * Default open api service implementation of Zibri. */ -export class OpenApiService implements OpenApiServiceInterface { +@Injectable({ register: 'onUse' }) +export class OpenApiService implements OpenApiServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc readonly openApiRoute: Route = '/explorer'; - private readonly logger: LoggerInterface; - private readonly assetService: AssetServiceInterface; - private readonly authService: AuthServiceInterface; - - constructor() { - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - this.assetService = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE); - this.authService = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); - } + + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + private readonly logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.ASSET_SERVICE) + private readonly assetService: AssetServiceInterface, + @Inject(ZIBRI_DI_TOKENS.AUTH_SERVICE) + private readonly authService: AuthServiceInterface, + @Inject(ZIBRI_DI_TOKENS.ROUTER) + private readonly router: RouterInterface + ) { } // eslint-disable-next-line jsdoc/require-jsdoc - async attachTo(app: ZibriApplication): Promise { + async onAppInit(app: ZibriApplication): Promise { const definition: OpenApiDefinition = await this.createOpenApiDefinition(app); await this.logger.info(`registers the OpenAPI Explorer at ${this.openApiRoute}`); - await app.router.register({ + await this.router.registerRoute({ httpMethod: HttpMethod.GET, route: `${this.openApiRoute}/swagger-ui.css`, handler: () => { - const filePath: Path = FsUtilities.getPath(this.assetService.publicAssetsPath, 'open-api', 'swagger-ui.css'); + const filePath: FsPath = FsUtilities.getPath(this.assetService.publicAssetsPath, 'open-api', 'swagger-ui.css'); return FileResponse.fromPath(filePath); } }); - await app.router.register({ + await this.router.registerRoute({ httpMethod: HttpMethod.GET, route: `${this.openApiRoute}/swagger-ui-bundle.js`, handler: () => { - const filePath: Path = FsUtilities.getPath(this.assetService.publicAssetsPath, 'open-api', 'swagger-ui-bundle.js'); + const filePath: FsPath = FsUtilities.getPath(this.assetService.publicAssetsPath, 'open-api', 'swagger-ui-bundle.js'); return FileResponse.fromPath(filePath); } }); - await app.router.register({ + await this.router.registerRoute({ httpMethod: HttpMethod.GET, route: `${this.openApiRoute}/swagger-ui-standalone-preset.js`, handler: () => { - const filePath: Path = FsUtilities.getPath( + const filePath: FsPath = FsUtilities.getPath( this.assetService.publicAssetsPath, 'open-api', 'swagger-ui-standalone-preset.js' @@ -125,7 +133,7 @@ export class OpenApiService implements OpenApiServiceInterface { return FileResponse.fromPath(filePath); } }); - await app.router.register({ + await this.router.registerRoute({ httpMethod: HttpMethod.GET, route: `${this.openApiRoute}/swagger-ui-init.js`, handler: (_, res) => { @@ -152,7 +160,7 @@ export class OpenApiService implements OpenApiServiceInterface { } }); - await app.router.register({ + await this.router.registerRoute({ httpMethod: HttpMethod.GET, route: `${this.openApiRoute}/custom.js`, handler: (_, res) => { @@ -258,7 +266,7 @@ export class OpenApiService implements OpenApiServiceInterface { }); app.use(this.openApiRoute, swaggerUi.serve); - await app.router.register({ + await this.router.registerRoute({ httpMethod: HttpMethod.GET, route: this.openApiRoute, handler: swaggerUi.setup( @@ -304,8 +312,8 @@ export class OpenApiService implements OpenApiServiceInterface { const res: OpenApiPaths = {}; for (const controllerClass of app.options.controllers) { - const baseRoute: Route | undefined = MetadataUtilities.getControllerBaseRoute(controllerClass); - if (!baseRoute) { + const controllerData: ControllerData | undefined = MetadataUtilities.getControllerData(controllerClass); + if (!controllerData) { throw new MissingBaseRouteError(controllerClass); } @@ -328,7 +336,8 @@ export class OpenApiService implements OpenApiServiceInterface { const bodyMetadata: BodyMetadata | undefined = MetadataUtilities.getRouteBody(controllerClass, route.controllerMethod); // Ensure an entry exists - const fullPath: string = `${baseRoute}${route.route}`.replaceAll(/:([^/]+)/g, '{$1}'); + const finalRoute: string = controllerData.baseRoute === '/' ? route.route : `${controllerData.baseRoute}${route.route}`; + const fullPath: string = finalRoute.replaceAll(/:([^/]+)/g, '{$1}'); res[fullPath] ??= {}; const responses: OpenApiResponse[] = MetadataUtilities.getRouteResponses(controllerClass, route.controllerMethod); @@ -354,7 +363,7 @@ export class OpenApiService implements OpenApiServiceInterface { } } - for (const route of app.router.manuallyRegisteredRoutes.filter(r => r.openApi.useInOpenApi)) { + for (const route of this.router.manuallyRegisteredRoutes.filter(r => r.openApi.useInOpenApi)) { // Ensure an entry exists const fullPath: string = `${route.route}`.replaceAll(/:([^/]+)/g, '{$1}'); res[fullPath] ??= {}; diff --git a/src/parsing/body-parser.interface.ts b/src/parsing/body-parser.interface.ts index dce4f7c..ba87560 100644 --- a/src/parsing/body-parser.interface.ts +++ b/src/parsing/body-parser.interface.ts @@ -1,4 +1,3 @@ -import { ZibriApplication } from '../application'; import { HttpRequest } from '../http/http-request.model'; import { MimeType } from '../http/mime-type.enum'; import { HttpClientResponse } from '../http-client/http-client-response.model'; @@ -13,10 +12,6 @@ export interface BodyParserInterface { * The content type that can be handled by this parser. */ readonly contentType: MimeType, - /** - * Attaches the body parser to the Zibri application. - */ - attachTo?: (app: ZibriApplication) => Promise | void, /** * Parses the body of the http request. */ diff --git a/src/parsing/form-data/file-response.model.ts b/src/parsing/form-data/file-response.model.ts index 851ce59..32eb42a 100644 --- a/src/parsing/form-data/file-response.model.ts +++ b/src/parsing/form-data/file-response.model.ts @@ -6,7 +6,7 @@ import { LooseFileMimeType } from '../../http/mime-type.enum'; import { resolveMimeType } from '../../http/mime-type.helpers'; import { LoggerInterface } from '../../logging/logger.interface'; import { OmitStrict } from '../../types/omit-strict.type'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; /** * Data shared by all FileResponses. @@ -70,8 +70,8 @@ export class FileResponse { * @param options - Additional options like file size. * @returns A new FileResponse. */ - static async fromPath(p: Path, options?: OmitStrict): Promise { - const fullPath: Path = FsUtilities.resolve(p); + static async fromPath(p: FsPath, options?: OmitStrict): Promise { + const fullPath: FsPath = FsUtilities.resolve(p); const fileName: string = options?.filename ?? FsUtilities.baseName(fullPath); const mimeType: string = options?.mimeType ?? resolveMimeType(fileName); diff --git a/src/parsing/form-data/file.model.ts b/src/parsing/form-data/file.model.ts index 9ae1a42..d151da9 100644 --- a/src/parsing/form-data/file.model.ts +++ b/src/parsing/form-data/file.model.ts @@ -1,5 +1,5 @@ import { Property } from '../../entity/decorators/property.decorator'; -import { type Path } from '../../utilities/fs.utilities'; +import { type FsPath } from '../../utilities/fs.utilities'; /** * A resolved file from a multipart/form-data request. @@ -31,7 +31,7 @@ export class File { // eslint-disable-next-line jsdoc/require-jsdoc @Property.string() - path: Path; + path: FsPath; constructor(file: File) { this.destination = file.destination; diff --git a/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts b/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts index 3d764e7..2acbdd1 100644 --- a/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts +++ b/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts @@ -2,15 +2,13 @@ import { Dirent } from 'node:fs'; import { CLEANUP_AT_FILE_NAME } from './form-data.model'; import { CronJob, InitialCronConfig } from '../../cron/cron-job.model'; -import { Injectable } from '../../di/decorators/injectable.decorator'; +import { Inject } from '../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; -import { inject } from '../../di/inject.function'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, type FsPath } from '../../utilities/fs.utilities'; /** * CronJob that cleans up the temp folder of the form data body parser. */ -@Injectable() export class FormDataBodyParserCleanupCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { @@ -19,19 +17,24 @@ export class FormDataBodyParserCleanupCronJob extends CronJob { runOnInit: false }; + constructor( + @Inject(ZIBRI_DI_TOKENS.FILE_UPLOAD_TEMP_FOLDER) + private readonly tempPath: FsPath + ) { + super(); + } + // eslint-disable-next-line jsdoc/require-jsdoc async onTick(): Promise { - const tempPath: Path = inject(ZIBRI_DI_TOKENS.FILE_UPLOAD_TEMP_FOLDER); - - await this.logger.info(`cleans up temp folder ${tempPath}`); + await this.logger.info(`cleans up temp folder ${this.tempPath}`); try { - const folders: Dirent[] = await FsUtilities.readdir(tempPath); + const folders: Dirent[] = await FsUtilities.readdir(this.tempPath); let foldersToPreserve: number = 0; for (const folder of folders) { try { - const folderPath: Path = FsUtilities.getPath(tempPath, folder.parentPath, folder.name); + const folderPath: FsPath = FsUtilities.getPath(this.tempPath, folder.parentPath, folder.name); const shouldBePreserved: boolean = await this.hasRecentlyBeenCreated(folderPath); if (!shouldBePreserved) { @@ -53,8 +56,8 @@ export class FormDataBodyParserCleanupCronJob extends CronJob { } } - private async hasRecentlyBeenCreated(folderPath: Path): Promise { - const cleanupAtPath: Path = FsUtilities.getPath(folderPath, CLEANUP_AT_FILE_NAME); + private async hasRecentlyBeenCreated(folderPath: FsPath): Promise { + const cleanupAtPath: FsPath = FsUtilities.getPath(folderPath, CLEANUP_AT_FILE_NAME); try { // Check if the file/folder has been modified within the last 24 hours const cleanupAtMs: number = Number(await FsUtilities.readFile(cleanupAtPath)); diff --git a/src/parsing/form-data/form-data.body-parser.ts b/src/parsing/form-data/form-data.body-parser.ts index dddcbc4..7d9f471 100644 --- a/src/parsing/form-data/form-data.body-parser.ts +++ b/src/parsing/form-data/form-data.body-parser.ts @@ -8,6 +8,7 @@ import { FormDataBodyParserCleanupCronJob } from './form-data-body-parser-cleanu import { FormData, FormDataValue } from './form-data.model'; import { ZibriApplication } from '../../application'; import { inject } from '../../di/inject.function'; +import { OnAppInit } from '../../global/on-app-init.interface'; import { HttpRequest } from '../../http/http-request.model'; import { MimeType } from '../../http/mime-type.enum'; import { BodyMetadata } from '../../routing/decorators/body.decorator'; @@ -21,7 +22,7 @@ import { KnownHeader } from '../../http/known-header.enum'; import { FileExtension, resolveFileExtension } from '../../http/mime-type.helpers'; import { HttpClientResponse } from '../../http-client/http-client-response.model'; import { BigNumberUtilities } from '../../utilities/big-number.utilities'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { UUIDUtilities } from '../../utilities/uuid.utilities'; import { BodyParser } from '../decorators/body-parser.decorator'; @@ -44,12 +45,12 @@ type ParsedForm = { * Body parser for form data. */ @BodyParser() -export class FormDataBodyParser implements BodyParserInterface { +export class FormDataBodyParser implements BodyParserInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc readonly contentType: MimeType = MimeType.FORM_DATA; // eslint-disable-next-line jsdoc/require-jsdoc - attachTo(app: ZibriApplication): void { + onAppInit(app: ZibriApplication): void { app.options.cronJobs.push(FormDataBodyParserCleanupCronJob); } @@ -88,7 +89,7 @@ export class FormDataBodyParser implements BodyParserInterface { throw new ContentTooLargeError(); } - const tempFolder: Path = this.getTempFolder(); + const tempFolder: FsPath = this.getTempFolder(); try { const parsed: ParsedForm = await this.parseMultipartStreamToDisk(stream, headers, tempFolder, metadata); @@ -107,8 +108,8 @@ export class FormDataBodyParser implements BodyParserInterface { } } - private getTempFolder(): Path { - const tempPath: Path = inject(ZIBRI_DI_TOKENS.FILE_UPLOAD_TEMP_FOLDER); + private getTempFolder(): FsPath { + const tempPath: FsPath = inject(ZIBRI_DI_TOKENS.FILE_UPLOAD_TEMP_FOLDER); return FsUtilities.getPath(tempPath, `temp-${UUIDUtilities.generate()}`); } @@ -121,7 +122,7 @@ export class FormDataBodyParser implements BodyParserInterface { return id; } - private async removeTempFolder(tempFolder: Path): Promise { + private async removeTempFolder(tempFolder: FsPath): Promise { try { await FsUtilities.rm(tempFolder); } @@ -242,7 +243,7 @@ export class FormDataBodyParser implements BodyParserInterface { private async parseMultipartStreamToDisk( stream: Readable, headers: Partial>, - tempFolder: Path, + tempFolder: FsPath, metadata: BodyMetadata ): Promise { const contentType: string | undefined = headers[KnownHeader.CONTENT_TYPE] @@ -293,7 +294,7 @@ export class FormDataBodyParser implements BodyParserInterface { bb.on('file', (fieldname: string, fileStream: BusboyFileStream, originalname: string, _: string, mimetype: string) => { const filename: string = this.getTempFileName(mimetype); - const destination: Path = FsUtilities.getPath(tempFolder, filename); + const destination: FsPath = FsUtilities.getPath(tempFolder, filename); const writeStream: WriteStream = FsUtilities.createWriteStream(destination); let size: number = 0; diff --git a/src/parsing/form-data/form-data.model.ts b/src/parsing/form-data/form-data.model.ts index 303e14e..d70fda6 100644 --- a/src/parsing/form-data/form-data.model.ts +++ b/src/parsing/form-data/form-data.model.ts @@ -1,5 +1,5 @@ import { File } from './file.model'; -import { FsUtilities, Path } from '../../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; /** * The raw value that a form-data property has. @@ -26,9 +26,9 @@ export class FormData { * The temporary folder where all files are cached. * Should be deleted after you handled the form data with the cleanup method. */ - readonly tempFolder: Path; + readonly tempFolder: FsPath; - private constructor(value: FormDataType, tempFolder: Path) { + private constructor(value: FormDataType, tempFolder: FsPath) { this.value = value; this.tempFolder = tempFolder; } @@ -42,7 +42,7 @@ export class FormData { */ static async create( value: FormDataType, - tempFolder: Path, + tempFolder: FsPath, cleanupAfterMs: number ): Promise> { const res: FormData = new this(value, tempFolder); diff --git a/src/parsing/parser.interface.ts b/src/parsing/parser.interface.ts index ea4d92e..66fb17b 100644 --- a/src/parsing/parser.interface.ts +++ b/src/parsing/parser.interface.ts @@ -1,4 +1,3 @@ -import { ZibriApplication } from '../application'; import { HttpRequest } from '../http/http-request.model'; import { HttpClientResponse } from '../http-client/http-client-response.model'; import { BodyMetadata } from '../routing/decorators/body.decorator'; @@ -27,9 +26,5 @@ export interface ParserInterface { /** * Parses the header param resolved from the given metadata. */ - parseHeaderParam: (req: HttpRequest | WebsocketRequest | HttpClientResponse, metadata: HeaderParamMetadata) => unknown, - /** - * Attaches the parser to the Zibri application. - */ - attachTo: (app: ZibriApplication) => Promise | void + parseHeaderParam: (req: HttpRequest | WebsocketRequest | HttpClientResponse, metadata: HeaderParamMetadata) => unknown } \ No newline at end of file diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index e9be9f4..4e4b083 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -2,10 +2,11 @@ import assert from 'assert'; import { BodyParserInterface } from './body-parser.interface'; import { ParserInterface } from './parser.interface'; -import { ZibriApplication } from '../application'; +import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { GlobalRegistry } from '../global/global-registry'; +import { OnAppInit } from '../global/on-app-init.interface'; import { HttpRequest, isHttpRequest } from '../http/http-request.model'; import { KnownHeader } from '../http/known-header.enum'; import { MimeType } from '../http/mime-type.enum'; @@ -20,6 +21,7 @@ import { parseObject } from './functions/parse-object.function'; import { parseString } from './functions/parse-string.function'; import { BodyMetadata } from '../routing/decorators/body.decorator'; import { PathParamMetadata, QueryParamMetadata, HeaderParamMetadata } from '../routing/decorators/param.decorator'; +import { UUIDUtilities } from '../utilities/uuid.utilities'; import { WebsocketRequest } from '../websocket/models/websocket-request.model'; /** @@ -40,7 +42,8 @@ type HeaderParamParseFunction = (rawValue: string | undefined, meta: HeaderParam /** * Default parser implementation of Zibri. */ -export class Parser implements ParserInterface { +@Injectable({ register: 'onUse' }) +export class Parser implements ParserInterface, OnAppInit { private readonly logger: LoggerInterface; private readonly bodyParsers: BodyParserInterface[] = []; @@ -81,8 +84,11 @@ export class Parser implements ParserInterface { } }; + private readonly id: string; + constructor() { this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); + this.id = UUIDUtilities.generate(); } // eslint-disable-next-line jsdoc/require-jsdoc @@ -123,6 +129,7 @@ export class Parser implements ParserInterface { if (metadata.type !== contentType) { throw new Error(`Unsupported ${KnownHeader.CONTENT_TYPE}: "${contentType}"`); } + const fittingParsers: BodyParserInterface[] = this.bodyParsers.filter(p => p.contentType === contentType); if (!fittingParsers.length) { throw new Error(`Unsupported ${KnownHeader.CONTENT_TYPE}: "${contentType}"`); @@ -140,13 +147,12 @@ export class Parser implements ParserInterface { } // eslint-disable-next-line jsdoc/require-jsdoc - async attachTo(app: ZibriApplication): Promise { + async onAppInit(): Promise { await this.logger.info(`registers ${GlobalRegistry.bodyParsers.length} request body parsers:`); for (const parserClass of GlobalRegistry.bodyParsers) { const parser: BodyParserInterface = inject(parserClass); this.bodyParsers.push(parser); await this.logger.info(` - ${parserClass.name} (${parser.contentType})`); - await parser.attachTo?.(app); } } } \ No newline at end of file diff --git a/src/plugin/invoicing/invoicing.plugin.ts b/src/plugin/invoicing/invoicing.plugin.ts index c3faf9e..6f25a32 100644 --- a/src/plugin/invoicing/invoicing.plugin.ts +++ b/src/plugin/invoicing/invoicing.plugin.ts @@ -1,7 +1,6 @@ -/* eslint-disable jsdoc/require-jsdoc */ -import { ZIBRI_INVOICING_DI_TOKENS } from './invoicing.tokens'; import { DiTokenProviderRecord, providersFromTokenRecord } from '../../di/models/di-token.model'; import { ZibriPlugin } from '../plugin.model'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from './invoicing.tokens'; import { Invoice } from './models/invoice.model'; import { InvoicingOptionsInput } from './models/invoicing-options-input.model'; import { InvoicingOptions } from './models/invoicing-options.model'; @@ -9,10 +8,12 @@ import { NumberInvoices } from './models/number-invoices.model'; import { InvoiceCalcService } from './services/invoice-calc.service'; import { InvoiceNumberService } from './services/invoice-number.service'; import { InvoicePdfService } from './services/invoice-pdf.service'; +import { ZibriApplication } from '../../application'; import { NoProviderError } from '../../di/errors/no-provider.error'; import { inject } from '../../di/inject.function'; import { DiProvider } from '../../di/models/di-provider.model'; import { validateEntitiesRegistered } from '../../utilities/validate-entities-registered.function'; +import { validateTokensRegistered } from '../../utilities/validate-tokens-registered.function'; import { PeppolConformanceService } from './services/conformance/en16931/peppol-conformance.service'; import { XRechnungConformanceService } from './services/conformance/en16931/x-rechnung-conformance.service'; @@ -20,7 +21,7 @@ import { XRechnungConformanceService } from './services/conformance/en16931/x-re * Plugin that includes everything for handling invoices. */ export class ZibriInvoicingPlugin extends ZibriPlugin { - private readonly defaultDiProviders: DiTokenProviderRecord = { + private readonly defaultDiProviders: DiTokenProviderRecord = { INVOICE_NUMBER_SERVICE: { useClass: InvoiceNumberService }, INVOICE_PDF_SERVICE: { useClass: InvoicePdfService }, INVOICE_CALC_SERVICE: { useClass: InvoiceCalcService }, @@ -29,12 +30,12 @@ export class ZibriInvoicingPlugin extends ZibriPlugin { }, OPTIONS_INPUT: { useFactory: () => { - throw new NoProviderError(ZIBRI_INVOICING_DI_TOKENS.OPTIONS_INPUT, []); + throw new NoProviderError(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS_INPUT, []); } }, OPTIONS: { useFactory: () => { - const input: InvoicingOptionsInput = inject(ZIBRI_INVOICING_DI_TOKENS.OPTIONS_INPUT); + const input: InvoicingOptionsInput = inject(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS_INPUT); const res: InvoicingOptions = { footerFontSize: 10, images: undefined, @@ -78,10 +79,12 @@ export class ZibriInvoicingPlugin extends ZibriPlugin { }; - providers: DiProvider[] = providersFromTokenRecord(ZIBRI_INVOICING_DI_TOKENS, this.defaultDiProviders); + // eslint-disable-next-line jsdoc/require-jsdoc + providers: DiProvider[] = providersFromTokenRecord(ZIBRI_INVOICING_PLUGIN_DI_TOKENS, this.defaultDiProviders); - validate(): void { - validateEntitiesRegistered(this.constructor.name, Invoice, NumberInvoices); - inject(ZIBRI_INVOICING_DI_TOKENS.OPTIONS); + // eslint-disable-next-line jsdoc/require-jsdoc + validate(app: ZibriApplication): void { + validateEntitiesRegistered(this.constructor.name, app, Invoice, NumberInvoices); + validateTokensRegistered(this.constructor.name, ZIBRI_INVOICING_PLUGIN_DI_TOKENS); } } \ No newline at end of file diff --git a/src/plugin/invoicing/invoicing.tokens.ts b/src/plugin/invoicing/invoicing.tokens.ts index 8fbb1c9..c20ad3e 100644 --- a/src/plugin/invoicing/invoicing.tokens.ts +++ b/src/plugin/invoicing/invoicing.tokens.ts @@ -12,7 +12,7 @@ import { InjectionToken } from '../../di/models/injection-token.model'; * The dependency injection tokens used by the ZibriInvoicingPlugin. */ // eslint-disable-next-line typescript/typedef -export const ZIBRI_INVOICING_DI_TOKENS = { +export const ZIBRI_INVOICING_PLUGIN_DI_TOKENS = { // eslint-disable-next-line typescript/no-explicit-any INVOICE_NUMBER_SERVICE: invoicingToken>('zi.invoicing.invoice_number_service'), OPTIONS_INPUT: invoicingToken('zi.invoicing.options_input'), diff --git a/src/plugin/invoicing/models/invoice.model.ts b/src/plugin/invoicing/models/invoice.model.ts index 6d319d3..101f4b4 100644 --- a/src/plugin/invoicing/models/invoice.model.ts +++ b/src/plugin/invoicing/models/invoice.model.ts @@ -8,7 +8,7 @@ import { type CurrencyCode } from '../../../localization/models/currency-code.mo /** * Contains information about an invoice. */ -@Entity() +@Entity({ allowOrphan: true }) export class Invoice extends BaseEntity { /** * The unique invoice number for this invoice. diff --git a/src/plugin/invoicing/models/number-invoices.model.ts b/src/plugin/invoicing/models/number-invoices.model.ts index 7f57a23..c686599 100644 --- a/src/plugin/invoicing/models/number-invoices.model.ts +++ b/src/plugin/invoicing/models/number-invoices.model.ts @@ -5,7 +5,7 @@ import { Property } from '../../../entity/decorators/property.decorator'; /** * Contains the information about how many invoices have been created in a specific year. */ -@Entity() +@Entity({ allowOrphan: true }) export class NumberInvoices extends BaseEntity { /** * The id of the recipient of the invoice. diff --git a/src/plugin/invoicing/services/conformance/en16931/en16931-conformance.service.ts b/src/plugin/invoicing/services/conformance/en16931/en16931-conformance.service.ts index 998148c..aece3b5 100644 --- a/src/plugin/invoicing/services/conformance/en16931/en16931-conformance.service.ts +++ b/src/plugin/invoicing/services/conformance/en16931/en16931-conformance.service.ts @@ -2,7 +2,7 @@ import { Inject } from '../../../../../di/decorators/inject.decorator'; import { PdfDocumentDefinition, PdfAttachmentDefinition, PdfDocument } from '../../../../../document/pdf.utilities'; import { XML, XmlUtilities } from '../../../../../document/xml.utilities'; import { MimeType } from '../../../../../http/mime-type.enum'; -import { ZIBRI_INVOICING_DI_TOKENS } from '../../../invoicing.tokens'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../../../invoicing.tokens'; import { InvoiceItem } from '../../../models/invoice-item.model'; import { Invoice } from '../../../models/invoice.model'; import { type InvoicingOptions } from '../../../models/invoicing-options.model'; @@ -26,9 +26,9 @@ export abstract class EN16931ConformanceService implements InvoiceConformanceSer abstract readonly documentContextId: EN16931DocumentContextId; constructor( - @Inject(ZIBRI_INVOICING_DI_TOKENS.OPTIONS) + @Inject(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS) protected readonly options: InvoicingOptions, - @Inject(ZIBRI_INVOICING_DI_TOKENS.INVOICE_CALC_SERVICE) + @Inject(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.INVOICE_CALC_SERVICE) private readonly invoiceCalcService: InvoiceCalcServiceInterface ) {} diff --git a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts index aa3e977..7b2348a 100644 --- a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts +++ b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.test.ts @@ -1,22 +1,23 @@ import { afterAll, beforeAll, describe, it } from '@jest/globals'; -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import { StartedTestContainer } from 'testcontainers'; import { PeppolConformanceService } from './peppol-conformance.service'; -import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../../../__testing__/constants'; -import { PostgresDataSource, PostgresOptions } from '../../../../../data-source/data-sources/postgres-data-source.model'; -import { DataSource } from '../../../../../data-source/decorators/data-source.decorator'; -import { MigrationEntity } from '../../../../../data-source/migration/migration-entity.model'; +import { testFileFolder } from '../../../../../__testing__/constants'; +import { createTestDataSource, defaultTestServerEntities } from '../../../../../__testing__/test-server/create-test-data-source.function'; +import { defaultTestServerProviders } from '../../../../../__testing__/test-server/providers'; +import { StartedTestServer, startTestServer } from '../../../../../__testing__/test-server/start-test-server.function'; import { Repository } from '../../../../../data-source/repository'; +import { repositoryTokenFor } from '../../../../../di/decorators/inject-repository.decorator'; +import { inject } from '../../../../../di/inject.function'; +import { defineProvider } from '../../../../../di/models/di-provider.model'; import { XML } from '../../../../../document/xml.utilities'; -import { BaseEntity } from '../../../../../entity/base-entity.model'; -import { Newable } from '../../../../../types/newable.type'; import { OmitStrict } from '../../../../../types/omit-strict.type'; import { FsUtilities } from '../../../../../utilities/fs.utilities'; import { Ms } from '../../../../../utilities/ms'; +import { ZibriInvoicingPlugin } from '../../../invoicing.plugin'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../../../invoicing.tokens'; import { Invoice } from '../../../models/invoice.model'; import { InvoicingOptions } from '../../../models/invoicing-options.model'; -import { InvoiceCalcService } from '../../invoice-calc.service'; +import { NumberInvoices } from '../../../models/number-invoices.model'; const invoicingOptions: InvoicingOptions = { footerFontSize: 10, @@ -72,44 +73,28 @@ const invoicingOptions: InvoicingOptions = { taxOffice: 'Tax Office Example City' }; -const invoiceCalcService: InvoiceCalcService = new InvoiceCalcService(); -const conformanceService: PeppolConformanceService = new PeppolConformanceService(invoicingOptions, invoiceCalcService); +let conformanceService: PeppolConformanceService; -@DataSource() -class DbDataSource extends PostgresDataSource { - options: PostgresOptions = { - host: 'localhost', - username: 'postgres', - password: 'password', - database: 'db', - synchronize: true - }; - entities: Newable[] = [Invoice, MigrationEntity]; -} - -let container: StartedTestContainer; -let dataSource: DbDataSource; +let server: StartedTestServer; let repo: Repository>; describe('generateXml', () => { beforeAll(async () => { await FsUtilities.mkdir(testFileFolder); - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - dataSource = new DbDataSource(); - dataSource.options = { - ...dataSource.options, - port: container.getMappedPort(5432) - }; - await dataSource.init(); - repo = dataSource.getRepository(Invoice); + server = await startTestServer({ + plugins: [new ZibriInvoicingPlugin()], + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Invoice, NumberInvoices] })], + providers: [ + ...defaultTestServerProviders, + defineProvider({ token: ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS_INPUT, useValue: invoicingOptions }) + ] + }); + repo = inject(repositoryTokenFor(Invoice)); + conformanceService = inject(PeppolConformanceService); }, 15000); afterAll(async () => { - await container.stop(); + await server.shutdown(); }); it('should create the expected result', async () => { diff --git a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.ts b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.ts index ce603b3..e7890bf 100644 --- a/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.ts +++ b/src/plugin/invoicing/services/conformance/en16931/peppol-conformance.service.ts @@ -5,7 +5,7 @@ import { EN16931ConformanceService, EN16931DocumentContextId } from './en16931-c /** * Handles conforming to the peppol standard. */ -@Injectable() +@Injectable({ register: 'onUse' }) export class PeppolConformanceService extends EN16931ConformanceService { override readonly name: InvoiceConformance = 'peppol'; // eslint-disable-next-line stylistic/max-len diff --git a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts index f646988..a005c1e 100644 --- a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts +++ b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.test.ts @@ -1,22 +1,23 @@ import { afterAll, beforeAll, describe, it } from '@jest/globals'; -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import { StartedTestContainer } from 'testcontainers'; import { XRechnungConformanceService } from './x-rechnung-conformance.service'; -import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../../../__testing__/constants'; -import { PostgresDataSource, PostgresOptions } from '../../../../../data-source/data-sources/postgres-data-source.model'; -import { DataSource } from '../../../../../data-source/decorators/data-source.decorator'; -import { MigrationEntity } from '../../../../../data-source/migration/migration-entity.model'; +import { testFileFolder } from '../../../../../__testing__/constants'; +import { createTestDataSource, defaultTestServerEntities } from '../../../../../__testing__/test-server/create-test-data-source.function'; +import { defaultTestServerProviders } from '../../../../../__testing__/test-server/providers'; +import { StartedTestServer, startTestServer } from '../../../../../__testing__/test-server/start-test-server.function'; import { Repository } from '../../../../../data-source/repository'; +import { repositoryTokenFor } from '../../../../../di/decorators/inject-repository.decorator'; +import { inject } from '../../../../../di/inject.function'; +import { defineProvider } from '../../../../../di/models/di-provider.model'; import { XML } from '../../../../../document/xml.utilities'; -import { BaseEntity } from '../../../../../entity/base-entity.model'; -import { Newable } from '../../../../../types/newable.type'; import { OmitStrict } from '../../../../../types/omit-strict.type'; import { FsUtilities } from '../../../../../utilities/fs.utilities'; import { Ms } from '../../../../../utilities/ms'; +import { ZibriInvoicingPlugin } from '../../../invoicing.plugin'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../../../invoicing.tokens'; import { Invoice } from '../../../models/invoice.model'; import { InvoicingOptions } from '../../../models/invoicing-options.model'; -import { InvoiceCalcService } from '../../invoice-calc.service'; +import { NumberInvoices } from '../../../models/number-invoices.model'; const invoicingOptions: InvoicingOptions = { footerFontSize: 10, @@ -72,44 +73,28 @@ const invoicingOptions: InvoicingOptions = { taxOffice: 'Tax Office Example City' }; -const invoiceCalcService: InvoiceCalcService = new InvoiceCalcService(); -const conformanceService: XRechnungConformanceService = new XRechnungConformanceService(invoicingOptions, invoiceCalcService); - -@DataSource() -class DbDataSource extends PostgresDataSource { - options: PostgresOptions = { - host: 'localhost', - username: 'postgres', - password: 'password', - database: 'db', - synchronize: true - }; - entities: Newable[] = [Invoice, MigrationEntity]; -} - -let container: StartedTestContainer; -let dataSource: DbDataSource; +let server: StartedTestServer; let repo: Repository>; +let conformanceService: XRechnungConformanceService; describe('generateXml', () => { beforeAll(async () => { await FsUtilities.mkdir(testFileFolder); - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - dataSource = new DbDataSource(); - dataSource.options = { - ...dataSource.options, - port: container.getMappedPort(5432) - }; - await dataSource.init(); - repo = dataSource.getRepository(Invoice); + server = await startTestServer({ + plugins: [new ZibriInvoicingPlugin()], + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Invoice, NumberInvoices] })], + providers: [ + ...defaultTestServerProviders, + defineProvider({ token: ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS_INPUT, useValue: invoicingOptions }) + ] + }); + + repo = inject(repositoryTokenFor(Invoice)); + conformanceService = inject(XRechnungConformanceService); }, 15000); afterAll(async () => { - await container.stop(); + await server.shutdown(); }); it('should create the expected result', async () => { diff --git a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.ts b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.ts index e342690..3561ac3 100644 --- a/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.ts +++ b/src/plugin/invoicing/services/conformance/en16931/x-rechnung-conformance.service.ts @@ -5,7 +5,7 @@ import { EN16931ConformanceService, EN16931DocumentContextId } from './en16931-c /** * Handles conforming to the X-Rechnung standard. */ -@Injectable() +@Injectable({ register: 'onUse' }) export class XRechnungConformanceService extends EN16931ConformanceService { override readonly name: InvoiceConformance = 'x-rechnung'; override readonly documentContextId: EN16931DocumentContextId = 'urn:cen.eu:en16931:2017#compliant#urn:xrechnung:3.0'; diff --git a/src/plugin/invoicing/services/invoice-calc.service.ts b/src/plugin/invoicing/services/invoice-calc.service.ts index 842fed4..d0e9c0b 100644 --- a/src/plugin/invoicing/services/invoice-calc.service.ts +++ b/src/plugin/invoicing/services/invoice-calc.service.ts @@ -1,5 +1,6 @@ import { InvoiceCalcServiceInterface } from './invoice-calc-service.interface'; +import { Injectable } from '../../../di/decorators/injectable.decorator'; import { BigNumberUtilities } from '../../../utilities/big-number.utilities'; import { InvoiceItem } from '../models/invoice-item.model'; import { Invoice as BaseInvoice, Invoice } from '../models/invoice.model'; @@ -8,6 +9,7 @@ import { Vat } from '../models/vat.model'; /** * Default implementation of the invoice calculation service. */ +@Injectable({ register: 'onUse' }) export class InvoiceCalcService implements InvoiceCalcServiceInterface { // eslint-disable-next-line jsdoc/require-jsdoc getItemTotalPriceBeforeTax(item: InvoiceItem): BigNumber { diff --git a/src/plugin/invoicing/services/invoice-number.service.test.ts b/src/plugin/invoicing/services/invoice-number.service.test.ts index bec5538..71abec3 100644 --- a/src/plugin/invoicing/services/invoice-number.service.test.ts +++ b/src/plugin/invoicing/services/invoice-number.service.test.ts @@ -1,18 +1,18 @@ /* eslint-disable cspell/spellchecker */ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import { StartedTestContainer } from 'testcontainers'; -import { InvoiceCalcService } from './invoice-calc.service'; import { InvoiceNumberService } from './invoice-number.service'; -import { POSTGRES_TEST_IMAGE } from '../../../__testing__/constants'; -import { PostgresDataSource, PostgresOptions } from '../../../data-source/data-sources/postgres-data-source.model'; -import { DataSource } from '../../../data-source/decorators/data-source.decorator'; -import { MigrationEntity } from '../../../data-source/migration/migration-entity.model'; +import { createTestDataSource, defaultTestServerEntities } from '../../../__testing__/test-server/create-test-data-source.function'; +import { defaultTestServerPlugins } from '../../../__testing__/test-server/plugins'; +import { defaultTestServerProviders } from '../../../__testing__/test-server/providers'; +import { StartedTestServer, startTestServer } from '../../../__testing__/test-server/start-test-server.function'; import { Repository } from '../../../data-source/repository'; -import { BaseEntity } from '../../../entity/base-entity.model'; -import { Newable } from '../../../types/newable.type'; +import { repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; +import { inject } from '../../../di/inject.function'; +import { defineProvider } from '../../../di/models/di-provider.model'; import { OmitStrict } from '../../../types/omit-strict.type'; +import { ZibriInvoicingPlugin } from '../invoicing.plugin'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../invoicing.tokens'; import { InvoiceAddress } from '../models/invoice-address.model'; import { Invoice } from '../models/invoice.model'; import { InvoicingOptions } from '../models/invoicing-options.model'; @@ -101,47 +101,26 @@ const privateCustomerData: InvoiceAddress = { countryId: 'DE' }; -// eslint-disable-next-line unusedImports/no-unused-vars -const invoiceCalcService: InvoiceCalcService = new InvoiceCalcService(); - -@DataSource() -class DbDataSource extends PostgresDataSource { - options: PostgresOptions = { - host: 'localhost', - username: 'postgres', - password: 'password', - database: 'db', - synchronize: true - }; - entities: Newable[] = [Invoice, NumberInvoices, MigrationEntity]; -} - -let container: StartedTestContainer; -let dataSource: DbDataSource; +let testServer: StartedTestServer; let invoiceRepo: Repository>; -let numberInvoicesRepo: Repository>; let invoiceNumberService: InvoiceNumberService; describe('generateInvoiceNumber', () => { beforeAll(async () => { - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - dataSource = new DbDataSource(); - dataSource.options = { - ...dataSource.options, - port: container.getMappedPort(5432) - }; - await dataSource.init(); - invoiceRepo = dataSource.getRepository(Invoice); - numberInvoicesRepo = dataSource.getRepository(NumberInvoices); - invoiceNumberService = new InvoiceNumberService(invoiceRepo, numberInvoicesRepo, invoicingOptions); + testServer = await startTestServer({ + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Invoice, NumberInvoices] })], + plugins: [...defaultTestServerPlugins, new ZibriInvoicingPlugin()], + providers: [ + ...defaultTestServerProviders, + defineProvider({ token: ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS_INPUT, useValue: invoicingOptions }) + ] + }); + invoiceRepo = inject(repositoryTokenFor(Invoice)); + invoiceNumberService = inject(InvoiceNumberService); }, 15000); afterAll(async () => { - await container.stop(); + await testServer.shutdown(); }); it('should generate the expected number for a company customer', async () => { diff --git a/src/plugin/invoicing/services/invoice-number.service.ts b/src/plugin/invoicing/services/invoice-number.service.ts index 697f1c1..c0f7273 100644 --- a/src/plugin/invoicing/services/invoice-number.service.ts +++ b/src/plugin/invoicing/services/invoice-number.service.ts @@ -1,11 +1,12 @@ -import { ZIBRI_INVOICING_DI_TOKENS } from '../invoicing.tokens'; import { InvoiceNumberServiceInterface } from './invoice-number-service.interface'; import { Repository } from '../../../data-source/repository'; import { Transaction } from '../../../data-source/transaction/transaction.model'; import { InjectRepository } from '../../../di/decorators/inject-repository.decorator'; import { Inject } from '../../../di/decorators/inject.decorator'; +import { Injectable } from '../../../di/decorators/injectable.decorator'; import { ConflictError } from '../../../error-handling/errors/conflict.error'; import { OmitStrict } from '../../../types/omit-strict.type'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../invoicing.tokens'; import { InvoiceAddress } from '../models/invoice-address.model'; import { Invoice } from '../models/invoice.model'; import { type InvoicingOptions } from '../models/invoicing-options.model'; @@ -14,6 +15,7 @@ import { NumberInvoices } from '../models/number-invoices.model'; /** * Default implementation of the invoice number service. */ +@Injectable({ register: 'onUse' }) export class InvoiceNumberService implements InvoiceNumberServiceInterface { constructor( @InjectRepository(Invoice) @@ -23,7 +25,7 @@ export class InvoiceNumberService implements InvoiceNumberServiceInterface >, - @Inject(ZIBRI_INVOICING_DI_TOKENS.OPTIONS) + @Inject(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS) protected readonly options: InvoicingOptions ) {} diff --git a/src/plugin/invoicing/services/invoice-pdf.service.test.ts b/src/plugin/invoicing/services/invoice-pdf.service.test.ts index deaa9f6..6e9018a 100644 --- a/src/plugin/invoicing/services/invoice-pdf.service.test.ts +++ b/src/plugin/invoicing/services/invoice-pdf.service.test.ts @@ -1,28 +1,25 @@ import { WriteStream } from 'node:fs'; import { afterAll, beforeAll, describe, it } from '@jest/globals'; -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import { StartedTestContainer } from 'testcontainers'; -import { XRechnungConformanceService } from './conformance/en16931/x-rechnung-conformance.service'; -import { InvoiceCalcService } from './invoice-calc.service'; import { InvoicePdfService } from './invoice-pdf.service'; -import { POSTGRES_TEST_IMAGE, testFileFolder } from '../../../__testing__/constants'; -import { PostgresDataSource, PostgresOptions } from '../../../data-source/data-sources/postgres-data-source.model'; -import { DataSource } from '../../../data-source/decorators/data-source.decorator'; -import { MigrationEntity } from '../../../data-source/migration/migration-entity.model'; +import { testFileFolder } from '../../../__testing__/constants'; +import { createTestDataSource, defaultTestServerEntities } from '../../../__testing__/test-server/create-test-data-source.function'; +import { defaultTestServerProviders } from '../../../__testing__/test-server/providers'; +import { StartedTestServer, startTestServer } from '../../../__testing__/test-server/start-test-server.function'; import { Repository } from '../../../data-source/repository'; +import { repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; +import { inject } from '../../../di/inject.function'; +import { defineProvider } from '../../../di/models/di-provider.model'; import { PdfDocument } from '../../../document/pdf.utilities'; -import { BaseEntity } from '../../../entity/base-entity.model'; -import { formatDate } from '../../../localization/formatting/format-date.function'; -import { formatPercent } from '../../../localization/formatting/format-percent.function'; -import { formatPrice } from '../../../localization/formatting/format-price.function'; -import { Newable } from '../../../types/newable.type'; import { OmitStrict } from '../../../types/omit-strict.type'; import { FsUtilities } from '../../../utilities/fs.utilities'; import { Ms } from '../../../utilities/ms'; +import { ZibriInvoicingPlugin } from '../invoicing.plugin'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../invoicing.tokens'; import { Invoice } from '../models/invoice.model'; import { InvoicingOptions } from '../models/invoicing-options.model'; +import { NumberInvoices } from '../models/number-invoices.model'; const invoicingOptions: InvoicingOptions = { footerFontSize: 10, @@ -78,52 +75,27 @@ const invoicingOptions: InvoicingOptions = { taxOffice: 'Tax Office Example City' }; -const invoiceCalcService: InvoiceCalcService = new InvoiceCalcService(); -const xRechnungConformanceService: XRechnungConformanceService = new XRechnungConformanceService(invoicingOptions, invoiceCalcService); -const invoicePdfService: InvoicePdfService = new InvoicePdfService( - invoicingOptions, - invoiceCalcService, - [xRechnungConformanceService], - formatDate, - formatPrice, - formatPercent -); - -@DataSource() -class DbDataSource extends PostgresDataSource { - options: PostgresOptions = { - host: 'localhost', - username: 'postgres', - password: 'password', - database: 'db', - synchronize: true - }; - entities: Newable[] = [Invoice, MigrationEntity]; -} - -let container: StartedTestContainer; -let dataSource: DbDataSource; +let server: StartedTestServer; +let invoicePdfService: InvoicePdfService; let repo: Repository>; describe('createInvoicePdf', () => { beforeAll(async () => { await FsUtilities.mkdir(testFileFolder); - container = await new PostgreSqlContainer(POSTGRES_TEST_IMAGE) - .withDatabase('db') - .withUsername('postgres') - .withPassword('password') - .start(); - dataSource = new DbDataSource(); - dataSource.options = { - ...dataSource.options, - port: container.getMappedPort(5432) - }; - await dataSource.init(); - repo = dataSource.getRepository(Invoice); + server = await startTestServer({ + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Invoice, NumberInvoices] })], + plugins: [new ZibriInvoicingPlugin()], + providers: [ + ...defaultTestServerProviders, + defineProvider({ token: ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS_INPUT, useValue: invoicingOptions }) + ] + }); + repo = inject(repositoryTokenFor(Invoice)); + invoicePdfService = inject(InvoicePdfService); }, 15000); afterAll(async () => { - await container.stop(); + await server.shutdown(); }); it('should create the expected result', async () => { diff --git a/src/plugin/invoicing/services/invoice-pdf.service.ts b/src/plugin/invoicing/services/invoice-pdf.service.ts index 26dab28..1979238 100644 --- a/src/plugin/invoicing/services/invoice-pdf.service.ts +++ b/src/plugin/invoicing/services/invoice-pdf.service.ts @@ -2,12 +2,13 @@ import { InvoiceConformanceServiceInterface, InvoiceConformance } from './confor import { type InvoiceCalcServiceInterface } from './invoice-calc-service.interface'; import { InvoicePdfServiceInterface } from './invoice-pdf-service.interface'; import { Inject } from '../../../di/decorators/inject.decorator'; +import { Injectable } from '../../../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; import { PdfContentDefinition, PdfColumnDefinition, PdfDocument, PdfDocumentDefinition, PdfUtilities, PdfTableCellDefinition, PdfContentSize } from '../../../document/pdf.utilities'; import { type FormatDateFn } from '../../../localization/formatting/format-date-fn.model'; import { type FormatPercentFn } from '../../../localization/formatting/format-percent-fn.model'; import { type FormatPriceFn } from '../../../localization/formatting/format-price-fn.model'; -import { ZIBRI_INVOICING_DI_TOKENS } from '../invoicing.tokens'; +import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../invoicing.tokens'; import { Invoice } from '../models/invoice.model'; import { type InvoicingOptions } from '../models/invoicing-options.model'; import { Vat } from '../models/vat.model'; @@ -15,6 +16,7 @@ import { Vat } from '../models/vat.model'; /** * Default implementation of the invoice pdf service. */ +@Injectable({ register: 'onUse' }) export class InvoicePdfService implements InvoicePdfServiceInterface { /** * The definition of the header of the pdf. @@ -30,11 +32,11 @@ export class InvoicePdfService implements InvoicePdfServiceInterface { protected readonly companyLetterheadColumn: PdfColumnDefinition; constructor( - @Inject(ZIBRI_INVOICING_DI_TOKENS.OPTIONS) + @Inject(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.OPTIONS) protected readonly options: InvoicingOptions, - @Inject(ZIBRI_INVOICING_DI_TOKENS.INVOICE_CALC_SERVICE) + @Inject(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.INVOICE_CALC_SERVICE) private readonly invoiceCalcService: InvoiceCalcServiceInterface, - @Inject(ZIBRI_INVOICING_DI_TOKENS.INVOICE_CONFORMANCE_SERVICES) + @Inject(ZIBRI_INVOICING_PLUGIN_DI_TOKENS.INVOICE_CONFORMANCE_SERVICES) private readonly invoiceConformanceServices: InvoiceConformanceServiceInterface[], @Inject(ZIBRI_DI_TOKENS.FORMAT_DATE) private readonly formatDate: FormatDateFn, diff --git a/src/plugin/mailing-list/mailing-list.controller.ts b/src/plugin/mailing-list/mailing-list.controller.ts new file mode 100644 index 0000000..d400163 --- /dev/null +++ b/src/plugin/mailing-list/mailing-list.controller.ts @@ -0,0 +1,114 @@ +import { ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS } from './mailing-list.tokens'; +import { MailingListSubscriber } from './models/mailing-list-subscriber.model'; +import { MailingList } from './models/mailing-list.model'; +import { UpdateMailingListPreferences } from './models/update-mailing-list-preferences.model'; +import { type MailingListServiceInterface } from './services/mailing-list-service.interface'; +import { ZibriApplication } from '../../application'; +import { type MailingListPreferencesPageTemplate } from './models/mailing-list-preferences-page-template.model'; +import { type MailingListSubscribeSuccessPageTemplate } from './models/mailing-list-subscribe-success-page-template.model'; +import { type MailingListUnsubscribeConfirmationPageTemplate } from './models/mailing-list-unsubscribe-confirmation-page-template.model'; +import { Repository } from '../../data-source/repository'; +import { InjectRepository } from '../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../di/decorators/inject.decorator'; +import { NoProviderError } from '../../di/errors/no-provider.error'; +import { inject } from '../../di/inject.function'; +import { OnAppInit } from '../../global/on-app-init.interface'; +import { Response } from '../../open-api/decorators/response.decorator'; +import { HtmlResponse } from '../../parsing/html/html-response.model'; +import { PreactUtilities } from '../../preact/preact.utilities'; +import { Body } from '../../routing/decorators/body.decorator'; +import { Controller } from '../../routing/decorators/controller.decorator'; +import { Get } from '../../routing/decorators/get.decorator'; +import { Param } from '../../routing/decorators/param.decorator'; +import { Patch } from '../../routing/decorators/patch.decorator'; + +@Controller('/mailing-lists', { allowOrphan: true }) +export class MailingListController implements OnAppInit { + + constructor( + @InjectRepository(MailingListSubscriber) + private readonly subscriberRepository: Repository, + @InjectRepository(MailingList) + private readonly mailingListRepository: Repository, + @Inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.MAILING_LIST_SERVICE) + private readonly mailingListService: MailingListServiceInterface, + @Inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.PREFERENCES_PAGE_TEMPLATE) + private readonly PreferencesPage: MailingListPreferencesPageTemplate, + @Inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.UNSUBSCRIBE_CONFIRMATION_PAGE_TEMPLATE) + private readonly UnsubscribeConfirmationPage: MailingListUnsubscribeConfirmationPageTemplate, + @Inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_SUCCESS_PAGE_TEMPLATE) + private readonly SubscribeSuccessPage: MailingListSubscribeSuccessPageTemplate + ) {} + + onAppInit(app: ZibriApplication): void { + if (app.options.controllers.find(c => c === MailingListController)) { + if (inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.PREFERENCES_PAGE_TEMPLATE) == undefined) { + throw new NoProviderError(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.PREFERENCES_PAGE_TEMPLATE, []); + } + if (inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.UNSUBSCRIBE_CONFIRMATION_PAGE_TEMPLATE) == undefined) { + throw new NoProviderError(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.UNSUBSCRIBE_CONFIRMATION_PAGE_TEMPLATE, []); + } + if (inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_SUCCESS_PAGE_TEMPLATE) == undefined) { + throw new NoProviderError(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_SUCCESS_PAGE_TEMPLATE, []); + } + } + } + + @Response.html() + @Get('/:id/subscribe/:token') + async subscribe( + @Param.path('id') + id: string, + @Param.path('token') + token: string + ): Promise { + const mailingList: MailingList = await this.mailingListRepository.findById(id); + + const subscriber: MailingListSubscriber = await this.mailingListService.confirmSubscribeToList(token); + const managePreferencesLink: string = this.mailingListService.getManagePreferencesLink(subscriber.id); + + return PreactUtilities.renderResponse(this.SubscribeSuccessPage, { subscriber, mailingList, managePreferencesLink }); + } + + @Response.html() + @Get('/:id/unsubscribe') + async unsubscribe( + @Param.path('id') + id: string, + @Param.query('subscriberId', { type: 'string', format: 'uuid' }) + subscriberId: string + ): Promise { + const subscriber: MailingListSubscriber = await this.subscriberRepository.findById(subscriberId); + const mailingList: MailingList = await this.mailingListRepository.findById(id); + + await this.mailingListService.unsubscribeFromList(id, subscriberId); + const managePreferencesLink: string = this.mailingListService.getManagePreferencesLink(subscriberId); + + return PreactUtilities.renderResponse(this.UnsubscribeConfirmationPage, { subscriber, mailingList, managePreferencesLink }); + } + + @Response.html() + @Get('/preferences') + async preferences( + @Param.query('subscriberId', { type: 'string', format: 'uuid' }) + subscriberId: string + ): Promise { + const subscriber: MailingListSubscriber = await this.subscriberRepository.findById(subscriberId); + const mailingLists: MailingList[] = await this.mailingListRepository.findAll(); + const managePreferencesApiUrl: string = this.mailingListService.getManagePreferencesLink(subscriberId); + + return PreactUtilities.renderResponse(this.PreferencesPage, { subscriber, mailingLists, managePreferencesApiUrl }); + } + + @Response.empty() + @Patch('/preferences') + async changePreferences( + @Param.query('subscriberId', { type: 'string', format: 'uuid' }) + subscriberId: string, + @Body(UpdateMailingListPreferences) + body: UpdateMailingListPreferences + ): Promise { + const mailingLists: MailingList[] = await this.mailingListRepository.findAll({ where: { id: { oneOf: body.mailingListIds } } }); + await this.subscriberRepository.updateById(subscriberId, { mailingLists }); + } +} \ No newline at end of file diff --git a/src/plugin/mailing-list/mailing-list.plugin.ts b/src/plugin/mailing-list/mailing-list.plugin.ts new file mode 100644 index 0000000..7b631ff --- /dev/null +++ b/src/plugin/mailing-list/mailing-list.plugin.ts @@ -0,0 +1,37 @@ +import { ZibriApplication } from '../../application'; +import { DiProvider } from '../../di/models/di-provider.model'; +import { DiTokenProviderRecord, providersFromTokenRecord } from '../../di/models/di-token.model'; +import { Ms } from '../../utilities/ms'; +import { validateEntitiesRegistered } from '../../utilities/validate-entities-registered.function'; +import { validateTokensRegistered } from '../../utilities/validate-tokens-registered.function'; +import { ZibriPlugin } from '../plugin.model'; +import { ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS } from './mailing-list.tokens'; +import { MailingListSubscriber } from './models/mailing-list-subscriber.model'; +import { MailingListSubscriptionConfirmationToken } from './models/mailing-list-subscription-confirmation-token.model'; +import { MailingList } from './models/mailing-list.model'; +import { MailingListService } from './services/mailing-list.service'; + +/** + * Plugin that includes everything for handling mailing lists. + */ +export class ZibriMailingListPlugin extends ZibriPlugin { + private readonly defaultDiProviders: DiTokenProviderRecord = { + MAILING_LIST_SERVICE: { useClass: MailingListService }, + CONFIRMATION_TOKEN_EXPIRES_IN_MS: { useFactory: () => Ms.DAY }, + SUBSCRIBE_CONFIRMATION_EMAIL_TEMPLATE: { useFactory: () => undefined }, + PREFERENCES_PAGE_TEMPLATE: { useFactory: () => undefined }, + UNSUBSCRIBE_CONFIRMATION_PAGE_TEMPLATE: { useFactory: () => undefined }, + SUBSCRIBE_SUCCESS_PAGE_TEMPLATE: { useFactory: () => undefined }, + BASE_EMAIL_TEMPLATE: { useFactory: () => undefined } + }; + + // eslint-disable-next-line jsdoc/require-jsdoc + providers: DiProvider[] = providersFromTokenRecord(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS, this.defaultDiProviders); + + // eslint-disable-next-line jsdoc/require-jsdoc + validate(app: ZibriApplication): void { + // eslint-disable-next-line stylistic/max-len + validateEntitiesRegistered(this.constructor.name, app, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken); + validateTokensRegistered(this.constructor.name, ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS); + } +} \ No newline at end of file diff --git a/src/plugin/mailing-list/mailing-list.tokens.ts b/src/plugin/mailing-list/mailing-list.tokens.ts new file mode 100644 index 0000000..daa3f8f --- /dev/null +++ b/src/plugin/mailing-list/mailing-list.tokens.ts @@ -0,0 +1,37 @@ +import { MailingListBaseEmailTemplate } from './models/mailing-list-base-email-template.model'; +import { MailingListPreferencesPageTemplate } from './models/mailing-list-preferences-page-template.model'; +import { MailingListSubscribeConfirmationEmailTemplate } from './models/mailing-list-subscribe-confirmation-email-template.model'; +import { MailingListSubscribeSuccessPageTemplate } from './models/mailing-list-subscribe-success-page-template.model'; +import { MailingListUnsubscribeConfirmationPageTemplate } from './models/mailing-list-unsubscribe-confirmation-page-template.model'; +import { MailingListServiceInterface } from './services/mailing-list-service.interface'; +import { TokenRecord } from '../../di/models/di-token.model'; +import { InjectionToken } from '../../di/models/injection-token.model'; + +/** + * The dependency injection tokens used by the ZibriMailingListPlugin. + */ +// eslint-disable-next-line typescript/typedef +export const ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS = { + MAILING_LIST_SERVICE: mailingListToken('zi.mailing_list.mailing_list_service'), + CONFIRMATION_TOKEN_EXPIRES_IN_MS: mailingListToken('zi.mailing_list.subscription_confirmation_token_expires_in_ms'), + SUBSCRIBE_CONFIRMATION_EMAIL_TEMPLATE: mailingListToken( + 'zi.mailing_list.subscribe_confirmation_email_template' + ), + PREFERENCES_PAGE_TEMPLATE: mailingListToken( + 'zi.mailing_list.preferences_page_template' + ), + UNSUBSCRIBE_CONFIRMATION_PAGE_TEMPLATE: mailingListToken( + 'zi.mailing_list.unsubscribe_confirmation_page_template' + ), + SUBSCRIBE_SUCCESS_PAGE_TEMPLATE: mailingListToken( + 'zi.mailing_list.subscribe_success_page_template' + ), + BASE_EMAIL_TEMPLATE: mailingListToken( + 'zi.mailing_list.base_email_template' + ) +} as const satisfies TokenRecord; + +// eslint-disable-next-line jsdoc/require-jsdoc +function mailingListToken(k: `zi.mailing_list.${string}`): InjectionToken { + return new InjectionToken(k); +} \ No newline at end of file diff --git a/src/plugin/mailing-list/models/mailing-list-base-email-template.model.ts b/src/plugin/mailing-list/models/mailing-list-base-email-template.model.ts new file mode 100644 index 0000000..fd264bc --- /dev/null +++ b/src/plugin/mailing-list/models/mailing-list-base-email-template.model.ts @@ -0,0 +1,36 @@ +import { MailingListSubscriber } from './mailing-list-subscriber.model'; +import { MailingList } from './mailing-list.model'; +import { PreactEmailComponent } from '../../../preact/preact-email-component.model'; + +/** + * Data about the mailing list that this email belongs to. + */ +export type MailingListTemplateData = { + /** + * The subscriber if the email belongs to a mailing list. + */ + subscriber: MailingListSubscriber, + /** + * The mailing list that the email belongs to, if any. + */ + list: MailingList +}; + +/** + * Properties of a mailing list base email. + */ +type MailingListBaseEmailTemplateProps = MailingListTemplateData & { + /** + * The title of the email. + */ + title: string, + /** + * The html content. + */ + html: string +}; + +/** + * Definition for a mailing list base email template. + */ +export type MailingListBaseEmailTemplate = PreactEmailComponent; \ No newline at end of file diff --git a/src/plugin/mailing-list/models/mailing-list-preferences-page-template.model.ts b/src/plugin/mailing-list/models/mailing-list-preferences-page-template.model.ts new file mode 100644 index 0000000..7e19254 --- /dev/null +++ b/src/plugin/mailing-list/models/mailing-list-preferences-page-template.model.ts @@ -0,0 +1,26 @@ +import { MailingListSubscriber } from './mailing-list-subscriber.model'; +import { MailingList } from './mailing-list.model'; +import { PreactComponent } from '../../../preact/preact-component.model'; + +/** + * Properties of a mailing list preferences page component. + */ +type MailingListPreferencesPageTemplateProps = { + /** + * The subscriber to render the page for. + */ + subscriber: MailingListSubscriber, + /** + * All mailing lists that can be managed. + */ + mailingLists: MailingList[], + /** + * The api url to update/patch the preferences. + */ + managePreferencesApiUrl: string +}; + +/** + * Definition for a mailing list preferences page template. + */ +export type MailingListPreferencesPageTemplate = PreactComponent; \ No newline at end of file diff --git a/src/plugin/mailing-list/models/mailing-list-subscribe-confirmation-email-template.model.ts b/src/plugin/mailing-list/models/mailing-list-subscribe-confirmation-email-template.model.ts new file mode 100644 index 0000000..0b94bc0 --- /dev/null +++ b/src/plugin/mailing-list/models/mailing-list-subscribe-confirmation-email-template.model.ts @@ -0,0 +1,26 @@ +import { MailingList } from './mailing-list.model'; +import { PreactEmailComponent } from '../../../preact/preact-email-component.model'; +import { MailingListSubscriberCreateData } from '../services/mailing-list-service.interface'; + +/** + * Properties of a mailing list subscribe confirmation email. + */ +type MailingListSubscribeConfirmationEmailTemplateProps = { + /** + * The (to be created) subscriber that needs to confirm. + */ + subscriber: MailingListSubscriberCreateData, + /** + * The link to confirm the email. + */ + confirmEmailLink: string, + /** + * The mailing list for wich this confirmation has been triggered. + */ + mailingList: MailingList +}; + +/** + * Definition for a mailing list subscribe confirmation email template. + */ +export type MailingListSubscribeConfirmationEmailTemplate = PreactEmailComponent; \ No newline at end of file diff --git a/src/plugin/mailing-list/models/mailing-list-subscribe-success-page-template.model.ts b/src/plugin/mailing-list/models/mailing-list-subscribe-success-page-template.model.ts new file mode 100644 index 0000000..8e58a4a --- /dev/null +++ b/src/plugin/mailing-list/models/mailing-list-subscribe-success-page-template.model.ts @@ -0,0 +1,26 @@ +import { MailingListSubscriber } from './mailing-list-subscriber.model'; +import { MailingList } from './mailing-list.model'; +import { PreactComponent } from '../../../preact/preact-component.model'; + +/** + * Properties of a mailing list subscribe success page. + */ +type MailingListSubscribeSuccessPageTemplateProps = { + /** + * The mailing list for which the subscription was successful. + */ + mailingList: MailingList, + /** + * The subscriber that confirmed the subscription. + */ + subscriber: MailingListSubscriber, + /** + * The link to the manage preferences page. + */ + managePreferencesLink: string +}; + +/** + * Definition for a mailing list subscribe success page template. + */ +export type MailingListSubscribeSuccessPageTemplate = PreactComponent; \ No newline at end of file diff --git a/src/email/mailing-list/models/mailing-list-subscriber.model.ts b/src/plugin/mailing-list/models/mailing-list-subscriber.model.ts similarity index 96% rename from src/email/mailing-list/models/mailing-list-subscriber.model.ts rename to src/plugin/mailing-list/models/mailing-list-subscriber.model.ts index f6a9ca3..4eb0322 100644 --- a/src/email/mailing-list/models/mailing-list-subscriber.model.ts +++ b/src/plugin/mailing-list/models/mailing-list-subscriber.model.ts @@ -6,7 +6,7 @@ import { Property } from '../../../entity/decorators/property.decorator'; /** * Defines a subscriber to a single or multiple mailing lists. */ -@Entity() +@Entity({ allowOrphan: true }) export class MailingListSubscriber extends BaseEntity { /** * The optional name of the subscriber. diff --git a/src/email/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts b/src/plugin/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts similarity index 97% rename from src/email/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts rename to src/plugin/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts index 1ef1ad5..9078288 100644 --- a/src/email/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts +++ b/src/plugin/mailing-list/models/mailing-list-subscription-confirmation-token.model.ts @@ -6,7 +6,7 @@ import { OmitClass } from '../../../entity/omit-class.model'; /** * A short lived token used to confirm a mailing list subscription. */ -@Entity() +@Entity({ allowOrphan: true }) export class MailingListSubscriptionConfirmationToken extends BaseEntity { /** * The expiration date of the confirmation token. diff --git a/src/plugin/mailing-list/models/mailing-list-unsubscribe-confirmation-page-template.model.ts b/src/plugin/mailing-list/models/mailing-list-unsubscribe-confirmation-page-template.model.ts new file mode 100644 index 0000000..38e4151 --- /dev/null +++ b/src/plugin/mailing-list/models/mailing-list-unsubscribe-confirmation-page-template.model.ts @@ -0,0 +1,26 @@ +import { MailingListSubscriber } from './mailing-list-subscriber.model'; +import { MailingList } from './mailing-list.model'; +import { PreactComponent } from '../../../preact/preact-component.model'; + +/** + * Properties of a mailing list unsubscribe confirmation page component. + */ +type MailingListUnsubscribeConfirmationPageTemplateProps = { + /** + * The subscriber that just unsubscribed. + */ + subscriber: MailingListSubscriber, + /** + * The mailing list that was just unsubscribed from. + */ + mailingList: MailingList, + /** + * The link to the manage preferences page. + */ + managePreferencesLink: string +}; + +/** + * Definition for a mailing list unsubscribe confirmation page template. + */ +export type MailingListUnsubscribeConfirmationPageTemplate = PreactComponent; \ No newline at end of file diff --git a/src/email/mailing-list/models/mailing-list.model.ts b/src/plugin/mailing-list/models/mailing-list.model.ts similarity index 96% rename from src/email/mailing-list/models/mailing-list.model.ts rename to src/plugin/mailing-list/models/mailing-list.model.ts index eaba84d..2aa9f5f 100644 --- a/src/email/mailing-list/models/mailing-list.model.ts +++ b/src/plugin/mailing-list/models/mailing-list.model.ts @@ -6,7 +6,7 @@ import { Property } from '../../../entity/decorators/property.decorator'; /** * A mailing list like a newsletter that people can easily subscribe and unsubscribe to. */ -@Entity() +@Entity({ allowOrphan: true }) export class MailingList extends BaseEntity { /** * The name of the mailing list. diff --git a/src/email/mailing-list/models/update-mailing-list-preferences.model.ts b/src/plugin/mailing-list/models/update-mailing-list-preferences.model.ts similarity index 100% rename from src/email/mailing-list/models/update-mailing-list-preferences.model.ts rename to src/plugin/mailing-list/models/update-mailing-list-preferences.model.ts diff --git a/src/plugin/mailing-list/services/mailing-list-service.interface.ts b/src/plugin/mailing-list/services/mailing-list-service.interface.ts new file mode 100644 index 0000000..7cc6b03 --- /dev/null +++ b/src/plugin/mailing-list/services/mailing-list-service.interface.ts @@ -0,0 +1,75 @@ +import { QueueEmailData } from '../../../email/models/create-email-data.model'; +import { OmitClass } from '../../../entity/omit-class.model'; +import { Route } from '../../../routing/controller-route-configuration.model'; +import { OmitStrict } from '../../../types/omit-strict.type'; +import { MailingListTemplateData } from '../models/mailing-list-base-email-template.model'; +import { MailingListSubscriber } from '../models/mailing-list-subscriber.model'; + +/** + * The data required to queue a new mailing list email. + */ +export type MailingListQueueEmailData = OmitStrict< + QueueEmailData, + 'bcc' | 'cc' | 'recipients' | 'userId' | 'priority' | 'html' +> & { + /** + * The title used in the template. Defaults to the subject. + */ + title?: string, + /** + * The template. Can be a template string, html or anything else, like a TSX component function. + */ + template: T, + /** + * A function that compiles the given template. + */ + compile: (template: T, data: MailingListTemplateData) => string | Promise +}; + +/** + * The required data to create a new mailing list subscriber. + */ +export class MailingListSubscriberCreateData extends OmitClass(MailingListSubscriber, ['id']) {} + +/** + * Interface for a mailing list service. + */ +export interface MailingListServiceInterface { + /** + * The base route for everything regarding mailing lists. + */ + readonly mailingListBaseRoute: Route, + /** + * Queues a new email for the mailing list with the provided id. + */ + queueEmailForList: (listId: string, data: MailingListQueueEmailData) => Promise, + /** + * Requests for a new subscriber to the be added to the mailing list with the provided id. + * This should initialize a two step process required by the GDPR. + */ + requestSubscribeToList: ( + listId: string, + subscriber: MailingListSubscriberCreateData, + emailData: OmitStrict, 'template' | 'compile'> + ) => Promise, + /** + * Confirms that a new subscriber is added to the mailing list. + */ + confirmSubscribeToList: (confirmationTokenValue: string) => Promise, + /** + * Removes a subscriber with the given id from the mailing list with the provided id. + */ + unsubscribeFromList: (listId: string, subscriberId: string) => Promise, + /** + * Gets the subscribe confirmation link for the list with the given id and the confirmationToken. + */ + getSubscribeConfirmationLink: (listId: string, confirmationToken: string) => string, + /** + * Gets the unsubscribe link for the list with the given id and the subscriber with the given id. + */ + getUnsubscribeLink: (listId: string, subscriberId: string) => string, + /** + * Gets the link to the manage preferences page for the subscriber with the given id. + */ + getManagePreferencesLink: (subscriberId: string) => string +} \ No newline at end of file diff --git a/src/plugin/mailing-list/services/mailing-list.service.ts b/src/plugin/mailing-list/services/mailing-list.service.ts new file mode 100644 index 0000000..d37477a --- /dev/null +++ b/src/plugin/mailing-list/services/mailing-list.service.ts @@ -0,0 +1,192 @@ +import { randomBytes } from 'crypto'; + +import { MailingListSubscriberCreateData, MailingListQueueEmailData, MailingListServiceInterface } from './mailing-list-service.interface'; +import { type AssetServiceInterface } from '../../../assets/asset-service.interface'; +import { Repository } from '../../../data-source/repository'; +import { InjectRepository } from '../../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../../di/decorators/inject.decorator'; +import { Injectable } from '../../../di/decorators/injectable.decorator'; +import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; +import { inject } from '../../../di/inject.function'; +import { type EmailServiceInterface } from '../../../email/email-service.interface'; +import { EmailPriority } from '../../../email/models/email-priority.enum'; +import { GlobalRegistry } from '../../../global/global-registry'; +import { OnAppInit } from '../../../global/on-app-init.interface'; +import { PreactUtilities } from '../../../preact/preact.utilities'; +import { Route } from '../../../routing/controller-route-configuration.model'; +import { OmitStrict } from '../../../types/omit-strict.type'; +import { PromiseUtilities } from '../../../utilities/promise.utilities'; +import { ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS } from '../mailing-list.tokens'; +import { type MailingListBaseEmailTemplate } from '../models/mailing-list-base-email-template.model'; +import { type MailingListSubscribeConfirmationEmailTemplate } from '../models/mailing-list-subscribe-confirmation-email-template.model'; +import { MailingListSubscriber } from '../models/mailing-list-subscriber.model'; +import { MailingListSubscriptionConfirmationToken, MailingListSubscriptionConfirmationTokenCreateData } from '../models/mailing-list-subscription-confirmation-token.model'; +import { MailingList } from '../models/mailing-list.model'; + +/** + * Default mailing list service implementation of Zibri. + */ +@Injectable({ register: 'onUse' }) +export class MailingListService implements MailingListServiceInterface, OnAppInit { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly mailingListBaseRoute: Route = '/mailing-lists'; + + constructor( + @Inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE) + protected readonly emailService: EmailServiceInterface, + @Inject(ZIBRI_DI_TOKENS.ASSET_SERVICE) + protected readonly assetService: AssetServiceInterface, + @Inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.CONFIRMATION_TOKEN_EXPIRES_IN_MS) + protected readonly mailingListSubscriptionConfirmationTokenExpiresInMs: number, + @InjectRepository(MailingList) + protected readonly mailingListRepository: Repository, + @InjectRepository(MailingListSubscriber) + protected readonly subscriberRepository: Repository, + @InjectRepository(MailingListSubscriptionConfirmationToken) + protected readonly confirmationTokenRepository: Repository< + MailingListSubscriptionConfirmationToken, + MailingListSubscriptionConfirmationTokenCreateData + >, + @Inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_CONFIRMATION_EMAIL_TEMPLATE) + protected readonly MailingListSubscribeConfirmationEmail: MailingListSubscribeConfirmationEmailTemplate, + @Inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.BASE_EMAIL_TEMPLATE) + protected readonly MailingListBaseEmail: MailingListBaseEmailTemplate + ) {} + + // eslint-disable-next-line jsdoc/require-jsdoc + onAppInit(): void { + if (inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_CONFIRMATION_EMAIL_TEMPLATE) == undefined) { + throw new Error([ + 'The builtin MailingListService requires that a value for', + 'ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.SUBSCRIBE_CONFIRMATION_EMAIL_TEMPLATE is provided.' + ].join(' ')); + } + if (inject(ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.BASE_EMAIL_TEMPLATE) == undefined) { + throw new Error([ + 'The builtin MailingListService requires that a value for', + 'ZIBRI_MAILING_LIST_PLUGIN_DI_TOKENS.BASE_EMAIL_TEMPLATE is provided.' + ].join(' ')); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + getSubscribeConfirmationLink(listId: string, confirmationToken: string): string { + const baseUrl: string = GlobalRegistry.getAppData('baseUrl') ?? ''; + return `${baseUrl}${this.mailingListBaseRoute}/${listId}/subscribe/${confirmationToken}`; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + getUnsubscribeLink(listId: string, subscriberId: string): string { + const baseUrl: string = GlobalRegistry.getAppData('baseUrl') ?? ''; + return `${baseUrl}${this.mailingListBaseRoute}/${listId}/unsubscribe?subscriberId=${subscriberId}`; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + getManagePreferencesLink(subscriberId: string): string { + const baseUrl: string = GlobalRegistry.getAppData('baseUrl') ?? ''; + return `${baseUrl}${this.mailingListBaseRoute}/preferences?subscriberId=${subscriberId}`; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async queueEmailForList(listId: string, data: MailingListQueueEmailData): Promise { + const list: MailingList = await this.mailingListRepository.findById(listId); + await PromiseUtilities.allChunked( + list.subscribers, + async subscriber => { + const content: string = await data.compile(data.template, { subscriber, list }); + + const html: string = PreactUtilities.renderEmail( + this.MailingListBaseEmail, + { + list, + subscriber, + html: content, + title: data.title ?? data.subject + } + ); + + await this.emailService.queue({ + html, + priority: EmailPriority.LOW, + recipients: [subscriber.email], + persist: false, + ...data + }); + } + ); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async requestSubscribeToList( + listId: string, + subscriber: MailingListSubscriberCreateData, + emailData: OmitStrict, 'template' | 'compile'> + ): Promise { + + const foundSubscriber: MailingListSubscriber | undefined = await this.subscriberRepository.findOne( + { where: { email: subscriber.email } }, + false + ); + const list: MailingList = await this.mailingListRepository.findById(listId); + if (foundSubscriber) { + await this.subscriberRepository.updateById(foundSubscriber.id, { mailingLists: [...foundSubscriber.mailingLists, list] }); + return; + } + + const token: MailingListSubscriptionConfirmationToken = await this.confirmationTokenRepository.create({ + email: subscriber.email, + value: randomBytes(16).toString('hex'), + expirationDate: new Date(Date.now() + this.mailingListSubscriptionConfirmationTokenExpiresInMs), + name: subscriber.name, + listId + }); + + const html: string = PreactUtilities.renderEmail( + this.MailingListSubscribeConfirmationEmail, + { + subscriber, + mailingList: list, + confirmEmailLink: this.getSubscribeConfirmationLink(listId, token.value) + } + ); + + await this.emailService.queue({ ...emailData, html, recipients: [subscriber.email] }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async confirmSubscribeToList(confirmationTokenValue: string): Promise { + const foundToken: MailingListSubscriptionConfirmationToken = await this.confirmationTokenRepository.findOne( + { where: { value: confirmationTokenValue } } + ); + const mailingList: MailingList = await this.mailingListRepository.findById(foundToken.listId); + const foundSubscriber: MailingListSubscriber | undefined = await this.subscriberRepository.findOne( + { where: { email: foundToken.email }, relations: ['mailingLists'] }, + false + ); + if (!foundSubscriber) { + return await this.subscriberRepository.create({ email: foundToken.email, name: foundToken.name, mailingLists: [mailingList] }); + } + if (foundSubscriber.mailingLists.find(l => l.id === mailingList.id)) { + // already subscribed, do nothing + return foundSubscriber; + } + return await this.subscriberRepository.updateById( + foundSubscriber.id, + { mailingLists: [...foundSubscriber.mailingLists, mailingList] } + ); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async unsubscribeFromList(listId: string, subscriberId: string): Promise { + await this.mailingListRepository.findById(listId); + const foundSubscriber: MailingListSubscriber = await this.subscriberRepository.findOne( + { where: { id: subscriberId }, relations: ['mailingLists'] } + ); + if (!foundSubscriber.mailingLists.find(l => l.id === listId)) { + // Is not subscribed to the mailing list. + return; + } + const newMailingLists: MailingList[] = foundSubscriber.mailingLists.filter(l => l.id !== listId); + await this.subscriberRepository.updateById(subscriberId, { mailingLists: newMailingLists }); + } +} \ No newline at end of file diff --git a/src/plugin/payment/models/payment-plugin-options-input.model.ts b/src/plugin/payment/models/payment-plugin-options-input.model.ts index 23df4b1..b6c73ea 100644 --- a/src/plugin/payment/models/payment-plugin-options-input.model.ts +++ b/src/plugin/payment/models/payment-plugin-options-input.model.ts @@ -1,23 +1,11 @@ import { PaymentMethod } from './payment-method.model'; import { PaymentPluginOptions } from './payment-plugin-options.model'; -import { AnyObject } from '../../../entity/any-object.model'; -import { PaymentProviderInterface } from '../providers/payment-provider.interface'; +import { AnyPaymentProviderInterface } from '../providers/payment-provider.interface'; /** * Input for configuring the payment plugin. */ export type PaymentPluginOptionsInput< M extends readonly PaymentMethod[], - P extends readonly PaymentProviderInterface< - M[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[] > = PaymentPluginOptions; \ No newline at end of file diff --git a/src/plugin/payment/models/payment-plugin-options.model.ts b/src/plugin/payment/models/payment-plugin-options.model.ts index 3cd33b0..0d9c091 100644 --- a/src/plugin/payment/models/payment-plugin-options.model.ts +++ b/src/plugin/payment/models/payment-plugin-options.model.ts @@ -1,24 +1,12 @@ import { PaymentMethod } from './payment-method.model'; -import { AnyObject } from '../../../entity/any-object.model'; -import { PaymentProviderInterface } from '../providers/payment-provider.interface'; +import { AnyPaymentProviderInterface } from '../providers/payment-provider.interface'; /** * Configuration for the payment plugin. */ export type PaymentPluginOptions< M extends readonly PaymentMethod[], - P extends readonly PaymentProviderInterface< - M[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[] > = { /** * The payment methods to use (credit card, bank transfer etc.). diff --git a/src/plugin/payment/models/payment.model.ts b/src/plugin/payment/models/payment.model.ts index f9462e8..e95fbf7 100644 --- a/src/plugin/payment/models/payment.model.ts +++ b/src/plugin/payment/models/payment.model.ts @@ -9,13 +9,18 @@ import { type CurrencyCode } from '../../../localization/models/currency-code.mo /** * Entity for an payment that has been made. */ -@Entity() +@Entity({ allowOrphan: true }) export class Payment extends BaseEntity { /** * The transactionId, should be provided eg. From the client side of a checkout to prevent duplicate payments. */ @Property.string({ format: 'uuid', unique: true }) transactionId!: string; + /** + * The createdAt date. Is set to now by default. + */ + @Property.date({ default: () => new Date() }) + createdAt!: Date; /** * The status of the payment. */ diff --git a/src/plugin/payment/payment.plugin.ts b/src/plugin/payment/payment.plugin.ts index 080f4e7..79b120f 100644 --- a/src/plugin/payment/payment.plugin.ts +++ b/src/plugin/payment/payment.plugin.ts @@ -2,23 +2,25 @@ import { PaymentMethod } from './models/payment-method.model'; import { PaymentPluginOptionsInput } from './models/payment-plugin-options-input.model'; import { PaymentPluginOptions } from './models/payment-plugin-options.model'; import { Payment } from './models/payment.model'; -import { DefaultPaymentProviderArray, ZIBRI_PAYMENT_DI_TOKENS } from './payment.tokens'; +import { DefaultPaymentProviderArray, ZIBRI_PAYMENT_PLUGIN_DI_TOKENS } from './payment.tokens'; +import { ZibriApplication } from '../../application'; import { NoProviderError } from '../../di/errors/no-provider.error'; import { inject } from '../../di/inject.function'; import { DiProvider } from '../../di/models/di-provider.model'; import { DiTokenProviderRecord, providersFromTokenRecord } from '../../di/models/di-token.model'; import { validateEntitiesRegistered } from '../../utilities/validate-entities-registered.function'; import { ZibriPlugin } from '../plugin.model'; +import { PaymentService } from './services/payment.service'; +import { validateTokensRegistered } from '../../utilities/validate-tokens-registered.function'; /** * Plugin that includes everything for handling payments. */ export class ZibriPaymentPlugin extends ZibriPlugin { - - private readonly defaultDiProviders: DiTokenProviderRecord = { + private readonly defaultDiProviders: DiTokenProviderRecord = { OPTIONS_INPUT: { useFactory: () => { - throw new NoProviderError(ZIBRI_PAYMENT_DI_TOKENS.OPTIONS_INPUT, []); + throw new NoProviderError(ZIBRI_PAYMENT_PLUGIN_DI_TOKENS.OPTIONS_INPUT, []); } }, OPTIONS: { @@ -26,30 +28,48 @@ export class ZibriPaymentPlugin extends ZibriPlugin { const input: PaymentPluginOptionsInput< PaymentMethod[], DefaultPaymentProviderArray - > = inject(ZIBRI_PAYMENT_DI_TOKENS.OPTIONS_INPUT); + > = inject(ZIBRI_PAYMENT_PLUGIN_DI_TOKENS.OPTIONS_INPUT); const res: PaymentPluginOptions = { ...input }; return res; } + }, + PAYMENT_SERVICE: { + useClass: PaymentService } }; // eslint-disable-next-line jsdoc/require-jsdoc - providers: DiProvider[] = providersFromTokenRecord(ZIBRI_PAYMENT_DI_TOKENS, this.defaultDiProviders); + providers: DiProvider[] = providersFromTokenRecord(ZIBRI_PAYMENT_PLUGIN_DI_TOKENS, this.defaultDiProviders); // eslint-disable-next-line jsdoc/require-jsdoc - validate(): void { - validateEntitiesRegistered(this.constructor.name, Payment); - const options: PaymentPluginOptions = inject(ZIBRI_PAYMENT_DI_TOKENS.OPTIONS); + validate(app: ZibriApplication): void { + validateEntitiesRegistered(this.constructor.name, app, Payment); + validateTokensRegistered(this.constructor.name, ZIBRI_PAYMENT_PLUGIN_DI_TOKENS); + const options: PaymentPluginOptions = inject(ZIBRI_PAYMENT_PLUGIN_DI_TOKENS.OPTIONS); const allNames: string[] = options.paymentProviders.map(p => p.name); - if (allNames.length >= [...new Set(allNames)].length) { - throw new Error('There are duplicate payment provider names'); + const duplicateNames: string[] = allNames.filter(name => allNames.filter(n => name === n).length > 1); + if (duplicateNames.length) { + throw new Error( + [ + 'There are duplicate payment provider names:', + [...new Set(duplicateNames)].map(n => `- ${n}`) + ].join('\n') + ); } - if (options.paymentMethods.length >= [...new Set(options.paymentMethods)].length) { - throw new Error('There are duplicate payment methods'); + const duplicatePaymentMethods: string[] = options.paymentMethods.filter( + method => options.paymentMethods.filter(m => method === m).length > 1 + ); + if (duplicatePaymentMethods.length) { + throw new Error( + [ + 'There are duplicate payment methods:', + [...new Set(duplicatePaymentMethods)].map(m => `- ${m}`) + ].join('\n') + ); } } } \ No newline at end of file diff --git a/src/plugin/payment/payment.tokens.ts b/src/plugin/payment/payment.tokens.ts index e2ad3a9..bf35ee4 100644 --- a/src/plugin/payment/payment.tokens.ts +++ b/src/plugin/payment/payment.tokens.ts @@ -1,29 +1,33 @@ /* eslint-disable jsdoc/require-jsdoc */ import { PaymentMethod } from './models/payment-method.model'; import { PaymentPluginOptionsInput } from './models/payment-plugin-options-input.model'; -import { PaymentProviderInterface } from './providers/payment-provider.interface'; +import { PaymentPluginOptions } from './models/payment-plugin-options.model'; +import { AnyPaymentProviderInterface } from './providers/payment-provider.interface'; +import { PaymentServiceInterface } from './services/payment-service.interface'; import { TokenRecord } from '../../di/models/di-token.model'; import { InjectionToken } from '../../di/models/injection-token.model'; -import { AnyObject } from '../../entity/any-object.model'; -export type DefaultPaymentProviderArray = PaymentProviderInterface< - PaymentMethod[], - Record, - Record, - Record, - Record, - Record, - Record, - Record ->[]; +export type DefaultPaymentProviderArray = readonly AnyPaymentProviderInterface[]; /** * The dependency injection tokens used by the ZibriPaymentPlugin. */ // eslint-disable-next-line typescript/typedef -export const ZIBRI_PAYMENT_DI_TOKENS = { +export const ZIBRI_PAYMENT_PLUGIN_DI_TOKENS = { OPTIONS_INPUT: paymentToken>('zi.payment.options_input'), - OPTIONS: paymentToken>('zi.payment.options') + OPTIONS: paymentToken>('zi.payment.options'), + /** + * Inject with explicit type parameters to get full type safety. + * @example + * inject< + * PaymentServiceInterface< + * [KnownPaymentMethod.PAY_PAL], + * [PayPalPaymentProvider] + * > + * >(ZIBRI_PAYMENT_DI_TOKENS.PAYMENT_SERVICE) + */ + // eslint-disable-next-line typescript/no-explicit-any + PAYMENT_SERVICE: paymentToken>('zi.payment.payment_service') } as const satisfies TokenRecord; function paymentToken(k: `zi.payment.${string}`): InjectionToken { diff --git a/src/plugin/payment/providers/pay-pal/pay-pal-client.ts b/src/plugin/payment/providers/pay-pal/pay-pal-client.ts index 2820ea1..9153642 100644 --- a/src/plugin/payment/providers/pay-pal/pay-pal-client.ts +++ b/src/plugin/payment/providers/pay-pal/pay-pal-client.ts @@ -1,4 +1,4 @@ -import { PayPalProviderOptions } from './pay-pal.payment-provider'; +import { PayPalPaymentProviderOptions } from './pay-pal.payment-provider'; import { ZIBRI_DI_TOKENS } from '../../../../di/default/zibri-di-tokens.default'; import { inject } from '../../../../di/inject.function'; import { Property } from '../../../../entity/decorators/property.decorator'; @@ -317,7 +317,7 @@ export class PayPalClient { private token?: PayPalAccessToken; private readonly http: HttpClientInterface; - constructor(private readonly options: PayPalProviderOptions) { + constructor(private readonly options: PayPalPaymentProviderOptions) { this.baseUrl = options.env === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; this.http = inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); } @@ -338,7 +338,7 @@ export class PayPalClient { [KnownHeader.AUTHORIZATION]: `Basic ${auth}`, [KnownHeader.CONTENT_TYPE]: MimeType.FORM_URL_ENCODED }, - responseBody: AuthResp + responseBody: { type: MimeType.JSON, modelClass: AuthResp, allowAdditionalProperties: true } })).body; this.token = { @@ -426,6 +426,24 @@ export class PayPalClient { })).body; } + /** + * Authorizes an AUTHORIZE-intent order that the buyer has already approved. + * Must be called before confirmPaymentReservation can read the authorization id. + * @param orderId - The id of the approved order to authorize. + * @returns The authorized order, including purchase_units[].payments.authorizations. + */ + async authorizeOrder(orderId: string): Promise { + const token: string = await this.getAccessToken(); + const url: string = `${this.baseUrl}/v2/checkout/orders/${encodeURIComponent(orderId)}/authorize`; + + return (await this.http.post(url, {}, { + responseBody: GetOrderResp, + headers: { + Authorization: `Bearer ${token}` + } + })).body; + } + /** * Cancels the existing authorization with the given id. * @param authorizationId - The id of the authorization to cancel. diff --git a/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts new file mode 100644 index 0000000..91efde1 --- /dev/null +++ b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.test.ts @@ -0,0 +1,280 @@ +import assert from 'node:assert'; + +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +// eslint-disable-next-line eslintImport/no-unassigned-import +import 'dotenv/config'; + +import { PayPalPaymentData, PayPalPaymentProvider, PayPalPaymentProviderPaymentData, PayPalValidatedPaymentData } from './pay-pal.payment-provider'; +import { defaultTestServerProviders } from '../../../../__testing__/test-server/providers'; +import { StartedTestServer, startTestServer } from '../../../../__testing__/test-server/start-test-server.function'; +import { Repository } from '../../../../data-source/repository'; +import { repositoryTokenFor } from '../../../../di/decorators/inject-repository.decorator'; +import { inject } from '../../../../di/inject.function'; +import { defineProvider } from '../../../../di/models/di-provider.model'; +import { isHttpClientError } from '../../../../http-client/http-client.error'; +import { CurrencyCode } from '../../../../localization/models/currency-code.model'; +import { UUIDUtilities } from '../../../../utilities/uuid.utilities'; +import { KnownPaymentMethod, PaymentMethod } from '../../models/payment-method.model'; +import { PaymentPluginOptionsInput } from '../../models/payment-plugin-options-input.model'; +import { PaymentStatus } from '../../models/payment-status.enum'; +import { Payment } from '../../models/payment.model'; +import { ZibriPaymentPlugin } from '../../payment.plugin'; +import { DefaultPaymentProviderArray, ZIBRI_PAYMENT_PLUGIN_DI_TOKENS } from '../../payment.tokens'; +import { PaymentServiceInterface } from '../../services/payment-service.interface'; + +async function getSandboxToken(clientId: string, clientSecret: string): Promise { + const auth: string = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + const res: Response = await fetch('https://api-m.sandbox.paypal.com/v1/oauth2/token', { + method: 'POST', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: 'grant_type=client_credentials' + }); + if (!res.ok) { + throw new Error(`Sandbox auth failed: ${await res.text()}`); + } + const { access_token } = await res.json() as { access_token: string }; + return access_token; +} + +// TODO: this currently doesn't work. +async function simulateBuyerApproval(merchantToken: string, orderId: string, returnUrl: string, cancelUrl: string): Promise { + const res: Response = await fetch( + `https://api-m.sandbox.paypal.com/v2/checkout/orders/${orderId}/confirm-payment-source`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${merchantToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + payment_source: { + // eslint-disable-next-line cspell/spellchecker + paypal: { + experience_context: { + payment_method_preference: 'IMMEDIATE_PAYMENT_REQUIRED', + return_url: returnUrl, + cancel_url: cancelUrl + } + } + } + }) + } + ); + + const body: string = await res.text(); + // eslint-disable-next-line no-console + console.debug('simulateBuyerApproval', { ok: res.ok, status: res.status, body }); + if (!res.ok) { + throw new Error(`Buyer approval failed (${res.status}): ${body}`); + } +} + +// ── Test suite ──────────────────────────────────────────────────────────────── + +describe('PayPalPaymentProvider (sandbox)', () => { + let paymentService: PaymentServiceInterface<[KnownPaymentMethod.PAY_PAL], [PayPalPaymentProvider]>; + let paymentRepository: Repository>; + + let sandboxToken: string; + let testServer: StartedTestServer; + + // eslint-disable-next-line cspell/spellchecker + const clientId: string | undefined = process.env['PAYPAL_CLIENT_ID']; + // eslint-disable-next-line cspell/spellchecker + const clientSecret: string | undefined = process.env['PAYPAL_CLIENT_SECRET']; + + if (!clientId || !clientSecret) { + throw new Error('clientId or clientSecret not available'); + } + + const METHOD: KnownPaymentMethod = KnownPaymentMethod.PAY_PAL; + const AMOUNT: number = 9.99; + const CURRENCY: CurrencyCode = 'EUR'; + const URLS: Required> = { returnUrl: 'https://example.com/return', cancelUrl: 'https://example.com/cancel' }; + + beforeAll(async () => { + testServer = await startTestServer({ + plugins: [new ZibriPaymentPlugin()], + providers: [ + ...defaultTestServerProviders, + defineProvider({ + token: ZIBRI_PAYMENT_PLUGIN_DI_TOKENS.OPTIONS_INPUT, + useFactory: () => { + const res: PaymentPluginOptionsInput< + [KnownPaymentMethod.PAY_PAL], + [PayPalPaymentProvider] + > = { + paymentMethods: [KnownPaymentMethod.PAY_PAL], + paymentProviders: [ + new PayPalPaymentProvider('PayPal', { + clientId, + clientSecret, + env: 'sandbox' + }) + ], + providerNameForMethod: { + [KnownPaymentMethod.PAY_PAL]: 'PayPal' + } + }; + return res as PaymentPluginOptionsInput; + } + }) + ] + }); + paymentService = inject>(ZIBRI_PAYMENT_PLUGIN_DI_TOKENS.PAYMENT_SERVICE); + paymentRepository = inject(repositoryTokenFor(Payment)); + sandboxToken = await getSandboxToken(clientId, clientSecret); + }); + + afterAll(async () => { + await testServer.shutdown(); + }); + + describe('validatePaymentData', () => { + it('accepts valid data', () => { + expect(() => paymentService.validatePaymentData(METHOD, { amount: AMOUNT, currencyCode: CURRENCY, transactionId: UUIDUtilities.generate() })).not.toThrow(); + }); + + it('rejects amount <= 0', () => { + expect(() => paymentService.validatePaymentData(METHOD, { amount: 0, currencyCode: CURRENCY, transactionId: UUIDUtilities.generate() })).toThrow('amount must be > 0'); + }); + + it('rejects missing currencyCode', () => { + expect(() => paymentService.validatePaymentData(METHOD, { amount: AMOUNT, currencyCode: '', transactionId: UUIDUtilities.generate() })).toThrow('currencyCode required'); + }); + }); + + // ── Direct payment: startPayment → confirmPayment ─────────────────────── + + describe.only('startPayment + confirmPayment', () => { + it.only('creates a CAPTURE-intent order and captures it after buyer approval', async () => { + const data: PayPalValidatedPaymentData = await paymentService.validatePaymentData(METHOD, { + amount: AMOUNT, currencyCode: CURRENCY, transactionId: UUIDUtilities.generate(), ...URLS + }); + + const payment: Payment = await paymentService.startPayment(METHOD, data); + + expect(payment.status).toBe(PaymentStatus.CREATED); + expect(payment.data?.orderId).toBeTruthy(); + expect(payment.data?.approvalUrl).toMatch(/paypal\.com/); + + assert(payment.data?.orderId); + + await simulateBuyerApproval(sandboxToken, payment.data.orderId, URLS.returnUrl, URLS.cancelUrl); + await paymentService.confirmPayment(payment); + + const confirmedPayment: Payment = await paymentRepository.findById(payment.id); + if (isHttpClientError(confirmedPayment.error)) { + // eslint-disable-next-line no-console + console.debug('confirmedPayment.error.responseData.body:\n', confirmedPayment.error.responseData?.body); + } + + expect(confirmedPayment.status).toBe(PaymentStatus.PAID); + expect(confirmedPayment.data?.captureId).toBeTruthy(); + expect(confirmedPayment.error).toBeUndefined(); + }); + }); + + // ── Cancel before buyer approves ──────────────────────────────────────── + + describe('cancelPayment (status: CREATED)', () => { + it('cancels locally without calling PayPal when the order was never approved', async () => { + const data: PayPalValidatedPaymentData = await paymentService.validatePaymentData(METHOD, { + amount: AMOUNT, currencyCode: CURRENCY, transactionId: UUIDUtilities.generate() + }); + + const payment: Payment = await paymentService.startPayment(METHOD, data); + expect(payment.status).toBe(PaymentStatus.CREATED); + + await paymentService.cancelPayment(payment); + + expect(payment.status).toBe(PaymentStatus.CANCELLED); + expect(payment.error).toBeUndefined(); + // no PayPal call needed — just a local state change + }); + }); + + // ── Reservation: startPaymentReservation → confirmPaymentReservation + // → collectPaymentFromReservation ───────────────────────── + + describe('startPaymentReservation + confirmPaymentReservation + collectPaymentFromReservation', () => { + it('creates an AUTHORIZE-intent order, reserves funds, then collects payment', async () => { + const data: PayPalValidatedPaymentData = await paymentService.validatePaymentData(METHOD, { + amount: AMOUNT, currencyCode: CURRENCY, transactionId: UUIDUtilities.generate(), ...URLS + }); + + const payment: Payment = await paymentService.startPaymentReservation(METHOD, data); + + expect(payment.status).toBe(PaymentStatus.CREATED); + expect(payment.data?.orderId).toBeTruthy(); + + assert(payment.data?.orderId); + + // Simulate buyer completing the approval redirect + await simulateBuyerApproval(sandboxToken, payment.data.orderId, URLS.returnUrl, URLS.cancelUrl); + + // confirmPaymentReservation must call POST .../authorize (the BUG-2 fix) + await paymentService.confirmPaymentReservation(payment); + + expect(payment.status).toBe(PaymentStatus.RESERVED); + expect(payment.data.authorizationId).toBeTruthy(); + expect(payment.error).toBeUndefined(); + + // Collect the reserved funds + await paymentService.collectPaymentFromReservation(payment); + + expect(payment.status).toBe(PaymentStatus.PAID); + expect(payment.data.captureId).toBeTruthy(); + expect(payment.error).toBeUndefined(); + }); + }); + + // ── Cancel a reserved authorization (void) ────────────────────────────── + + describe('cancelPayment (status: RESERVED)', () => { + it('voids the PayPal authorization and marks payment as CANCELLED', async () => { + const data: PayPalValidatedPaymentData = await paymentService.validatePaymentData(METHOD, { + amount: AMOUNT, currencyCode: CURRENCY, transactionId: UUIDUtilities.generate(), ...URLS + }); + + const payment: Payment = await paymentService.startPaymentReservation(METHOD, data); + + assert(payment.data?.orderId); + await simulateBuyerApproval(sandboxToken, payment.data.orderId, URLS.returnUrl, URLS.cancelUrl); + await paymentService.confirmPaymentReservation(payment); + + expect(payment.status).toBe(PaymentStatus.RESERVED); + + await paymentService.cancelPayment(payment); + + expect(payment.status).toBe(PaymentStatus.CANCELLED); + expect(payment.error).toBeUndefined(); + }); + }); + + // ── Refund ────────────────────────────────────────────────────────────── + + describe('refundPayment', () => { + it('refunds a captured payment and marks it REFUNDED', async () => { + const data: PayPalValidatedPaymentData = await paymentService.validatePaymentData(METHOD, { + amount: AMOUNT, currencyCode: CURRENCY, transactionId: UUIDUtilities.generate(), ...URLS + }); + + const payment: Payment = await paymentService.startPayment(METHOD, data); + + assert(payment.data?.orderId); + await simulateBuyerApproval(sandboxToken, payment.data.orderId, URLS.returnUrl, URLS.cancelUrl); + await paymentService.confirmPayment(payment); + + expect(payment.status).toBe(PaymentStatus.PAID); + + await paymentService.refundPayment(payment); + + expect(payment.status).toBe(PaymentStatus.REFUNDED); + expect(payment.error).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts index c4159c8..2ebc167 100644 --- a/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts +++ b/src/plugin/payment/providers/pay-pal/pay-pal.payment-provider.ts @@ -10,7 +10,7 @@ import { Payment } from '../../models/payment.model'; /** * The environment of the pay-pal client. */ -type PayPalEnv = 'sandbox' | 'live'; +export type PayPalEnv = 'sandbox' | 'live'; /** * The supported methods of the PayPalPaymentProvider. @@ -20,7 +20,7 @@ type SupportedMethods = [KnownPaymentMethod.PAY_PAL]; /** * The data for creating a new payment. */ -type PayPalPaymentData = { +export type PayPalPaymentData = { /** * The amount of the payment. */ @@ -46,12 +46,12 @@ type PayPalPaymentData = { /** * The validated payment data. */ -type PayPalValidatedPaymentData = PayPalPaymentData; +export type PayPalValidatedPaymentData = PayPalPaymentData; /** * The payment data stored by the provider. */ -type PayPalProviderPaymentData = { +export type PayPalPaymentProviderPaymentData = { /** * The id of the order. */ @@ -67,13 +67,17 @@ type PayPalProviderPaymentData = { /** * The id of the payment capture. */ - captureId?: string + captureId?: string, + /** + * The id of the refund. + */ + refundId?: string }; /** * The payment reservation data. */ -type PayPalProviderReservationPaymentData = PayPalProviderPaymentData; +type PayPalProviderReservationPaymentData = PayPalPaymentProviderPaymentData; /** * Maps payment methods to their payment data. @@ -102,7 +106,7 @@ type ProviderPaymentDataMap = { /** * The provider payment data for the PayPal payment method. */ - PAY_PAL: PayPalProviderPaymentData + PAY_PAL: PayPalPaymentProviderPaymentData }; /** @@ -148,7 +152,7 @@ type ReservationSupport = { /** * Options for configuring the PayPalPaymentProvider. */ -export type PayPalProviderOptions = { +export type PayPalPaymentProviderOptions = { /** * The clientId needed to authenticate with the api. */ @@ -177,11 +181,21 @@ export class PayPalPaymentProvider implements PaymentProviderInterface< ReservationSupport > { // eslint-disable-next-line jsdoc/require-jsdoc - readonly cancellationSupported: CancellationSupport = { PAY_PAL: true }; + declare readonly __supportedMethods: SupportedMethods; + // eslint-disable-next-line jsdoc/require-jsdoc + declare readonly __paymentDataMap: PaymentDataMap; + // eslint-disable-next-line jsdoc/require-jsdoc + declare readonly __validatedDataMap: ValidatedPaymentDataMap; + // eslint-disable-next-line jsdoc/require-jsdoc + declare readonly __providerPaymentDataMap: ProviderPaymentDataMap; + // eslint-disable-next-line jsdoc/require-jsdoc + declare readonly __providerReservationPaymentDataMap: ProviderReservationPaymentDataMap; + // eslint-disable-next-line jsdoc/require-jsdoc + declare readonly __cancellationSupportMap: CancellationSupport; // eslint-disable-next-line jsdoc/require-jsdoc - readonly refundSupported: RefundSupport = { PAY_PAL: true }; + declare readonly __refundSupportMap: RefundSupport; // eslint-disable-next-line jsdoc/require-jsdoc - readonly reservationSupported: ReservationSupport = { PAY_PAL: true }; + declare readonly __reservationSupportMap: ReservationSupport; /** * Client that encapsulates the PayPal API. @@ -190,7 +204,7 @@ export class PayPalPaymentProvider implements PaymentProviderInterface< constructor( readonly name: string, - options: PayPalProviderOptions + options: PayPalPaymentProviderOptions ) { this.client = new PayPalClient(options); } @@ -300,7 +314,7 @@ export class PayPalPaymentProvider implements PaymentProviderInterface< } try { - const resp: GetOrderResp = await this.client.getOrder(payment.data.orderId); + const resp: GetOrderResp = await this.client.authorizeOrder(payment.data.orderId); let foundAuthId: string | undefined; for (const pu of resp.purchase_units ?? []) { diff --git a/src/plugin/payment/providers/payment-provider.interface.ts b/src/plugin/payment/providers/payment-provider.interface.ts index c36141a..07c8ac9 100644 --- a/src/plugin/payment/providers/payment-provider.interface.ts +++ b/src/plugin/payment/providers/payment-provider.interface.ts @@ -3,53 +3,115 @@ import { PaymentMethod } from '../models/payment-method.model'; import { Payment } from '../models/payment.model'; /** - * Interface for a payment provider. + * A payment provider interface with loose typing for use in DI. */ -export interface PaymentProviderInterface< - SupportedMethods extends readonly PaymentMethod[], - // eslint-disable-next-line jsdoc/require-jsdoc - PaymentDataForMethod extends Record, - // eslint-disable-next-line jsdoc/require-jsdoc - ValidatedPaymentDataForMethod extends Record, - ProviderPaymentDataForMethod extends Record, - ProviderReservationPaymentDataForMethod extends Record, - CancellationSupport extends Record, - RefundSupport extends Record, - ReservationSupport extends Record -> { +export interface AnyPaymentProviderInterface { /** * The unique name of the provider, used to differentiate between them. */ readonly name: string, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __supportedMethods: readonly PaymentMethod[], + // eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any + readonly __paymentDataMap: Record, + // eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any + readonly __validatedDataMap: Record, + // eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any + readonly __providerPaymentDataMap: Record, + // eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any + readonly __providerReservationPaymentDataMap: Record, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __cancellationSupportMap: Record, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __refundSupportMap: Record, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __reservationSupportMap: Record, /** - * A map that defines which payment methods support cancellation. + * Validates the given payment data for the given method. */ - readonly cancellationSupported: CancellationSupport, + // eslint-disable-next-line typescript/no-explicit-any + validatePaymentData: (method: any, data: any) => any, /** - * A map that defines which payment methods support refunding. + * Starts a new payment with the given method and data. */ - readonly refundSupported: RefundSupport, + // eslint-disable-next-line typescript/no-explicit-any + startPayment: (method: any, data: any) => any, /** - * A map that defines which payment methods support reservation. + * Starts a new payment reservation with the given method and data. */ - readonly reservationSupported: ReservationSupport, + // eslint-disable-next-line typescript/no-explicit-any + startPaymentReservation: (method: any, data: any) => any, /** - * Validates the given payment data for the given method. + * Confirms The given payment reservation. This does NOT move any money yet, it just reserves the funds. */ + // eslint-disable-next-line typescript/no-explicit-any + confirmPaymentReservation: (payment: any) => any, + /** + * Confirms and finishes a payment. + */ + // eslint-disable-next-line typescript/no-explicit-any + confirmPayment: (payment: any) => any, + /** + * Collects payment from the given payment reservation. + */ + // eslint-disable-next-line typescript/no-explicit-any + collectPaymentFromReservation: (payment: any) => any, + /** + * Cancels the given payment. + */ + // eslint-disable-next-line typescript/no-explicit-any + cancelPayment: (payment: any) => any, + /** + * Refunds the given payment. + */ + // eslint-disable-next-line typescript/no-explicit-any + refundPayment: (payment: any) => any +} + +/** + * Interface for a payment provider. + */ +export interface PaymentProviderInterface< + SupportedMethods extends readonly PaymentMethod[], + // eslint-disable-next-line jsdoc/require-jsdoc + PaymentDataForMethod extends Record, + // eslint-disable-next-line jsdoc/require-jsdoc + ValidatedPaymentDataForMethod extends Record, + ProviderPaymentDataForMethod extends Record, + ProviderReservationPaymentDataForMethod extends Record, + CancellationSupport extends Record, + RefundSupport extends Record, + ReservationSupport extends Record +> extends AnyPaymentProviderInterface { + // phantom carriers — never set at runtime, only for type inference + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __supportedMethods: SupportedMethods, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __paymentDataMap: PaymentDataForMethod, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __validatedDataMap: ValidatedPaymentDataForMethod, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __providerPaymentDataMap: ProviderPaymentDataForMethod, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __providerReservationPaymentDataMap: ProviderReservationPaymentDataForMethod, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __cancellationSupportMap: CancellationSupport, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __refundSupportMap: RefundSupport, + // eslint-disable-next-line jsdoc/require-jsdoc + readonly __reservationSupportMap: ReservationSupport, + + // eslint-disable-next-line jsdoc/require-jsdoc validatePaymentData: ( method: M, data: PaymentDataForMethod[M] ) => ValidatedPaymentDataForMethod[M] | Promise, - /** - * Starts a new payment with the given method and data. - */ + // eslint-disable-next-line jsdoc/require-jsdoc startPayment: ( method: M, data: ValidatedPaymentDataForMethod[M] ) => Payment | Promise>, - /** - * Starts a new payment reservation with the given method and data. - */ + // eslint-disable-next-line jsdoc/require-jsdoc startPaymentReservation: < M extends { [K in SupportedMethods[number]]: ReservationSupport[K] extends true ? K : never @@ -58,31 +120,23 @@ export interface PaymentProviderInterface< method: M, data: ValidatedPaymentDataForMethod[M] ) => Payment | Promise>, - /** - * Confirms The given payment reservation. This does NOT move any money yet, it just reserves the funds. - */ + // eslint-disable-next-line jsdoc/require-jsdoc confirmPaymentReservation: < M extends { [K in SupportedMethods[number]]: ReservationSupport[K] extends true ? K : never }[SupportedMethods[number]] >(payment: Payment) => void | Promise, - /** - * Confirms and finishes a payment. - */ + // eslint-disable-next-line jsdoc/require-jsdoc confirmPayment: ( payment: Payment ) => void | Promise, - /** - * Collects payment from the given payment reservation. - */ + // eslint-disable-next-line jsdoc/require-jsdoc collectPaymentFromReservation: < M extends { [K in SupportedMethods[number]]: ReservationSupport[K] extends true ? K : never }[SupportedMethods[number]] >(payment: Payment) => void | Promise, - /** - * Cancels the given payment. - */ + // eslint-disable-next-line jsdoc/require-jsdoc cancelPayment: < M extends { [K in SupportedMethods[number]]: CancellationSupport[K] extends true ? K : ReservationSupport[K] extends true ? K : never @@ -90,9 +144,7 @@ export interface PaymentProviderInterface< >( payment: Payment | Payment ) => void | Promise, - /** - * Refunds the given payment. - */ + // eslint-disable-next-line jsdoc/require-jsdoc refundPayment: < M extends { [K in SupportedMethods[number]]: RefundSupport[K] extends true ? K : never diff --git a/src/plugin/payment/services/payment-service.interface.ts b/src/plugin/payment/services/payment-service.interface.ts index 1067fbf..1be56f8 100644 --- a/src/plugin/payment/services/payment-service.interface.ts +++ b/src/plugin/payment/services/payment-service.interface.ts @@ -1,25 +1,25 @@ import { PaymentDataForMethod, ValidatedPaymentDataForMethod, PaymentForMethod, AllowedReservationMethods, PaymentReservationForMethod, AllowedCancellationMethods, AllowedRefundMethods } from './payment-service.types'; -import { AnyObject } from '../../../entity/any-object.model'; import { PaymentMethod } from '../models/payment-method.model'; -import { PaymentProviderInterface } from '../providers/payment-provider.interface'; +import { AnyPaymentProviderInterface } from '../providers/payment-provider.interface'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type MethodsCoveredByProviders

+ // eslint-disable-next-line jsdoc/require-jsdoc + = P[number] extends { __supportedMethods: infer SM } + ? SM extends readonly (infer S)[] ? S : never + : never; /** * Interface for a payment service. */ export interface PaymentServiceInterface< Methods extends readonly PaymentMethod[], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[], + // eslint-disable-next-line unusedImports/no-unused-vars + _Check extends (Methods[number] extends MethodsCoveredByProviders

+ // eslint-disable-next-line stylistic/max-len + ? unknown : 'Error: not all payment methods are covered by the provided providers') = Methods[number] extends MethodsCoveredByProviders

+ ? unknown : 'Error: not all payment methods are covered by the provided providers' > { /** * Validates the given data of the given payment method. diff --git a/src/plugin/payment/services/payment-service.types.ts b/src/plugin/payment/services/payment-service.types.ts index 64e6d94..63ea2f3 100644 --- a/src/plugin/payment/services/payment-service.types.ts +++ b/src/plugin/payment/services/payment-service.types.ts @@ -1,9 +1,8 @@ -/* eslint-disable unusedImports/no-unused-vars */ -import { AnyObject } from '../../../entity/any-object.model'; +/* eslint-disable jsdoc/require-jsdoc */ import { PaymentMethod } from '../models/payment-method.model'; import { PaymentPluginOptions } from '../models/payment-plugin-options.model'; import { Payment } from '../models/payment.model'; -import { PaymentProviderInterface } from '../providers/payment-provider.interface'; +import { AnyPaymentProviderInterface } from '../providers/payment-provider.interface'; /** * Helper: pick the provider *instance* (from the P tuple) for the given method M. @@ -11,21 +10,10 @@ import { PaymentProviderInterface } from '../providers/payment-provider.interfac type ProviderInstanceForMethod< Methods extends readonly PaymentMethod[], M extends Methods[number], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[] > = Extract< P[number], - // eslint-disable-next-line jsdoc/require-jsdoc + { name: PaymentPluginOptions['providerNameForMethod'][M] } >; @@ -35,31 +23,11 @@ type ProviderInstanceForMethod< export type PaymentDataForMethod< Methods extends readonly PaymentMethod[], M extends Methods[number], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] -> - = ProviderInstanceForMethod extends PaymentProviderInterface< - infer _SM, - infer PaymentDataMap, - infer _ValidatedMap, - infer _ProviderPaymentData, - infer _ProviderReservationPaymentData, - infer _CS, - infer _RS, - infer _ReservationSupport - > - ? PaymentDataMap[M] - : never; + P extends readonly AnyPaymentProviderInterface[] +> = ProviderInstanceForMethod extends { __paymentDataMap: infer DataMap } + // eslint-disable-next-line typescript/no-explicit-any + ? (DataMap & Record)[M] & { transactionId: string } + : never; /** * ValidatedPaymentDataForMethod: the validated/normalized data type returned by validatePaymentData. @@ -67,31 +35,12 @@ export type PaymentDataForMethod< export type ValidatedPaymentDataForMethod< Methods extends readonly PaymentMethod[], M extends Methods[number], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] -> - = ProviderInstanceForMethod extends PaymentProviderInterface< - infer _SM, - infer _PaymentDataMap, - infer ValidatedMap, - infer _ProviderPaymentData, - infer _ProviderReservationPaymentData, - infer _CS, - infer _RS, - infer _ReservationSupport - > - ? ValidatedMap[M] - : never; + P extends readonly AnyPaymentProviderInterface[] + +> = ProviderInstanceForMethod extends { __validatedDataMap: infer DataMap } + // eslint-disable-next-line typescript/no-explicit-any + ? (DataMap & Record)[M] + : never; /** * The payment entity type used for payments. @@ -99,31 +48,9 @@ export type ValidatedPaymentDataForMethod< export type PaymentForMethod< Methods extends readonly PaymentMethod[], M extends Methods[number], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] -> - = ProviderInstanceForMethod extends PaymentProviderInterface< - infer _SM, - infer _PaymentDataMap, - infer _ValidatedMap, - infer ProviderPaymentDataMap, - infer _ProviderReservationPaymentData, - infer _CS, - infer _RS, - infer _ReservationSupport - > - ? Payment - : never; + P extends readonly AnyPaymentProviderInterface[] + +> = Payment['__providerPaymentDataMap'])[M]>; /** * The payment entity type used for reservation payments. @@ -131,63 +58,19 @@ export type PaymentForMethod< export type PaymentReservationForMethod< Methods extends readonly PaymentMethod[], M extends Methods[number], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] -> - = ProviderInstanceForMethod extends PaymentProviderInterface< - infer _SM, - infer _PaymentDataMap, - infer _ValidatedMap, - infer _ProviderPaymentDataMap, - infer ProviderReservationPaymentDataMap, - infer _CS, - infer _RS, - infer _ReservationSupport - > - ? Payment - : never; + P extends readonly AnyPaymentProviderInterface[] +> = Payment['__providerReservationPaymentDataMap'])[M]>; /** * Filters to all methods that are allowed for reservation. */ export type AllowedReservationMethods< Methods extends readonly PaymentMethod[], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[] > = { - [K in Methods[number]]: ProviderInstanceForMethod extends PaymentProviderInterface< - infer _SM, - infer _PaymentDataMap, - infer _ValidatedMap, - infer _ProviderPaymentDataMap, - infer _ProviderReservationPaymentDataMap, - infer _CancellationSupport, - infer _RefundSupport, - infer ReservationSupport - > - ? ReservationSupport[K] extends true - ? K - : never + [K in Methods[number]]: ProviderInstanceForMethod extends { __reservationSupportMap: infer RS } + // eslint-disable-next-line typescript/no-explicit-any + ? (RS & Record)[K] extends true ? K : never : never; }[Methods[number]]; @@ -196,32 +79,12 @@ export type AllowedReservationMethods< */ export type AllowedCancellationMethods< Methods extends readonly PaymentMethod[], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[] > = { - [K in Methods[number]]: ProviderInstanceForMethod extends PaymentProviderInterface< - infer _SM, - infer _PaymentDataMap, - infer _ValidatedMap, - infer _ProviderPaymentDataMap, - infer _ProviderReservationPaymentDataMap, - infer CancellationSupport, - infer _RefundSupport, - infer ReservationSupport - > - ? CancellationSupport[K] extends true - ? K - : ReservationSupport[K] extends true ? K : never + // eslint-disable-next-line stylistic/max-len + [K in Methods[number]]: ProviderInstanceForMethod extends { __cancellationSupportMap: infer CS, __reservationSupportMap: infer RS } + // eslint-disable-next-line typescript/no-explicit-any + ? (CS & Record)[K] extends true ? K : (RS & Record)[K] extends true ? K : never : never; }[Methods[number]]; @@ -230,31 +93,10 @@ export type AllowedCancellationMethods< */ export type AllowedRefundMethods< Methods extends readonly PaymentMethod[], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[] > = { - [K in Methods[number]]: ProviderInstanceForMethod extends PaymentProviderInterface< - infer _SM, - infer _PaymentDataMap, - infer _ValidatedMap, - infer _ProviderPaymentDataMap, - infer _ProviderReservationPaymentDataMap, - infer _CancellationSupport, - infer _RefundSupport, - infer _ReservationSupport - > - ? _RefundSupport[K] extends true - ? K - : never + [K in Methods[number]]: ProviderInstanceForMethod extends { __refundSupportMap: infer RS } + // eslint-disable-next-line typescript/no-explicit-any + ? (RS & Record)[K] extends true ? K : never : never; }[Methods[number]]; \ No newline at end of file diff --git a/src/plugin/payment/services/payment.service.ts b/src/plugin/payment/services/payment.service.ts index 887b335..993888e 100644 --- a/src/plugin/payment/services/payment.service.ts +++ b/src/plugin/payment/services/payment.service.ts @@ -3,6 +3,7 @@ import { PaymentDataForMethod, ValidatedPaymentDataForMethod, PaymentForMethod, import { Repository } from '../../../data-source/repository'; import { InjectRepository } from '../../../di/decorators/inject-repository.decorator'; import { Inject } from '../../../di/decorators/inject.decorator'; +import { Injectable } from '../../../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; import { AnyObject } from '../../../entity/any-object.model'; import { type LoggerInterface } from '../../../logging/logger.interface'; @@ -10,30 +11,20 @@ import { PaymentMethod } from '../models/payment-method.model'; import { type PaymentPluginOptions } from '../models/payment-plugin-options.model'; import { PaymentStatus } from '../models/payment-status.enum'; import { Payment } from '../models/payment.model'; -import { ZIBRI_PAYMENT_DI_TOKENS } from '../payment.tokens'; -import { PaymentProviderInterface } from '../providers/payment-provider.interface'; +import { ZIBRI_PAYMENT_PLUGIN_DI_TOKENS } from '../payment.tokens'; +import { AnyPaymentProviderInterface } from '../providers/payment-provider.interface'; /** * Default payment service implementation of zibri. */ +@Injectable({ register: 'onUse' }) export class PaymentService< Methods extends readonly PaymentMethod[], - P extends readonly PaymentProviderInterface< - Methods[number][], - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - // eslint-disable-next-line jsdoc/require-jsdoc - Record, - Record, - Record, - Record, - Record, - Record - >[] + P extends readonly AnyPaymentProviderInterface[] >implements PaymentServiceInterface { constructor( - @Inject(ZIBRI_PAYMENT_DI_TOKENS.OPTIONS) + @Inject(ZIBRI_PAYMENT_PLUGIN_DI_TOKENS.OPTIONS) protected readonly options: PaymentPluginOptions, @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -60,8 +51,11 @@ export class PaymentService< method, data ) as ValidatedPaymentDataForMethod; - if ((await this.paymentRepository.findAll({ where: { transactionId: data.transactionId } })).length) { - throw new Error(`a payment for the transactionId "${data.transactionId}" already exists`); + + // eslint-disable-next-line jsdoc/require-jsdoc + const { transactionId } = data as unknown as { transactionId: string }; + if ((await this.paymentRepository.findAll({ where: { transactionId } })).length) { + throw new Error(`a payment for the transactionId "${transactionId}" already exists`); } return res; } @@ -81,7 +75,7 @@ export class PaymentService< data: ValidatedPaymentDataForMethod ): Promise> { const provider: P[number] = this.findPaymentProviderForMethod(method); - return await provider.startPayment(method, data) as PaymentReservationForMethod; + return await provider.startPaymentReservation(method, data) as PaymentReservationForMethod; } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/plugin/plugin.model.ts b/src/plugin/plugin.model.ts index eff9627..4169f56 100644 --- a/src/plugin/plugin.model.ts +++ b/src/plugin/plugin.model.ts @@ -37,7 +37,7 @@ export abstract class ZibriPlugin { * Validates that the plugin can work correctly. This should check that all entities exist, * all required providers exist etc. * - * It's called after the initialization of the app, so that data sources etc. Should all be available. + * It's called at the end of app initialization, so that data sources etc. Should all be available. */ abstract validate(app: ZibriApplication): void | Promise; } \ No newline at end of file diff --git a/src/preact/generate-client-scripts.function.ts b/src/preact/generate-client-scripts.function.ts index b4b0abe..dbff1cd 100644 --- a/src/preact/generate-client-scripts.function.ts +++ b/src/preact/generate-client-scripts.function.ts @@ -1,22 +1,20 @@ import { createRequire } from 'node:module'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; import { toKebabCase } from '../utilities/to-kebab-case.function'; +const defaultGlobs: string[] = ['src/templates/pages/**/*.tsx', 'src/templates/components/**/*.tsx']; + /** - * Scans compiled JS files in srcDir for ?client imports, resolves their browser - * distributions, and writes them to outputDir with predictable names. + * Scans compiled JS files in the given template files for ?client imports, resolves their browser + * distributions, and writes them to assets/public/vendor with predictable names. * Call this from your build plugin before compilation completes. - * @example - * // webpack plugin: - * compiler.hooks.beforeCompile.tapPromise('ZibriClientScripts', () => - * generateClientScripts({ srcDir: './dist', outputDir: './assets' }) - * ); + * @param glob - The glob(s) to find your .tsx templates from. */ -export async function generateClientScripts(): Promise { +export async function generateClientScripts(glob: string | string[] = defaultGlobs): Promise { const allPackages: Set = new Set(); const packagesByComponent: Record = {}; - const templateFiles: Path[] = await FsUtilities.glob('src/templates/**/*.tsx'); + const templateFiles: FsPath[] = await FsUtilities.glob(glob); await Promise.all(templateFiles.map(async f => { const src: string = await FsUtilities.readFile(f); @@ -36,12 +34,12 @@ export async function generateClientScripts(): Promise { } })); - const vendorPath: Path = FsUtilities.getPath('assets', 'public', 'vendor'); + const vendorPath: FsPath = FsUtilities.getPath('assets', 'public', 'vendor'); await FsUtilities.mkdir(vendorPath); await Promise.all([...allPackages].map(async pkg => { const code: string = await resolveBrowserDist(pkg); - const outFile: Path = FsUtilities.getPath(vendorPath, pkgToFilename(pkg)); + const outFile: FsPath = FsUtilities.getPath(vendorPath, pkgToFilename(pkg)); if (await FsUtilities.exists(outFile)) { const oldFileContent: string = await FsUtilities.readFile(outFile); if (oldFileContent.trim() === code.trim()) { @@ -51,7 +49,7 @@ export async function generateClientScripts(): Promise { await FsUtilities.upsertFile(outFile, code); })); - const manifestFile: Path = FsUtilities.getPath(vendorPath, 'manifest.json'); + const manifestFile: FsPath = FsUtilities.getPath(vendorPath, 'manifest.json'); const sorted: Record = Object.fromEntries( Object.entries(packagesByComponent).sort(([a], [b]) => a.localeCompare(b)) ); @@ -119,10 +117,10 @@ async function findPackageDir(pkg: string, userRequire: NodeJS.Require): Promise } // Fallback — resolve main entry and walk up to find the package root - const main: Path = FsUtilities.getPath(userRequire.resolve(pkg)); + const main: FsPath = FsUtilities.getPath(userRequire.resolve(pkg)); let dir: string = FsUtilities.dirName(main); while (true) { - const candidate: Path = FsUtilities.getPath(dir, 'package.json'); + const candidate: FsPath = FsUtilities.getPath(dir, 'package.json'); try { // eslint-disable-next-line typescript/no-unsafe-assignment const json: Record = JSON.parse(await FsUtilities.readFile(candidate)); diff --git a/src/preact/preact-email-component.model.ts b/src/preact/preact-email-component.model.ts new file mode 100644 index 0000000..18868d9 --- /dev/null +++ b/src/preact/preact-email-component.model.ts @@ -0,0 +1,6 @@ +import { PreactComponent } from './preact-component.model'; + +/** + * Definition of a valid preact email component that can be rendered by zibri. + */ +export type PreactEmailComponent = PreactComponent; \ No newline at end of file diff --git a/src/preact/preact.utilities.ts b/src/preact/preact.utilities.ts index 6af4f7d..1c8db67 100644 --- a/src/preact/preact.utilities.ts +++ b/src/preact/preact.utilities.ts @@ -1,13 +1,14 @@ -import { VNode } from 'preact'; +import { JSX, VNode } from 'preact'; import renderToString from 'preact-render-to-string'; import { NestedComponentEntry, PreactCollector } from './collector'; import { pkgToFilename } from './generate-client-scripts.function'; import { preactHooks } from './hooks/hooks'; import { PreactComponent } from './preact-component.model'; +import { PreactEmailComponent } from './preact-email-component.model'; import { findStringEnd, stringAwareReplace } from './string-aware-replace.function'; import { HtmlResponse } from '../parsing/html/html-response.model'; -import { FsUtilities, Path } from '../utilities/fs.utilities'; +import { FsUtilities, FsPath } from '../utilities/fs.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; /** @@ -45,21 +46,46 @@ export abstract class PreactUtilities { private static clientManifest: Record | undefined; /** - * Render a component and inline the component "body" (everything before the top-level return) - * into the same