diff --git a/.env.example b/.env.example index f406dd4..02e7f87 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,16 @@ # Database Configuration -DB_HOST=localhost -DB_PORT=5432 -DB_USERNAME=root -DB_PASSWORD=root -DB_NAME=teachlink +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USER=postgres +DATABASE_PASSWORD=postgres +DATABASE_NAME=teachlink # JWT Configuration -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-min-10-chars JWT_EXPIRES_IN=15m JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production JWT_REFRESH_EXPIRES_IN=7d -# AWS Configuration -AWS_ACCESS_KEY_ID=your_access_key -AWS_SECRET_ACCESS_KEY=your_secret_key -AWS_REGION=us-east-1 -AWS_S3_BUCKET=teachlink-media -AWS_ELASTIC_TRANSCODER_PIPELINE_ID=your_pipeline_id - # Redis Configuration (for Bull Queue) REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/package-lock.json b/package-lock.json index 02fc971..4a4a44a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -331,6 +331,7 @@ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.27.3.tgz", "integrity": "sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -346,6 +347,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -354,7 +356,8 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@apollo/cache-control-types": { "version": "1.0.3", @@ -2062,6 +2065,17 @@ } } }, + "node_modules/@aws-sdk/util-utf8-browser": { + "version": "3.259.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", + "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.3.1" + } + }, "node_modules/@aws-sdk/xml-builder": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.6.tgz", @@ -2134,7 +2148,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2652,6 +2665,7 @@ "resolved": "https://registry.npmjs.org/@browserbasehq/sdk/-/sdk-2.6.0.tgz", "integrity": "sha512-83iXP5D7xMm8Wyn66TUaUrgoByCmAJuoMoZQI3sGg3JAiMlTfnCIMqyVBoNSaItaPIkaCnrsj6LiusmXV2X9YA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -2667,6 +2681,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2675,7 +2690,8 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@browserbasehq/stagehand": { "version": "1.14.0", @@ -2711,7 +2727,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@colors/colors": { "version": "1.5.0", @@ -3016,7 +3033,6 @@ "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-8.19.1.tgz", "integrity": "sha512-+1j9NnQVOX+lbWB8LhCM7IkUmjU05Y4+BmSLfusq0msCsQb1Va+OUKFCoOXjCJqQrcgdRdQCjYYyolQ/npQALQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@elastic/transport": "^8.9.6", "apache-arrow": "18.x - 21.x", @@ -3143,16 +3159,6 @@ "concat-map": "0.0.1" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3251,7 +3257,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -3291,7 +3296,6 @@ "resolved": "https://registry.npmjs.org/@huggingface/inference/-/inference-4.13.13.tgz", "integrity": "sha512-kxa3hkQEgD/B2x6QTZQBnu4Wx/Uc7pOAqmLS8T3VkTM4weshJzaeYH/nEDKGuChUKJza0IglytRViSCiT8KLjg==", "license": "MIT", - "peer": true, "dependencies": { "@huggingface/jinja": "^0.5.5", "@huggingface/tasks": "^0.19.85" @@ -3389,6 +3393,7 @@ "resolved": "https://registry.npmjs.org/@ibm-cloud/watsonx-ai/-/watsonx-ai-1.7.8.tgz", "integrity": "sha512-UpU/hTHRrCwzkqV1+H/CGbHIGRKty6SX1Aea2CBbyRonsEPIPQtFfhz8FHhs+o6Ca/TCupHNlcLAQUFektZmEQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/node": "^18.0.0", "extend": "3.0.2", @@ -3404,6 +3409,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3412,7 +3418,8 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@img/colour": { "version": "1.0.0", @@ -5262,6 +5269,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -5588,6 +5596,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -5630,7 +5639,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", @@ -5887,7 +5895,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -5946,7 +5953,6 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -6008,7 +6014,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-12.2.2.tgz", "integrity": "sha512-lUDy/1uqbRA1kBKpXcmY0aHhcPbfeG52Wg5+9Jzd1d57dwSjCAmuO+mWy5jz9ugopVCZeK0S/kdAMvA+r9fNdA==", "license": "MIT", - "peer": true, "dependencies": { "@graphql-tools/merge": "9.0.11", "@graphql-tools/schema": "10.0.10", @@ -6218,7 +6223,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -6685,7 +6689,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -6699,7 +6702,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -6820,7 +6822,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -7766,7 +7767,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -7979,18 +7979,87 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.10.tgz", - "integrity": "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-2.2.0.tgz", + "integrity": "sha512-8janZoJw85nJmQZc4L8TuePp2pk1nxLgkxIR0TUjKJ5Dkj5oelB9WtiSSGXCQvNsJl0VSTvK/2ueMXxvpa9GVw==", "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@smithy/types": "^2.12.0", + "@smithy/util-hex-encoding": "^2.2.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/eventstream-codec/node_modules/@aws-crypto/crc32": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", + "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@smithy/eventstream-codec/node_modules/@aws-crypto/crc32/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD", + "optional": true, + "peer": true + }, + "node_modules/@smithy/eventstream-codec/node_modules/@aws-crypto/util": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", + "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@smithy/eventstream-codec/node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD", + "optional": true, + "peer": true + }, + "node_modules/@smithy/eventstream-codec/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/eventstream-codec/node_modules/@smithy/util-hex-encoding": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.2.0.tgz", + "integrity": "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/@smithy/eventstream-serde-browser": { @@ -8048,6 +8117,21 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/eventstream-serde-universal/node_modules/@smithy/eventstream-codec": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", + "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/fetch-http-handler": { "version": "5.3.11", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.11.tgz", @@ -8381,6 +8465,7 @@ "integrity": "sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" @@ -8395,6 +8480,7 @@ "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -8460,6 +8546,7 @@ "integrity": "sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "@smithy/types": "^2.12.0", @@ -8479,6 +8566,7 @@ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -8492,6 +8580,7 @@ "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -8505,6 +8594,7 @@ "integrity": "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -8518,6 +8608,7 @@ "integrity": "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" @@ -8532,6 +8623,7 @@ "integrity": "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -8720,9 +8812,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz", - "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -9064,6 +9156,7 @@ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/ms": "*" } @@ -9074,7 +9167,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -9275,7 +9367,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -9442,7 +9533,8 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/uuid": { "version": "10.0.0", @@ -9502,13 +9594,22 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9946,6 +10047,7 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", + "peer": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -9986,7 +10088,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10009,6 +10110,7 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" }, @@ -10044,6 +10146,7 @@ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", "license": "MIT", + "peer": true, "dependencies": { "humanize-ms": "^1.2.1" }, @@ -10057,7 +10160,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10376,7 +10478,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -10923,7 +11024,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10989,7 +11089,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -11037,7 +11136,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -11312,15 +11410,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -11825,7 +11921,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -12051,6 +12146,7 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12105,7 +12201,6 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12258,7 +12353,6 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=12" }, @@ -12532,7 +12626,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -12596,7 +12691,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12653,7 +12747,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -12820,16 +12913,6 @@ "node": ">=10.13.0" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -12984,6 +13067,7 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -13210,7 +13294,8 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/external-editor": { "version": "3.1.0", @@ -13323,7 +13408,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" @@ -13764,7 +13848,8 @@ "version": "1.7.2", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/form-data/node_modules/mime-db": { "version": "1.52.0", @@ -13792,6 +13877,7 @@ "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", "license": "MIT", + "peer": true, "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" @@ -14161,7 +14247,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.0.tgz", "integrity": "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -14229,7 +14314,6 @@ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", @@ -14394,6 +14478,7 @@ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.0.0" } @@ -14419,6 +14504,7 @@ "resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.4.8.tgz", "integrity": "sha512-tLMlZv13cV6S1UPj/bhv8XfV9Z1BDDs/4DxHKWnCw7QlJMzmGdHLPX386x9nrFMQMPZ48eAH+Thsa06tzUZkaA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/debug": "^4.1.12", "@types/node": "^18.19.80", @@ -14445,6 +14531,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -14454,6 +14541,7 @@ "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", "license": "MIT", + "peer": true, "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", @@ -14484,13 +14572,15 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/ibm-cloud-sdk-core/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -14500,6 +14590,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -14512,6 +14603,7 @@ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", "license": "MIT", + "peer": true, "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" @@ -14529,6 +14621,7 @@ "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", "license": "MIT", + "peer": true, "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" @@ -14545,7 +14638,8 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/iconv-lite": { "version": "0.4.24", @@ -14566,12 +14660,11 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -14764,7 +14857,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", @@ -15052,7 +15144,8 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -15171,7 +15264,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -16564,7 +16656,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -17121,8 +17212,7 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.camelcase": { "version": "4.3.0", @@ -17907,6 +17997,7 @@ "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", "license": "MIT", + "peer": true, "bin": { "mustache": "bin/mustache" } @@ -17931,16 +18022,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -18032,6 +18113,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=10.5.0" } @@ -18322,6 +18404,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -18330,7 +18413,8 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/openapi-types": { "version": "12.1.3", @@ -18611,7 +18695,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -18726,7 +18809,6 @@ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^3.1.0", "node-ensure": "^0.0.0" @@ -18749,6 +18831,7 @@ "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" }, @@ -18762,7 +18845,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -18980,6 +19062,7 @@ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.58.2" }, @@ -18998,6 +19081,7 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -19015,6 +19099,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -19148,7 +19233,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19192,6 +19276,7 @@ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6.0" } @@ -19277,6 +19362,7 @@ "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", "license": "MIT", + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -19348,7 +19434,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -19460,6 +19547,7 @@ "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^4.7.0" }, @@ -19490,6 +19578,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -19500,6 +19589,7 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } @@ -19522,13 +19612,15 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", + "peer": true, "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", @@ -19571,7 +19663,6 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", "license": "MIT", - "peer": true, "workspaces": [ "./packages/*" ], @@ -19658,7 +19749,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/resolve": { "version": "1.22.11", @@ -19748,6 +19840,7 @@ "resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-2.6.0.tgz", "integrity": "sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=10.7.0" }, @@ -19906,7 +19999,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -19985,7 +20077,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -21413,6 +21504,7 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -21428,6 +21520,7 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -21593,7 +21686,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -21818,7 +21910,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -22002,7 +22093,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22160,6 +22250,7 @@ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "license": "MIT", + "peer": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -22339,6 +22430,7 @@ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", "license": "MIT", + "peer": true, "engines": { "node": ">= 14" } @@ -22425,6 +22517,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -22439,6 +22532,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -22449,6 +22543,7 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } @@ -22459,6 +22554,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -22469,6 +22565,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -22482,6 +22579,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -22685,6 +22783,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -22839,7 +22938,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -22849,6 +22947,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/src/ab-testing/ab-testing.controller.ts b/src/ab-testing/ab-testing.controller.ts index 3b0e3ea..b1e1173 100644 --- a/src/ab-testing/ab-testing.controller.ts +++ b/src/ab-testing/ab-testing.controller.ts @@ -1,4 +1,16 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, Logger, HttpCode, HttpStatus } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Logger, + HttpCode, + HttpStatus, +} from '@nestjs/common'; import { ABTestingService } from './ab-testing.service'; import { ExperimentService } from './experiments/experiment.service'; import { StatisticalAnalysisService } from './analysis/statistical-analysis.service'; @@ -54,10 +66,7 @@ export class ABTestingController { @Put('experiments/:id') @HttpCode(HttpStatus.OK) - async updateExperiment( - @Param('id') id: string, - @Body() updateData: any - ) { + async updateExperiment(@Param('id') id: string, @Body() updateData: any) { this.logger.log(`Updating experiment: ${id}`); return await this.experimentService.updateExperiment(id, updateData); } @@ -78,10 +87,7 @@ export class ABTestingController { @Post('experiments/:id/variants') @HttpCode(HttpStatus.CREATED) - async addVariant( - @Param('id') experimentId: string, - @Body() variantData: any - ) { + async addVariant(@Param('id') experimentId: string, @Body() variantData: any) { this.logger.log(`Adding variant to experiment: ${experimentId}`); return await this.experimentService.addVariant(experimentId, variantData); } @@ -98,7 +104,7 @@ export class ABTestingController { @HttpCode(HttpStatus.OK) async updateTrafficAllocation( @Param('id') experimentId: string, - @Body() allocations: Record + @Body() allocations: Record, ) { this.logger.log(`Updating traffic allocation for experiment: ${experimentId}`); await this.experimentService.updateTrafficAllocation(experimentId, allocations); @@ -119,10 +125,7 @@ export class ABTestingController { @Post('experiments/:id/auto-select-winner') @HttpCode(HttpStatus.OK) - async autoSelectWinner( - @Param('id') id: string, - @Body() criteria?: any - ) { + async autoSelectWinner(@Param('id') id: string, @Body() criteria?: any) { this.logger.log(`Auto-selecting winner for experiment: ${id}`); return await this.automatedDecisionService.autoSelectWinner(id, criteria); } @@ -197,11 +200,8 @@ export class ABTestingController { } @Get('experiments/:id/assign-user/:userId') - async assignUserToVariant( - @Param('id') experimentId: string, - @Param('userId') userId: string - ) { + async assignUserToVariant(@Param('id') experimentId: string, @Param('userId') userId: string) { this.logger.log(`Assigning user ${userId} to variant for experiment: ${experimentId}`); return await this.abTestingService.assignUserToVariant(experimentId, userId); } -} \ No newline at end of file +} diff --git a/src/ab-testing/ab-testing.module.ts b/src/ab-testing/ab-testing.module.ts index 54f963c..50ee541 100644 --- a/src/ab-testing/ab-testing.module.ts +++ b/src/ab-testing/ab-testing.module.ts @@ -13,16 +13,9 @@ import { ABTestingController } from './ab-testing.controller'; @Module({ imports: [ - TypeOrmModule.forFeature([ - Experiment, - ExperimentVariant, - ExperimentMetric, - VariantMetric - ]), - ], - controllers: [ - ABTestingController, + TypeOrmModule.forFeature([Experiment, ExperimentVariant, ExperimentMetric, VariantMetric]), ], + controllers: [ABTestingController], providers: [ ABTestingService, ExperimentService, @@ -38,4 +31,4 @@ import { ABTestingController } from './ab-testing.controller'; ABTestingReportsService, ], }) -export class ABTestingModule {} \ No newline at end of file +export class ABTestingModule {} diff --git a/src/ab-testing/ab-testing.service.ts b/src/ab-testing/ab-testing.service.ts index 24d3e5e..ea811f0 100644 --- a/src/ab-testing/ab-testing.service.ts +++ b/src/ab-testing/ab-testing.service.ts @@ -73,7 +73,7 @@ export class ABTestingService { const savedExperiment = await this.experimentRepository.save(experiment); // Create variants - const variants = createExperimentDto.variants.map(variantDto => { + const variants = createExperimentDto.variants.map((variantDto) => { const variant = new ExperimentVariant(); variant.name = variantDto.name; variant.description = variantDto.description; @@ -122,7 +122,7 @@ export class ABTestingService { this.logger.log(`Starting experiment: ${id}`); const experiment = await this.getExperimentById(id); - + if (experiment.status !== ExperimentStatus.DRAFT) { throw new Error('Only draft experiments can be started'); } @@ -132,7 +132,7 @@ export class ABTestingService { } // Validate that there's exactly one control variant - const controlVariants = experiment.variants.filter(v => v.isControl); + const controlVariants = experiment.variants.filter((v) => v.isControl); if (controlVariants.length !== 1) { throw new Error('Experiment must have exactly one control variant'); } @@ -178,7 +178,7 @@ export class ABTestingService { */ async assignUserToVariant(experimentId: string, userId: string): Promise { const experiment = await this.getExperimentById(experimentId); - + if (experiment.status !== ExperimentStatus.RUNNING) { throw new Error('Experiment is not running'); } @@ -195,9 +195,9 @@ export class ABTestingService { let hash = 0; for (let i = 0; i < userId.length; i++) { const char = userId.charCodeAt(i); - hash = ((hash << 5) - hash) + char; + hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash) % variantCount; } -} \ No newline at end of file +} diff --git a/src/ab-testing/analysis/statistical-analysis.service.ts b/src/ab-testing/analysis/statistical-analysis.service.ts index e698bce..fbfdd32 100644 --- a/src/ab-testing/analysis/statistical-analysis.service.ts +++ b/src/ab-testing/analysis/statistical-analysis.service.ts @@ -47,13 +47,13 @@ export class StatisticalAnalysisService { } // Check if any variant is statistically significant compared to control - const controlVariant = experiment.variants.find(v => v.isControl); + const controlVariant = experiment.variants.find((v) => v.isControl); if (controlVariant) { const controlMetrics = await this.getVariantMetrics(controlVariant.id); results.statisticallySignificant = await this.checkSignificanceAgainstControl( experiment.variants, controlMetrics, - experiment.confidenceLevel + experiment.confidenceLevel, ); } @@ -65,7 +65,7 @@ export class StatisticalAnalysisService { */ private async analyzeVariant(variant: ExperimentVariant, confidenceLevel: number): Promise { const metrics = await this.getVariantMetrics(variant.id); - + const analysis = { variantId: variant.id, variantName: variant.name, @@ -77,7 +77,7 @@ export class StatisticalAnalysisService { for (const metric of metrics) { const statisticalData = await this.calculateMetricStatistics(metric, confidenceLevel); analysis.metrics.push(statisticalData); - + // For overall performance, we'll use conversion rate or value depending on metric type if (statisticalData.conversionRate) { analysis.overallPerformance += statisticalData.conversionRate; @@ -92,16 +92,20 @@ export class StatisticalAnalysisService { /** * Calculates statistics for a specific metric */ - private async calculateMetricStatistics(metric: VariantMetric, confidenceLevel: number): Promise { + private async calculateMetricStatistics( + metric: VariantMetric, + confidenceLevel: number, + ): Promise { // Calculate standard error - const standardError = metric.standardDeviation && metric.sampleSize > 0 - ? metric.standardDeviation / Math.sqrt(metric.sampleSize) - : 0; + const standardError = + metric.standardDeviation && metric.sampleSize > 0 + ? metric.standardDeviation / Math.sqrt(metric.sampleSize) + : 0; // Calculate confidence interval const zScore = this.getZScore(confidenceLevel); const marginOfError = zScore * standardError; - + const confidenceIntervalLower = metric.value - marginOfError; const confidenceIntervalUpper = metric.value + marginOfError; @@ -109,7 +113,7 @@ export class StatisticalAnalysisService { const pValue = this.calculatePValue(metric.value, standardError); // Determine if statistically significant - const isStatisticallySignificant = pValue < (1 - (confidenceLevel / 100)); + const isStatisticallySignificant = pValue < 1 - confidenceLevel / 100; return { metricId: metric.id, @@ -118,8 +122,8 @@ export class StatisticalAnalysisService { conversionRate: metric.conversionRate, standardDeviation: metric.standardDeviation, confidenceInterval: [confidenceIntervalLower, confidenceIntervalUpper], - pValue: pValue, - isStatisticallySignificant: isStatisticallySignificant, + pValue, + isStatisticallySignificant, }; } @@ -138,27 +142,27 @@ export class StatisticalAnalysisService { private async checkSignificanceAgainstControl( variants: ExperimentVariant[], controlMetrics: VariantMetric[], - confidenceLevel: number + confidenceLevel: number, ): Promise { - const controlVariant = variants.find(v => v.isControl); + const controlVariant = variants.find((v) => v.isControl); if (!controlVariant) return false; for (const variant of variants) { if (variant.id === controlVariant.id) continue; const variantMetrics = await this.getVariantMetrics(variant.id); - + // Compare each metric for (let i = 0; i < controlMetrics.length && i < variantMetrics.length; i++) { const controlMetric = controlMetrics[i]; const variantMetric = variantMetrics[i]; - + const isSignificant = await this.compareMetrics( controlMetric, variantMetric, - confidenceLevel + confidenceLevel, ); - + if (isSignificant) { return true; } @@ -174,12 +178,12 @@ export class StatisticalAnalysisService { private async compareMetrics( metric1: VariantMetric, metric2: VariantMetric, - confidenceLevel: number + confidenceLevel: number, ): Promise { // Calculate pooled standard error for comparison const pooledSE = Math.sqrt( Math.pow(metric1.standardDeviation || 0, 2) / (metric1.sampleSize || 1) + - Math.pow(metric2.standardDeviation || 0, 2) / (metric2.sampleSize || 1) + Math.pow(metric2.standardDeviation || 0, 2) / (metric2.sampleSize || 1), ); // Calculate z-score for the difference @@ -198,7 +202,7 @@ export class StatisticalAnalysisService { private getZScore(confidenceLevel: number): number { const confidence = confidenceLevel / 100; const alpha = 1 - confidence; - + // Z-scores for common confidence levels const zScores: Record = { 90: 1.645, @@ -214,7 +218,7 @@ export class StatisticalAnalysisService { */ private calculatePValue(value: number, standardError: number): number { if (standardError === 0) return 1; - + // Simplified p-value calculation const zScore = Math.abs(value / standardError); // This is a very simplified approximation @@ -236,7 +240,7 @@ export class StatisticalAnalysisService { throw new Error(`Experiment with ID ${experimentId} not found`); } - const controlVariant = experiment.variants.find(v => v.isControl); + const controlVariant = experiment.variants.find((v) => v.isControl); if (!controlVariant) { throw new Error('No control variant found'); } @@ -253,7 +257,7 @@ export class StatisticalAnalysisService { effectSizes.push({ variantId: variant.id, variantName: variant.name, - effectSize: effectSize, + effectSize, interpretation: this.interpretEffectSize(effectSize), }); } @@ -261,7 +265,7 @@ export class StatisticalAnalysisService { return { experimentId: experiment.id, controlVariantId: controlVariant.id, - effectSizes: effectSizes, + effectSizes, }; } @@ -270,28 +274,28 @@ export class StatisticalAnalysisService { */ private async calculateCohensD( controlMetrics: VariantMetric[], - variantMetrics: VariantMetric[] + variantMetrics: VariantMetric[], ): Promise { if (controlMetrics.length === 0 || variantMetrics.length === 0) return 0; // Simplified Cohen's d calculation const controlMean = controlMetrics.reduce((sum, m) => sum + m.value, 0) / controlMetrics.length; const variantMean = variantMetrics.reduce((sum, m) => sum + m.value, 0) / variantMetrics.length; - + const controlStdDev = Math.sqrt( - controlMetrics.reduce((sum, m) => sum + Math.pow(m.value - controlMean, 2), 0) / - (controlMetrics.length - 1) + controlMetrics.reduce((sum, m) => sum + Math.pow(m.value - controlMean, 2), 0) / + (controlMetrics.length - 1), ); - + const variantStdDev = Math.sqrt( - variantMetrics.reduce((sum, m) => sum + Math.pow(m.value - variantMean, 2), 0) / - (variantMetrics.length - 1) + variantMetrics.reduce((sum, m) => sum + Math.pow(m.value - variantMean, 2), 0) / + (variantMetrics.length - 1), ); const pooledStdDev = Math.sqrt( - ((controlMetrics.length - 1) * Math.pow(controlStdDev, 2) + - (variantMetrics.length - 1) * Math.pow(variantStdDev, 2)) / - (controlMetrics.length + variantMetrics.length - 2) + ((controlMetrics.length - 1) * Math.pow(controlStdDev, 2) + + (variantMetrics.length - 1) * Math.pow(variantStdDev, 2)) / + (controlMetrics.length + variantMetrics.length - 2), ); return pooledStdDev > 0 ? Math.abs(variantMean - controlMean) / pooledStdDev : 0; @@ -306,4 +310,4 @@ export class StatisticalAnalysisService { if (effectSize < 0.8) return 'medium'; return 'large'; } -} \ No newline at end of file +} diff --git a/src/ab-testing/automation/automated-decision.service.ts b/src/ab-testing/automation/automated-decision.service.ts index 7360c4b..d4d1d24 100644 --- a/src/ab-testing/automation/automated-decision.service.ts +++ b/src/ab-testing/automation/automated-decision.service.ts @@ -64,8 +64,9 @@ export class AutomatedDecisionService { } // Perform statistical analysis - const statisticalResults = await this.statisticalAnalysisService.calculateStatisticalSignificance(experimentId); - + const statisticalResults = + await this.statisticalAnalysisService.calculateStatisticalSignificance(experimentId); + // Check if results are statistically significant if (!statisticalResults.statisticallySignificant) { return { @@ -111,9 +112,9 @@ export class AutomatedDecisionService { private async determineWinner( experiment: Experiment, statisticalResults: any, - criteria: WinnerSelectionCriteria + criteria: WinnerSelectionCriteria, ): Promise { - const controlVariant = experiment.variants.find(v => v.isControl); + const controlVariant = experiment.variants.find((v) => v.isControl); if (!controlVariant) return null; let bestVariant: ExperimentVariant | null = null; @@ -121,25 +122,29 @@ export class AutomatedDecisionService { // Find the variant with the best performance that meets criteria for (const variantAnalysis of statisticalResults.variants) { - const variant = experiment.variants.find(v => v.id === variantAnalysis.variantId); + const variant = experiment.variants.find((v) => v.id === variantAnalysis.variantId); if (!variant || variant.isControl) continue; // Check minimum sample size const hasSufficientSample = variantAnalysis.metrics.every( - (metric: any) => metric.sampleSize >= criteria.minimumSampleSize + (metric: any) => metric.sampleSize >= criteria.minimumSampleSize, ); if (!hasSufficientSample) continue; // Check if statistically significant const isSignificant = variantAnalysis.metrics.some( - (metric: any) => metric.isStatisticallySignificant + (metric: any) => metric.isStatisticallySignificant, ); if (!isSignificant) continue; // Check effect size - const effectSize = await this.calculateEffectSizeForVariant(experiment.id, variant.id, controlVariant.id); + const effectSize = await this.calculateEffectSizeForVariant( + experiment.id, + variant.id, + controlVariant.id, + ); if (effectSize < criteria.effectSizeThreshold) continue; // Compare performance (simplified - would be more complex in real implementation) @@ -159,7 +164,7 @@ export class AutomatedDecisionService { private async calculateEffectSizeForVariant( experimentId: string, variantId: string, - controlId: string + controlId: string, ): Promise { // This would use the statistical analysis service to calculate effect size // For now, returning a placeholder value @@ -169,13 +174,16 @@ export class AutomatedDecisionService { /** * Calculates effect size for the winning variant */ - private async calculateEffectSizeForWinner(experimentId: string, winnerId: string): Promise { + private async calculateEffectSizeForWinner( + experimentId: string, + winnerId: string, + ): Promise { const experiment = await this.experimentRepository.findOne({ where: { id: experimentId }, relations: ['variants'], }); - const controlVariant = experiment?.variants.find(v => v.isControl); + const controlVariant = experiment?.variants.find((v) => v.isControl); if (!controlVariant) return 0; return await this.calculateEffectSizeForVariant(experimentId, winnerId, controlVariant.id); @@ -214,7 +222,7 @@ export class AutomatedDecisionService { // Check if all variants have sufficient sample size const minimumSampleSize = experiment.minimumSampleSize || 100; - + for (const variant of experiment.variants) { // This would check actual sample sizes from metrics // For now, we'll assume variants are ready @@ -259,29 +267,30 @@ export class AutomatedDecisionService { if (ready) { recommendations.recommendations.push('Experiment is ready for winner selection'); - + // Get potential winner - const statisticalResults = await this.statisticalAnalysisService.calculateStatisticalSignificance(experimentId); + const statisticalResults = + await this.statisticalAnalysisService.calculateStatisticalSignificance(experimentId); if (statisticalResults.statisticallySignificant) { - const winner = await this.determineWinner( - experiment, - statisticalResults, - { - confidenceLevel: experiment.confidenceLevel || 95, - minimumSampleSize: experiment.minimumSampleSize || 100, - effectSizeThreshold: 0.1, - durationThreshold: 7, - } - ); + const winner = await this.determineWinner(experiment, statisticalResults, { + confidenceLevel: experiment.confidenceLevel || 95, + minimumSampleSize: experiment.minimumSampleSize || 100, + effectSizeThreshold: 0.1, + durationThreshold: 7, + }); if (winner) { recommendations.winnerCandidate = winner.id; - recommendations.recommendations.push(`Variant "${winner.name}" is the recommended winner`); + recommendations.recommendations.push( + `Variant "${winner.name}" is the recommended winner`, + ); } } } else { const remainingDays = Math.max(0, 7 - duration); - recommendations.recommendations.push(`Wait ${remainingDays} more days before making decision`); + recommendations.recommendations.push( + `Wait ${remainingDays} more days before making decision`, + ); } return recommendations; @@ -304,7 +313,7 @@ export class AutomatedDecisionService { // This would implement multi-armed bandit algorithm or similar // For now, we'll implement a simple performance-based allocation - + const variants = experiment.variants; if (variants.length < 2) return; @@ -313,7 +322,7 @@ export class AutomatedDecisionService { // Allocate traffic proportionally to performance scores const totalScore = performanceScores.reduce((sum, score) => sum + score.score, 0); - + for (let i = 0; i < variants.length; i++) { const variant = variants[i]; const score = performanceScores[i]; @@ -330,9 +339,9 @@ export class AutomatedDecisionService { private async calculateVariantPerformanceScores(variants: ExperimentVariant[]): Promise { // This would fetch actual performance data // For now, returning equal scores - return variants.map(variant => ({ + return variants.map((variant) => ({ variantId: variant.id, score: 1.0, // Placeholder score })); } -} \ No newline at end of file +} diff --git a/src/ab-testing/entities/experiment-metric.entity.ts b/src/ab-testing/entities/experiment-metric.entity.ts index 93c872c..4424c76 100644 --- a/src/ab-testing/entities/experiment-metric.entity.ts +++ b/src/ab-testing/entities/experiment-metric.entity.ts @@ -1,4 +1,11 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, +} from 'typeorm'; import { Experiment } from './experiment.entity'; export enum MetricType { @@ -6,7 +13,7 @@ export enum MetricType { REVENUE = 'revenue', ENGAGEMENT = 'engagement', RETENTION = 'retention', - CUSTOM = 'custom' + CUSTOM = 'custom', } @Entity({ name: 'experiment_metrics' }) @@ -23,7 +30,7 @@ export class ExperimentMetric { @Column({ type: 'enum', enum: MetricType, - default: MetricType.CONVERSION + default: MetricType.CONVERSION, }) type: MetricType; @@ -39,6 +46,6 @@ export class ExperimentMetric { @UpdateDateColumn() updatedAt: Date; - @ManyToOne(() => Experiment, experiment => experiment.metrics) + @ManyToOne(() => Experiment, (experiment) => experiment.metrics) experiment: Experiment; -} \ No newline at end of file +} diff --git a/src/ab-testing/entities/experiment-variant.entity.ts b/src/ab-testing/entities/experiment-variant.entity.ts index a60f760..b6bb38c 100644 --- a/src/ab-testing/entities/experiment-variant.entity.ts +++ b/src/ab-testing/entities/experiment-variant.entity.ts @@ -1,4 +1,12 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, +} from 'typeorm'; import { Experiment } from './experiment.entity'; import { VariantMetric } from './variant-metric.entity'; @@ -31,9 +39,9 @@ export class ExperimentVariant { @UpdateDateColumn() updatedAt: Date; - @ManyToOne(() => Experiment, experiment => experiment.variants) + @ManyToOne(() => Experiment, (experiment) => experiment.variants) experiment: Experiment; - @OneToMany(() => VariantMetric, metric => metric.variant) + @OneToMany(() => VariantMetric, (metric) => metric.variant) metrics: VariantMetric[]; -} \ No newline at end of file +} diff --git a/src/ab-testing/entities/experiment.entity.ts b/src/ab-testing/entities/experiment.entity.ts index 935a39e..9b1c119 100644 --- a/src/ab-testing/entities/experiment.entity.ts +++ b/src/ab-testing/entities/experiment.entity.ts @@ -1,4 +1,11 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; import { ExperimentVariant } from './experiment-variant.entity'; import { ExperimentMetric } from './experiment-metric.entity'; @@ -7,13 +14,13 @@ export enum ExperimentStatus { RUNNING = 'running', PAUSED = 'paused', COMPLETED = 'completed', - ARCHIVED = 'archived' + ARCHIVED = 'archived', } export enum ExperimentType { A_B_TEST = 'a_b_test', MULTIVARIATE = 'multivariate', - MULTI_ARMED_BANDIT = 'multi_armed_bandit' + MULTI_ARMED_BANDIT = 'multi_armed_bandit', } @Entity({ name: 'experiments' }) @@ -30,14 +37,14 @@ export class Experiment { @Column({ type: 'enum', enum: ExperimentType, - default: ExperimentType.A_B_TEST + default: ExperimentType.A_B_TEST, }) type: ExperimentType; @Column({ type: 'enum', enum: ExperimentStatus, - default: ExperimentStatus.DRAFT + default: ExperimentStatus.DRAFT, }) status: ExperimentStatus; @@ -74,9 +81,9 @@ export class Experiment { @UpdateDateColumn() updatedAt: Date; - @OneToMany(() => ExperimentVariant, variant => variant.experiment) + @OneToMany(() => ExperimentVariant, (variant) => variant.experiment) variants: ExperimentVariant[]; - @OneToMany(() => ExperimentMetric, metric => metric.experiment) + @OneToMany(() => ExperimentMetric, (metric) => metric.experiment) metrics: ExperimentMetric[]; -} \ No newline at end of file +} diff --git a/src/ab-testing/entities/variant-metric.entity.ts b/src/ab-testing/entities/variant-metric.entity.ts index 0c1f174..8875b8d 100644 --- a/src/ab-testing/entities/variant-metric.entity.ts +++ b/src/ab-testing/entities/variant-metric.entity.ts @@ -1,4 +1,11 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, +} from 'typeorm'; import { ExperimentVariant } from './experiment-variant.entity'; @Entity({ name: 'variant_metrics' }) @@ -36,6 +43,6 @@ export class VariantMetric { @UpdateDateColumn() updatedAt: Date; - @ManyToOne(() => ExperimentVariant, variant => variant.metrics) + @ManyToOne(() => ExperimentVariant, (variant) => variant.metrics) variant: ExperimentVariant; -} \ No newline at end of file +} diff --git a/src/ab-testing/experiments/experiment.service.ts b/src/ab-testing/experiments/experiment.service.ts index 89099dd..2869f97 100644 --- a/src/ab-testing/experiments/experiment.service.ts +++ b/src/ab-testing/experiments/experiment.service.ts @@ -46,7 +46,10 @@ export class ExperimentService { /** * Adds a variant to an experiment */ - async addVariant(experimentId: string, variantData: Partial): Promise { + async addVariant( + experimentId: string, + variantData: Partial, + ): Promise { this.logger.log(`Adding variant to experiment: ${experimentId}`); const experiment = await this.experimentRepository.findOne({ @@ -78,7 +81,10 @@ export class ExperimentService { /** * Updates traffic allocation for variants */ - async updateTrafficAllocation(experimentId: string, allocations: Record): Promise { + async updateTrafficAllocation( + experimentId: string, + allocations: Record, + ): Promise { this.logger.log(`Updating traffic allocation for experiment: ${experimentId}`); const experiment = await this.experimentRepository.findOne({ @@ -130,21 +136,18 @@ export class ExperimentService { status: experiment.status, type: experiment.type, }, - variants: experiment.variants.map(variant => ({ + variants: experiment.variants.map((variant) => ({ id: variant.id, name: variant.name, isControl: variant.isControl, isWinner: variant.isWinner, trafficAllocation: variant.trafficAllocation, - metrics: variant.metrics.map(metric => ({ + metrics: variant.metrics.map((metric) => ({ id: metric.id, value: metric.value, sampleSize: metric.sampleSize, conversionRate: metric.conversionRate, - confidenceInterval: [ - metric.confidenceIntervalLower, - metric.confidenceIntervalUpper - ], + confidenceInterval: [metric.confidenceIntervalLower, metric.confidenceIntervalUpper], pValue: metric.pValue, isStatisticallySignificant: metric.isStatisticallySignificant, })), @@ -224,4 +227,4 @@ export class ExperimentService { this.logger.log(`Experiment resumed: ${resumedExperiment.name}`); return resumedExperiment; } -} \ No newline at end of file +} diff --git a/src/ab-testing/reporting/ab-testing-reports.service.ts b/src/ab-testing/reporting/ab-testing-reports.service.ts index 931b01a..e47c0d2 100644 --- a/src/ab-testing/reporting/ab-testing-reports.service.ts +++ b/src/ab-testing/reporting/ab-testing-reports.service.ts @@ -43,8 +43,10 @@ export class ABTestingReportsService { throw new Error(`Experiment with ID ${experimentId} not found`); } - const statisticalAnalysis = await this.statisticalAnalysisService.calculateStatisticalSignificance(experimentId); - const decisionRecommendations = await this.automatedDecisionService.getDecisionRecommendations(experimentId); + const statisticalAnalysis = + await this.statisticalAnalysisService.calculateStatisticalSignificance(experimentId); + const decisionRecommendations = + await this.automatedDecisionService.getDecisionRecommendations(experimentId); const report = { experiment: { @@ -61,7 +63,7 @@ export class ABTestingReportsService { minimumSampleSize: experiment.minimumSampleSize, trafficAllocation: experiment.trafficAllocation, }, - variants: experiment.variants.map(variant => ({ + variants: experiment.variants.map((variant) => ({ id: variant.id, name: variant.name, description: variant.description, @@ -69,22 +71,19 @@ export class ABTestingReportsService { isWinner: variant.isWinner, trafficAllocation: variant.trafficAllocation, configuration: variant.configuration, - metrics: variant.metrics.map(metric => ({ + metrics: variant.metrics.map((metric) => ({ id: metric.id, value: metric.value, sampleSize: metric.sampleSize, conversionRate: metric.conversionRate, standardDeviation: metric.standardDeviation, - confidenceInterval: [ - metric.confidenceIntervalLower, - metric.confidenceIntervalUpper - ], + confidenceInterval: [metric.confidenceIntervalLower, metric.confidenceIntervalUpper], pValue: metric.pValue, isStatisticallySignificant: metric.isStatisticallySignificant, })), })), - statisticalAnalysis: statisticalAnalysis, - decisionRecommendations: decisionRecommendations, + statisticalAnalysis, + decisionRecommendations, summary: this.generateSummary(experiment, statisticalAnalysis), }; @@ -95,8 +94,8 @@ export class ABTestingReportsService { * Generates summary statistics for the experiment */ private generateSummary(experiment: Experiment, statisticalAnalysis: any): any { - const controlVariant = experiment.variants.find(v => v.isControl); - const winnerVariant = experiment.variants.find(v => v.isWinner); + const controlVariant = experiment.variants.find((v) => v.isControl); + const winnerVariant = experiment.variants.find((v) => v.isWinner); return { totalVariants: experiment.variants.length, @@ -105,8 +104,8 @@ export class ABTestingReportsService { isStatisticallySignificant: statisticalAnalysis.statisticallySignificant, duration: this.calculateExperimentDuration(experiment), status: experiment.status, - recommendations: statisticalAnalysis.statisticallySignificant - ? 'Statistically significant results found' + recommendations: statisticalAnalysis.statisticallySignificant + ? 'Statistically significant results found' : 'Continue running experiment for more data', }; } @@ -123,12 +122,13 @@ export class ABTestingReportsService { totalExperiments: experiments.length, experimentsByStatus: this.groupExperimentsByStatus(experiments), experimentsByType: this.groupExperimentsByType(experiments), - runningExperiments: experiments.filter(e => e.status === ExperimentStatus.RUNNING).length, - completedExperiments: experiments.filter(e => e.status === ExperimentStatus.COMPLETED).length, + runningExperiments: experiments.filter((e) => e.status === ExperimentStatus.RUNNING).length, + completedExperiments: experiments.filter((e) => e.status === ExperimentStatus.COMPLETED) + .length, recentExperiments: experiments.slice(0, 5), // Last 5 experiments - upcomingExperiments: experiments.filter(e => - e.status === ExperimentStatus.DRAFT && e.startDate > new Date() - ).slice(0, 5), + upcomingExperiments: experiments + .filter((e) => e.status === ExperimentStatus.DRAFT && e.startDate > new Date()) + .slice(0, 5), }; return summary; @@ -139,7 +139,7 @@ export class ABTestingReportsService { */ private async getFilteredExperiments(filters?: ReportFilters): Promise { const queryBuilder = this.experimentRepository.createQueryBuilder('experiment'); - + if (filters?.status) { queryBuilder.andWhere('experiment.status = :status', { status: filters.status }); } @@ -157,11 +157,13 @@ export class ABTestingReportsService { } if (!filters?.includeArchived) { - queryBuilder.andWhere('experiment.status != :archived', { archived: ExperimentStatus.ARCHIVED }); + queryBuilder.andWhere('experiment.status != :archived', { + archived: ExperimentStatus.ARCHIVED, + }); } queryBuilder.orderBy('experiment.createdAt', 'DESC'); - + return await queryBuilder.getMany(); } @@ -170,7 +172,7 @@ export class ABTestingReportsService { */ private groupExperimentsByStatus(experiments: Experiment[]): Record { const statusGroups: Record = {}; - + for (const experiment of experiments) { const status = experiment.status; statusGroups[status] = (statusGroups[status] || 0) + 1; @@ -184,7 +186,7 @@ export class ABTestingReportsService { */ private groupExperimentsByType(experiments: Experiment[]): Record { const typeGroups: Record = {}; - + for (const experiment of experiments) { const type = experiment.type; typeGroups[type] = (typeGroups[type] || 0) + 1; @@ -219,12 +221,16 @@ export class ABTestingReportsService { const performanceData = []; for (const experiment of experiments) { - const winner = experiment.variants.find(v => v.isWinner); - const control = experiment.variants.find(v => v.isControl); - + const winner = experiment.variants.find((v) => v.isWinner); + const control = experiment.variants.find((v) => v.isControl); + if (winner && control && winner.id !== control.id) { - const improvement = await this.calculateImprovementPercentage(experiment.id, winner.id, control.id); - + const improvement = await this.calculateImprovementPercentage( + experiment.id, + winner.id, + control.id, + ); + performanceData.push({ experimentId: experiment.id, experimentName: experiment.name, @@ -242,13 +248,18 @@ export class ABTestingReportsService { reportTitle: 'Performance Comparison Report', generatedAt: new Date(), totalComparisons: performanceData.length, - averageImprovement: performanceData.length > 0 - ? performanceData.reduce((sum, data) => sum + data.improvementPercentage, 0) / performanceData.length - : 0, - bestPerforming: performanceData.length > 0 - ? [...performanceData].sort((a, b) => b.improvementPercentage - a.improvementPercentage)[0] - : null, - performanceData: performanceData, + averageImprovement: + performanceData.length > 0 + ? performanceData.reduce((sum, data) => sum + data.improvementPercentage, 0) / + performanceData.length + : 0, + bestPerforming: + performanceData.length > 0 + ? [...performanceData].sort( + (a, b) => b.improvementPercentage - a.improvementPercentage, + )[0] + : null, + performanceData, }; } @@ -258,7 +269,7 @@ export class ABTestingReportsService { private async calculateImprovementPercentage( experimentId: string, winnerId: string, - controlId: string + controlId: string, ): Promise { // This would fetch actual metric data and calculate improvement // For now, returning a placeholder value @@ -272,10 +283,11 @@ export class ABTestingReportsService { this.logger.log(`Exporting data for experiment: ${experimentId}`); const report = await this.generateExperimentReport(experimentId); - + // Convert report to CSV format - let csv = 'Metric,Variant,Value,Sample Size,Conversion Rate,Confidence Interval,P-Value,Statistically Significant\n'; - + let csv = + 'Metric,Variant,Value,Sample Size,Conversion Rate,Confidence Interval,P-Value,Statistically Significant\n'; + for (const variant of report.variants) { for (const metric of variant.metrics) { csv += `${metric.id},${variant.name},${metric.value},${metric.sampleSize},${metric.conversionRate || ''},`; @@ -295,7 +307,7 @@ export class ABTestingReportsService { order: { startDate: 'ASC' }, }); - const timeline = experiments.map(experiment => ({ + const timeline = experiments.map((experiment) => ({ id: experiment.id, name: experiment.name, startDate: experiment.startDate, @@ -306,10 +318,10 @@ export class ABTestingReportsService { })); return { - timeline: timeline, + timeline, totalExperiments: timeline.length, startDate: timeline.length > 0 ? timeline[0].startDate : null, endDate: timeline.length > 0 ? timeline[timeline.length - 1].endDate : null, }; } -} \ No newline at end of file +} diff --git a/src/app.module.ts b/src/app.module.ts index 815ae09..1bb971a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -35,18 +35,17 @@ import { HealthModule } from './health/health.module'; ConfigModule.forRoot({ isGlobal: true, validationSchema: envValidationSchema, - }), TypeOrmModule.forRootAsync({ imports: [MonitoringModule], inject: [MetricsCollectionService], useFactory: (metricsService: MetricsCollectionService) => ({ type: 'postgres', - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432'), - username: process.env.DB_USERNAME || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - database: process.env.DB_DATABASE || 'teachlink', + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5432'), + username: process.env.DATABASE_USER || 'postgres', + password: process.env.DATABASE_PASSWORD || 'postgres', + database: process.env.DATABASE_NAME || 'teachlink', autoLoadEntities: true, synchronize: process.env.NODE_ENV !== 'production', logging: true, diff --git a/src/assessment/assessment.controller.ts b/src/assessment/assessment.controller.ts index 4300e15..50338b7 100644 --- a/src/assessment/assessment.controller.ts +++ b/src/assessment/assessment.controller.ts @@ -1,25 +1,17 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { AssessmentsService } from './assessments.service'; - - @Controller('assessments') export class AssessmentsController { constructor(private readonly service: AssessmentsService) {} @Post(':id/start') - start( - @Param('id') id: string, - @Body('studentId') studentId: string, - ) { + start(@Param('id') id: string, @Body('studentId') studentId: string) { return this.service.startAssessment(studentId, id); } @Post('attempts/:id/submit') - submit( - @Param('id') id: string, - @Body('answers') answers: any[], - ) { + submit(@Param('id') id: string, @Body('answers') answers: any[]) { return this.service.submitAssessment(id, answers); } diff --git a/src/assessment/assessment.module.ts b/src/assessment/assessment.module.ts index d5d3b4c..897fb2a 100644 --- a/src/assessment/assessment.module.ts +++ b/src/assessment/assessment.module.ts @@ -1,24 +1,17 @@ -import { TypeOrmModule } from "@nestjs/typeorm"; -import { AssessmentsController } from "./assessment.controller"; -import { AssessmentsService } from "./assessments.service"; -import { Answer } from "./entities/answer.entity"; -import { AssessmentAttempt } from "./entities/assessment-attempt.entity"; -import { Assessment } from "./entities/assessment.entity"; -import { Question } from "./entities/question.entity"; -import { FeedbackGenerationService } from "./feedback/feedback-generation.service"; -import { QuestionBankService } from "./questions/question-bank.service"; -import { ScoreCalculationService } from "./scoring/score-calculation.service"; -import { Module } from "@nestjs/common"; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssessmentsController } from './assessment.controller'; +import { AssessmentsService } from './assessments.service'; +import { Answer } from './entities/answer.entity'; +import { AssessmentAttempt } from './entities/assessment-attempt.entity'; +import { Assessment } from './entities/assessment.entity'; +import { Question } from './entities/question.entity'; +import { FeedbackGenerationService } from './feedback/feedback-generation.service'; +import { QuestionBankService } from './questions/question-bank.service'; +import { ScoreCalculationService } from './scoring/score-calculation.service'; +import { Module } from '@nestjs/common'; @Module({ - imports: [ - TypeOrmModule.forFeature([ - Assessment, - Question, - AssessmentAttempt, - Answer, - ]), - ], + imports: [TypeOrmModule.forFeature([Assessment, Question, AssessmentAttempt, Answer])], controllers: [AssessmentsController], providers: [ AssessmentsService, diff --git a/src/assessment/assessments.service.ts b/src/assessment/assessments.service.ts index ff00588..4adcd09 100644 --- a/src/assessment/assessments.service.ts +++ b/src/assessment/assessments.service.ts @@ -1,12 +1,12 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { AssessmentStatus } from "./enums/assessment-status.enum"; -import { Assessment } from "./entities/assessment.entity"; -import { AssessmentAttempt } from "./entities/assessment-attempt.entity"; -import { FeedbackGenerationService } from "./feedback/feedback-generation.service"; -import { Answer } from "./entities/answer.entity"; -import { ScoreCalculationService } from "./scoring/score-calculation.service"; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssessmentStatus } from './enums/assessment-status.enum'; +import { Assessment } from './entities/assessment.entity'; +import { AssessmentAttempt } from './entities/assessment-attempt.entity'; +import { FeedbackGenerationService } from './feedback/feedback-generation.service'; +import { Answer } from './entities/answer.entity'; +import { ScoreCalculationService } from './scoring/score-calculation.service'; @Injectable() export class AssessmentsService { @@ -75,8 +75,7 @@ export class AssessmentsService { }); const endTime = - new Date(attempt.startedAt).getTime() + - attempt.assessment.durationMinutes * 60000; + new Date(attempt.startedAt).getTime() + attempt.assessment.durationMinutes * 60000; if (Date.now() > endTime) { attempt.status = AssessmentStatus.TIMED_OUT; @@ -87,9 +86,7 @@ export class AssessmentsService { let maxScore = 0; for (const question of attempt.assessment.questions) { - const response = answers.find( - (a) => a.questionId === question.id, - )?.response; + const response = answers.find((a) => a.questionId === question.id)?.response; const score = this.scoringService.calculate(question, response); maxScore += question.points; diff --git a/src/assessment/entities/answer.entity.ts b/src/assessment/entities/answer.entity.ts index 3384166..06a02ce 100644 --- a/src/assessment/entities/answer.entity.ts +++ b/src/assessment/entities/answer.entity.ts @@ -1,8 +1,7 @@ -import { - Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn -} from "typeorm"; -import { AssessmentAttempt } from "./assessment-attempt.entity"; -import { Question } from "./question.entity";@Entity() +import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { AssessmentAttempt } from './assessment-attempt.entity'; +import { Question } from './question.entity'; +@Entity() export class Answer { @PrimaryGeneratedColumn('uuid') id: string; @@ -14,7 +13,7 @@ export class Answer { question: Question; @Column({ type: 'json' }) - response: string| any; + response: string | any; @Column({ nullable: true }) awardedPoints?: number; diff --git a/src/assessment/entities/assessment-attempt.entity.ts b/src/assessment/entities/assessment-attempt.entity.ts index d3f8712..aa37f7d 100644 --- a/src/assessment/entities/assessment-attempt.entity.ts +++ b/src/assessment/entities/assessment-attempt.entity.ts @@ -1,7 +1,7 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from "typeorm"; -import { AssessmentStatus } from "../enums/assessment-status.enum"; -import { Answer } from "./answer.entity"; -import { Assessment } from "./assessment.entity"; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm'; +import { AssessmentStatus } from '../enums/assessment-status.enum'; +import { Answer } from './answer.entity'; +import { Assessment } from './assessment.entity'; @Entity() export class AssessmentAttempt { diff --git a/src/assessment/entities/assessment.entity.ts b/src/assessment/entities/assessment.entity.ts index f38cfd9..5612f60 100644 --- a/src/assessment/entities/assessment.entity.ts +++ b/src/assessment/entities/assessment.entity.ts @@ -1,7 +1,5 @@ -import { - Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn -} from "typeorm"; -import { Question } from "./question.entity"; +import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { Question } from './question.entity'; @Entity() export class Assessment { diff --git a/src/assessment/entities/question.entity.ts b/src/assessment/entities/question.entity.ts index d378a25..8dc1b75 100644 --- a/src/assessment/entities/question.entity.ts +++ b/src/assessment/entities/question.entity.ts @@ -1,6 +1,6 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { QuestionType } from "../enums/question-type.enum"; -import { Assessment } from "./assessment.entity"; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { QuestionType } from '../enums/question-type.enum'; +import { Assessment } from './assessment.entity'; @Entity() export class Question { diff --git a/src/assessment/feedback/feedback-generation.service.ts b/src/assessment/feedback/feedback-generation.service.ts index 953e432..630442a 100644 --- a/src/assessment/feedback/feedback-generation.service.ts +++ b/src/assessment/feedback/feedback-generation.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable } from '@nestjs/common'; @Injectable() export class FeedbackGenerationService { diff --git a/src/assessment/questions/question-bank.service.ts b/src/assessment/questions/question-bank.service.ts index 84c9cdd..5582302 100644 --- a/src/assessment/questions/question-bank.service.ts +++ b/src/assessment/questions/question-bank.service.ts @@ -1,7 +1,7 @@ -import { Question } from "../entities/question.entity"; -import { Repository } from "typeorm"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; +import { Question } from '../entities/question.entity'; +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; @Injectable() export class QuestionBankService { diff --git a/src/assessment/scoring/score-calculation.service.ts b/src/assessment/scoring/score-calculation.service.ts index fbf6d8b..060be7b 100644 --- a/src/assessment/scoring/score-calculation.service.ts +++ b/src/assessment/scoring/score-calculation.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from "@nestjs/common"; -import { Question } from "../entities/question.entity"; -import { QuestionType } from "../enums/question-type.enum"; +import { Injectable } from '@nestjs/common'; +import { Question } from '../entities/question.entity'; +import { QuestionType } from '../enums/question-type.enum'; @Injectable() export class ScoreCalculationService { @@ -8,9 +8,7 @@ export class ScoreCalculationService { switch (question.type) { case QuestionType.MULTIPLE_CHOICE: case QuestionType.TRUE_FALSE: - return response === question.correctAnswer - ? question.points - : 0; + return response === question.correctAnswer ? question.points : 0; case QuestionType.CODING: // Placeholder (extend with judge later) diff --git a/src/audit-log/audit-log.entity.ts b/src/audit-log/audit-log.entity.ts index 95ec4d2..2a73cbb 100644 --- a/src/audit-log/audit-log.entity.ts +++ b/src/audit-log/audit-log.entity.ts @@ -1,6 +1,6 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from "typeorm"; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; -@Entity("audit_logs") +@Entity('audit_logs') export class AuditLog { @PrimaryGeneratedColumn() id: number; diff --git a/src/audit-log/audit-log.module.ts b/src/audit-log/audit-log.module.ts index f71bcac..7107d30 100644 --- a/src/audit-log/audit-log.module.ts +++ b/src/audit-log/audit-log.module.ts @@ -1,7 +1,7 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; -import { AuditLog } from "./audit-log.entity"; -import { AuditLogService } from "./audit-log.service"; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './audit-log.entity'; +import { AuditLogService } from './audit-log.service'; @Module({ imports: [TypeOrmModule.forFeature([AuditLog])], diff --git a/src/audit-log/audit-log.service.ts b/src/audit-log/audit-log.service.ts index d61a671..4625f0b 100644 --- a/src/audit-log/audit-log.service.ts +++ b/src/audit-log/audit-log.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { AuditLog } from "./audit-log.entity"; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from './audit-log.entity'; @Injectable() export class AuditLogService { @@ -16,14 +16,14 @@ export class AuditLogService { } async findAll(): Promise { - return this.auditRepo.find({ order: { timestamp: "DESC" } }); + return this.auditRepo.find({ order: { timestamp: 'DESC' } }); } async findByUser(userId: string): Promise { - return this.auditRepo.find({ where: { userId }, order: { timestamp: "DESC" } }); + return this.auditRepo.find({ where: { userId }, order: { timestamp: 'DESC' } }); } async findByAction(action: string): Promise { - return this.auditRepo.find({ where: { action }, order: { timestamp: "DESC" } }); + return this.auditRepo.find({ where: { action }, order: { timestamp: 'DESC' } }); } } diff --git a/src/audit-log/tests/audit-log.test.ts b/src/audit-log/tests/audit-log.test.ts index ad32eee..226b8d1 100644 --- a/src/audit-log/tests/audit-log.test.ts +++ b/src/audit-log/tests/audit-log.test.ts @@ -1,19 +1,21 @@ -import { AuditLogService } from "../audit-log.service"; -import { Repository } from "typeorm"; -import { AuditLog } from "../audit-log.entity"; +import { AuditLogService } from '../audit-log.service'; +import { Repository } from 'typeorm'; +import { AuditLog } from '../audit-log.entity'; +import { getRepositoryToken } from '@nestjs/typeorm'; -describe("AuditLogService", () => { +describe('AuditLogService', () => { let service: AuditLogService; let repo: Repository; beforeEach(() => { - repo = new Repository(); + // Mock repository without calling constructor + repo = {} as Repository; service = new AuditLogService(repo as any); }); - it("records an audit log", async () => { - const log = await service.record("user1", "TIP_SENT", "receiver:user2"); - expect(log.userId).toBe("user1"); - expect(log.action).toBe("TIP_SENT"); + it('records an audit log', async () => { + const log = await service.record('user1', 'TIP_SENT', 'receiver:user2'); + expect(log.userId).toBe('user1'); + expect(log.action).toBe('TIP_SENT'); }); }); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c42069a..d54aeac 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,10 +1,4 @@ -import { - Controller, - Post, - Body, - UseGuards, - Req, -} from '@nestjs/common'; +import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { @@ -66,10 +60,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Change password for authenticated user' }) - async changePassword( - @CurrentUser() user: any, - @Body() changePasswordDto: ChangePasswordDto, - ) { + async changePassword(@CurrentUser() user: any, @Body() changePasswordDto: ChangePasswordDto) { return this.authService.changePassword(user.userId, changePasswordDto); } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 6e3001c..38993d0 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -17,15 +17,10 @@ import { JwtStrategy } from './strategies/jwt.strategy'; inject: [ConfigService], useFactory: async ( configService: ConfigService, - ): Promise<{ - secret: string; - signOptions: { expiresIn: string }; - }> => ({ - secret: - configService.get('JWT_SECRET') ?? 'your-secret-key', + ): Promise<{ secret: string; signOptions: { expiresIn: number } }> => ({ + secret: configService.get('JWT_SECRET') ?? 'your-secret-key', signOptions: { - expiresIn: - configService.get('JWT_EXPIRES_IN') ?? '15m', + expiresIn: parseInt(configService.get('JWT_EXPIRES_IN') ?? '900', 10), // Convert to seconds (number) }, }), }), @@ -34,4 +29,4 @@ import { JwtStrategy } from './strategies/jwt.strategy'; providers: [AuthService, JwtStrategy], exports: [AuthService], }) -export class AuthModule {} \ No newline at end of file +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 203829e..97b853f 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -182,10 +182,7 @@ export class AuthService { const user = await this.usersService.findOne(userId); // Verify current password - const isPasswordValid = await bcrypt.compare( - changePasswordDto.currentPassword, - user.password, - ); + const isPasswordValid = await bcrypt.compare(changePasswordDto.currentPassword, user.password); if (!isPasswordValid) { throw new BadRequestException('Current password is incorrect'); } @@ -232,7 +229,10 @@ export class AuthService { }), this.jwtService.signAsync(payload, { secret: this.configService.get('JWT_REFRESH_SECRET') || 'refresh-secret-key', - expiresIn: parseInt(this.configService.get('JWT_REFRESH_EXPIRES_IN') || '604800', 10), // 604800s = 7d + expiresIn: parseInt( + this.configService.get('JWT_REFRESH_EXPIRES_IN') || '604800', + 10, + ), // 604800s = 7d }), ]); diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts index 7919497..461622c 100644 --- a/src/auth/decorators/current-user.decorator.ts +++ b/src/auth/decorators/current-user.decorator.ts @@ -1,8 +1,6 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -export const CurrentUser = createParamDecorator( - (data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - return request.user; - }, -); +export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; +}); diff --git a/src/auth/guards/ws-jwt-auth.guard.ts b/src/auth/guards/ws-jwt-auth.guard.ts index ad67111..e9decba 100644 --- a/src/auth/guards/ws-jwt-auth.guard.ts +++ b/src/auth/guards/ws-jwt-auth.guard.ts @@ -1,6 +1,6 @@ -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; -import { Socket } from "socket.io"; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Socket } from 'socket.io'; @Injectable() export class WsJwtAuthGuard implements CanActivate { @@ -9,8 +9,7 @@ export class WsJwtAuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const client: Socket = context.switchToWs().getClient(); const token = - client.handshake.auth?.token || - client.handshake.headers?.authorization?.split(" ")[1]; + client.handshake.auth?.token || client.handshake.headers?.authorization?.split(' ')[1]; if (!token) { client.disconnect(true); diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 99c04db..4c7ac1b 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -1,6 +1,6 @@ -import { Injectable } from "@nestjs/common"; -import { PassportStrategy } from "@nestjs/passport"; -import { ExtractJwt, Strategy } from "passport-jwt"; +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { diff --git a/src/backup/backup.controller.ts b/src/backup/backup.controller.ts index 8437cf8..6b36f4f 100644 --- a/src/backup/backup.controller.ts +++ b/src/backup/backup.controller.ts @@ -30,18 +30,14 @@ export class BackupController { @Post('restore') @ApiOperation({ summary: 'Restore from backup' }) @HttpCode(HttpStatus.ACCEPTED) - async restoreBackup( - @Body() dto: RestoreBackupDto, - ): Promise<{ message: string }> { + async restoreBackup(@Body() dto: RestoreBackupDto): Promise<{ message: string }> { await this.disasterRecoveryService.executeRestore(dto.backupRecordId); return { message: 'Restore initiated' }; } @Post('test') @ApiOperation({ summary: 'Trigger recovery test' }) - async triggerRecoveryTest( - @Body() dto: TriggerRecoveryTestDto, - ): Promise { + async triggerRecoveryTest(@Body() dto: TriggerRecoveryTestDto): Promise { return this.recoveryTestingService.createRecoveryTest(dto.backupRecordId); } diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts index a377f33..6ae7a97 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -28,10 +28,7 @@ export class BackupService { private readonly alertingService: AlertingService, private readonly metricsService: MetricsCollectionService, ) { - this.retentionDays = this.configService.get( - 'BACKUP_RETENTION_DAYS', - 30, - ); + this.retentionDays = this.configService.get('BACKUP_RETENTION_DAYS', 30); } /** @@ -46,13 +43,8 @@ export class BackupService { try { const region = - (this.configService.get( - 'BACKUP_PRIMARY_REGION', - ) as Region) || Region.US_EAST_1; - const databaseName = this.configService.get( - 'DB_DATABASE', - 'teachlink', - ); + (this.configService.get('BACKUP_PRIMARY_REGION') as Region) || Region.US_EAST_1; + const databaseName = this.configService.get('DB_DATABASE', 'teachlink'); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + this.retentionDays); @@ -121,9 +113,7 @@ export class BackupService { }, }); - this.logger.log( - `Found ${expiredBackups.length} expired backups to cleanup`, - ); + this.logger.log(`Found ${expiredBackups.length} expired backups to cleanup`); for (const backup of expiredBackups) { await this.backupQueue.add( diff --git a/src/backup/disaster-recovery/disaster-recovery.service.ts b/src/backup/disaster-recovery/disaster-recovery.service.ts index ef7528d..e9a7601 100644 --- a/src/backup/disaster-recovery/disaster-recovery.service.ts +++ b/src/backup/disaster-recovery/disaster-recovery.service.ts @@ -92,10 +92,7 @@ export class DisasterRecoveryService { } } - private async restoreDatabase( - dbName: string, - backupFile: string, - ): Promise { + private async restoreDatabase(dbName: string, backupFile: string): Promise { const host = this.configService.get('DB_HOST', 'localhost'); const port = this.configService.get('DB_PORT', '5432'); const username = this.configService.get('DB_USERNAME', 'postgres'); diff --git a/src/backup/integrity/data-integrity.service.ts b/src/backup/integrity/data-integrity.service.ts index 23b73aa..d45df82 100644 --- a/src/backup/integrity/data-integrity.service.ts +++ b/src/backup/integrity/data-integrity.service.ts @@ -28,17 +28,13 @@ export class DataIntegrityService { } if (!backup.encryptedStorageKey) { - this.logger.error( - `Backup ${backupId} has no encrypted storage key, cannot verify`, - ); + this.logger.error(`Backup ${backupId} has no encrypted storage key, cannot verify`); return false; } try { // Download backup from S3 - const backupData = await this.fileStorageService.downloadFile( - backup.encryptedStorageKey, - ); + const backupData = await this.fileStorageService.downloadFile(backup.encryptedStorageKey); // Save to temp file const tempFile = `/tmp/verify-${backupId}.backup`; @@ -64,24 +60,16 @@ export class DataIntegrityService { return false; } } catch (error) { - this.logger.error( - `Error verifying backup ${backupId} integrity:`, - error, - ); + this.logger.error(`Error verifying backup ${backupId} integrity:`, error); return false; } } - async calculateChecksums( - filePath: string, - ): Promise<{ md5: string; sha256: string }> { + async calculateChecksums(filePath: string): Promise<{ md5: string; sha256: string }> { const fileBuffer = await fs.promises.readFile(filePath); const md5 = crypto.createHash('md5').update(fileBuffer).digest('hex'); - const sha256 = crypto - .createHash('sha256') - .update(fileBuffer) - .digest('hex'); + const sha256 = crypto.createHash('sha256').update(fileBuffer).digest('hex'); return { md5, sha256 }; } diff --git a/src/backup/processing/backup-queue.processor.ts b/src/backup/processing/backup-queue.processor.ts index 1ba0b21..2ce6546 100644 --- a/src/backup/processing/backup-queue.processor.ts +++ b/src/backup/processing/backup-queue.processor.ts @@ -72,10 +72,7 @@ export class BackupQueueProcessor { try { // Step 1: Create pg_dump - await this.backupService.updateBackupStatus( - backupRecordId, - BackupStatus.IN_PROGRESS, - ); + await this.backupService.updateBackupStatus(backupRecordId, BackupStatus.IN_PROGRESS); const dumpStartTime = Date.now(); const tempFile = path.join('/tmp', `backup-${backupRecordId}.sql`); @@ -92,11 +89,7 @@ export class BackupQueueProcessor { const storageKey = `backups/${backup.region}/${databaseName}/${backupRecordId}.sql`; const fileBuffer = await fs.promises.readFile(tempFile); - await this.fileStorageService.uploadProcessedFile( - fileBuffer, - storageKey, - 'application/sql', - ); + await this.fileStorageService.uploadProcessedFile(fileBuffer, storageKey, 'application/sql'); const uploadDuration = Date.now() - uploadStartTime; backup.storageKey = storageKey; @@ -116,18 +109,13 @@ export class BackupQueueProcessor { 'BACKUP_SECONDARY_REGION', 'us-west-2', ); - const replicatedKey = await this.replicateToRegion( - encryptedKey, - secondaryRegion, - ); + const replicatedKey = await this.replicateToRegion(encryptedKey, secondaryRegion); backup.replicatedStorageKey = replicatedKey; const replicationDuration = Date.now() - replicationStartTime; // Step 5: Calculate checksums - const checksums = await this.dataIntegrityService.calculateChecksums( - tempFile, - ); + const checksums = await this.dataIntegrityService.calculateChecksums(tempFile); backup.checksumMd5 = checksums.md5; backup.checksumSha256 = checksums.sha256; @@ -169,32 +157,22 @@ export class BackupQueueProcessor { this.logger.log(`Verifying backup integrity: ${backupRecordId}`); try { - const isValid = await this.dataIntegrityService.verifyBackupIntegrity( - backupRecordId, - ); + const isValid = await this.dataIntegrityService.verifyBackupIntegrity(backupRecordId); if (isValid) { - await this.backupService.updateBackupStatus( - backupRecordId, - BackupStatus.COMPLETED, - { - integrityVerified: true, - verifiedAt: new Date(), - completedAt: new Date(), - }, - ); + await this.backupService.updateBackupStatus(backupRecordId, BackupStatus.COMPLETED, { + integrityVerified: true, + verifiedAt: new Date(), + completedAt: new Date(), + }); this.logger.log(`Backup ${backupRecordId} verified successfully`); } else { throw new Error('Backup integrity verification failed'); } } catch (error) { - await this.backupService.updateBackupStatus( - backupRecordId, - BackupStatus.FAILED, - { - errorMessage: `Verification failed: ${error.message}`, - }, - ); + await this.backupService.updateBackupStatus(backupRecordId, BackupStatus.FAILED, { + errorMessage: `Verification failed: ${error.message}`, + }); throw error; } } @@ -269,10 +247,7 @@ export class BackupQueueProcessor { return `PGPASSWORD="${password}" pg_dump -h ${host} -p ${port} -U ${username} -F c -b -v -f ${outputFile} ${databaseName}`; } - private async encryptBackup( - storageKey: string, - kmsKeyId: string, - ): Promise { + private async encryptBackup(storageKey: string, kmsKeyId: string): Promise { const encryptedKey = `${storageKey}.encrypted`; // Download from S3, encrypt with KMS, re-upload @@ -294,17 +269,11 @@ export class BackupQueueProcessor { return encryptedKey; } - private async replicateToRegion( - storageKey: string, - targetRegion: string, - ): Promise { + private async replicateToRegion(storageKey: string, targetRegion: string): Promise { this.logger.log(`Replicating ${storageKey} to ${targetRegion}`); const sourceBucket = this.configService.get('AWS_S3_BUCKET', ''); - const targetBucket = this.configService.get( - 'AWS_S3_BUCKET_SECONDARY', - sourceBucket, - ); + const targetBucket = this.configService.get('AWS_S3_BUCKET_SECONDARY', sourceBucket); const targetKey = storageKey.replace(`backups/`, `backups-${targetRegion}/`); diff --git a/src/backup/testing/recovery-testing.service.ts b/src/backup/testing/recovery-testing.service.ts index 555eb8b..33de439 100644 --- a/src/backup/testing/recovery-testing.service.ts +++ b/src/backup/testing/recovery-testing.service.ts @@ -120,9 +120,7 @@ export class RecoveryTestingService { // Step 5: Validate const validationStartTime = Date.now(); - const validationResults = await this.validateRestoredDatabase( - test.testDatabaseName, - ); + const validationResults = await this.validateRestoredDatabase(test.testDatabaseName); const validationDuration = Date.now() - validationStartTime; // Step 6: Cleanup @@ -193,10 +191,7 @@ export class RecoveryTestingService { await client.end(); } - private async restoreDatabase( - dbName: string, - backupFile: string, - ): Promise { + private async restoreDatabase(dbName: string, backupFile: string): Promise { const host = this.configService.get('DB_HOST', 'localhost'); const port = this.configService.get('DB_PORT', '5432'); const username = this.configService.get('DB_USERNAME', 'postgres'); diff --git a/src/caching/caching.module.ts b/src/caching/caching.module.ts index 211fc90..3026c9f 100644 --- a/src/caching/caching.module.ts +++ b/src/caching/caching.module.ts @@ -9,4 +9,4 @@ import { CacheModule } from '@nestjs/cache-manager'; ], exports: [CacheModule], }) -export class CachingModule {} \ No newline at end of file +export class CachingModule {} diff --git a/src/cdn/caching/edge-caching.service.ts b/src/cdn/caching/edge-caching.service.ts index 3c9c298..d210dac 100644 --- a/src/cdn/caching/edge-caching.service.ts +++ b/src/cdn/caching/edge-caching.service.ts @@ -24,10 +24,7 @@ export class EdgeCachingService { async getEdgeUrl(originalUrl: string, location?: string): Promise { // In a real implementation, this would return the CDN URL for the optimal edge location // For now, just return the original URL with CDN prefix - const cdnUrl = originalUrl.replace( - /^https?:\/\/[^\/]+/, - 'https://cdn.example.com' - ); + const cdnUrl = originalUrl.replace(/^https?:\/\/[^\/]+/, 'https://cdn.example.com'); // Add location-based routing if needed if (location) { @@ -67,9 +64,9 @@ export class EdgeCachingService { } // Combine results - const success = results.every(r => r.success); - const purgedUrls = results.flatMap(r => r.purgedUrls); - const failedUrls = results.flatMap(r => r.failedUrls); + const success = results.every((r) => r.success); + const purgedUrls = results.flatMap((r) => r.purgedUrls); + const failedUrls = results.flatMap((r) => r.failedUrls); return { success, diff --git a/src/cdn/cdn.controller.ts b/src/cdn/cdn.controller.ts index f396cb3..f541dcc 100644 --- a/src/cdn/cdn.controller.ts +++ b/src/cdn/cdn.controller.ts @@ -65,10 +65,7 @@ export class CdnController { return result; } catch (error) { this.logger.error('Upload failed:', error); - throw new HttpException( - `Upload failed: ${error.message}`, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new HttpException(`Upload failed: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -113,10 +110,7 @@ export class CdnController { if (error.message.includes('not found')) { throw new HttpException('Content not found', HttpStatus.NOT_FOUND); } - throw new HttpException( - 'Failed to retrieve content', - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new HttpException('Failed to retrieve content', HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -132,10 +126,7 @@ export class CdnController { return { success: true }; } catch (error) { this.logger.error(`Failed to invalidate content ${contentId}:`, error); - throw new HttpException( - 'Failed to invalidate content', - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new HttpException('Failed to invalidate content', HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -160,10 +151,7 @@ export class CdnController { timestamp: new Date().toISOString(), }; } catch (error) { - throw new HttpException( - 'Health check failed', - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new HttpException('Health check failed', HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -177,7 +165,9 @@ export class CdnController { @Query('endDate') endDate?: string, ): Promise { try { - const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const start = startDate + ? new Date(startDate) + : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const end = endDate ? new Date(endDate) : new Date(); // In a real implementation, aggregate analytics from providers @@ -192,10 +182,7 @@ export class CdnController { }, }; } catch (error) { - throw new HttpException( - 'Failed to retrieve analytics', - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new HttpException('Failed to retrieve analytics', HttpStatus.INTERNAL_SERVER_ERROR); } } } diff --git a/src/cdn/cdn.service.ts b/src/cdn/cdn.service.ts index 37bb183..342ec53 100644 --- a/src/cdn/cdn.service.ts +++ b/src/cdn/cdn.service.ts @@ -35,10 +35,7 @@ export class CdnService { private cloudflareService: CloudflareService, ) {} - async deliverContent( - contentId: string, - options: ContentDeliveryOptions = {}, - ): Promise { + async deliverContent(contentId: string, options: ContentDeliveryOptions = {}): Promise { const cacheKey = `cdn:${contentId}:${JSON.stringify(options)}`; // Check cache first @@ -57,17 +54,12 @@ export class CdnService { await this.updateAccessStats(metadata); // Determine optimal delivery strategy - const optimalLocation = await this.geoLocationService.getOptimalLocation( - options.userLocation, - ); + const optimalLocation = await this.geoLocationService.getOptimalLocation(options.userLocation); // Optimize content if needed let deliveryUrl = metadata.cdnUrl || metadata.originalUrl; if (options.optimize && metadata.contentType === ContentType.IMAGE) { - deliveryUrl = await this.assetOptimizationService.optimizeImage( - deliveryUrl, - options, - ); + deliveryUrl = await this.assetOptimizationService.optimizeImage(deliveryUrl, options); } // Apply bandwidth optimization @@ -76,10 +68,7 @@ export class CdnService { } // Get edge-cached URL - const edgeUrl = await this.edgeCachingService.getEdgeUrl( - deliveryUrl, - optimalLocation, - ); + const edgeUrl = await this.edgeCachingService.getEdgeUrl(deliveryUrl, optimalLocation); // Cache the result await this.cacheManager.set(cacheKey, edgeUrl, 3600000); // 1 hour @@ -118,13 +107,15 @@ export class CdnService { status: ContentStatus.READY, etag: uploadResult.etag, provider: uploadResult.provider, - optimizationSettings: options.optimize ? { - width: options.width, - height: options.height, - quality: options.quality, - format: options.format, - responsive: options.responsive, - } : undefined, + optimizationSettings: options.optimize + ? { + width: options.width, + height: options.height, + quality: options.quality, + format: options.format, + responsive: options.responsive, + } + : undefined, }); // Store metadata @@ -204,14 +195,15 @@ export class CdnService { // Generate responsive variants if requested let variants = []; if (options.responsive) { - variants = await this.assetOptimizationService.generateResponsiveImages( - metadata.cdnUrl, - ); + variants = await this.assetOptimizationService.generateResponsiveImages(metadata.cdnUrl); } metadata.status = ContentStatus.OPTIMIZED; - metadata.optimizedSize = variants.reduce((total, variant) => total + variant.optimizedSize, 0); - metadata.variants = variants.map(v => ({ + metadata.optimizedSize = variants.reduce( + (total, variant) => total + variant.optimizedSize, + 0, + ); + metadata.variants = variants.map((v) => ({ name: v.url.split('/').pop(), url: v.url, width: options.width || 0, diff --git a/src/cdn/entities/content-metadata.entity.ts b/src/cdn/entities/content-metadata.entity.ts index 009e403..a0d42b0 100644 --- a/src/cdn/entities/content-metadata.entity.ts +++ b/src/cdn/entities/content-metadata.entity.ts @@ -1,4 +1,11 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; export enum ContentType { IMAGE = 'image', diff --git a/src/cdn/geo/geo-location.service.ts b/src/cdn/geo/geo-location.service.ts index a4e2b91..f7b22c1 100644 --- a/src/cdn/geo/geo-location.service.ts +++ b/src/cdn/geo/geo-location.service.ts @@ -26,12 +26,60 @@ export interface EdgeLocation { export class GeoLocationService { private readonly logger = new Logger(GeoLocationService.name); private edgeLocations: EdgeLocation[] = [ - { id: 'us-east-1', name: 'Virginia', country: 'US', latitude: 39.0438, longitude: -77.4874, provider: 'cloudflare', priority: 1 }, - { id: 'us-west-1', name: 'California', country: 'US', latitude: 37.7749, longitude: -122.4194, provider: 'cloudflare', priority: 2 }, - { id: 'eu-west-1', name: 'Ireland', country: 'IE', latitude: 53.1424, longitude: -7.6921, provider: 'cloudflare', priority: 1 }, - { id: 'eu-central-1', name: 'Germany', country: 'DE', latitude: 50.1109, longitude: 8.6821, provider: 'cloudflare', priority: 2 }, - { id: 'ap-southeast-1', name: 'Singapore', country: 'SG', latitude: 1.3521, longitude: 103.8198, provider: 'cloudflare', priority: 1 }, - { id: 'ap-northeast-1', name: 'Japan', country: 'JP', latitude: 35.6762, longitude: 139.6503, provider: 'cloudflare', priority: 2 }, + { + id: 'us-east-1', + name: 'Virginia', + country: 'US', + latitude: 39.0438, + longitude: -77.4874, + provider: 'cloudflare', + priority: 1, + }, + { + id: 'us-west-1', + name: 'California', + country: 'US', + latitude: 37.7749, + longitude: -122.4194, + provider: 'cloudflare', + priority: 2, + }, + { + id: 'eu-west-1', + name: 'Ireland', + country: 'IE', + latitude: 53.1424, + longitude: -7.6921, + provider: 'cloudflare', + priority: 1, + }, + { + id: 'eu-central-1', + name: 'Germany', + country: 'DE', + latitude: 50.1109, + longitude: 8.6821, + provider: 'cloudflare', + priority: 2, + }, + { + id: 'ap-southeast-1', + name: 'Singapore', + country: 'SG', + latitude: 1.3521, + longitude: 103.8198, + provider: 'cloudflare', + priority: 1, + }, + { + id: 'ap-northeast-1', + name: 'Japan', + country: 'JP', + latitude: 35.6762, + longitude: 139.6503, + provider: 'cloudflare', + priority: 2, + }, ]; constructor(private configService: ConfigService) {} @@ -83,17 +131,14 @@ export class GeoLocationService { return optimalLocation.id; } - async getNearestEdgeLocations( - userLocation: string, - limit: number = 3, - ): Promise { + async getNearestEdgeLocations(userLocation: string, limit: number = 3): Promise { const userCoords = await this.getCoordinates(userLocation); if (!userCoords) { return this.edgeLocations.slice(0, limit); } const sortedLocations = this.edgeLocations - .map(location => ({ + .map((location) => ({ ...location, distance: this.calculateDistance( userCoords.latitude, @@ -107,10 +152,7 @@ export class GeoLocationService { return sortedLocations.slice(0, limit); } - async optimizeRouteForConnection( - userLocation: string, - connectionType: string, - ): Promise { + async optimizeRouteForConnection(userLocation: string, connectionType: string): Promise { const locations = await this.getNearestEdgeLocations(userLocation, 5); // Adjust based on connection type @@ -147,34 +189,33 @@ export class GeoLocationService { return estimates; } - private async getCoordinates(location: string): Promise<{ latitude: number; longitude: number } | null> { + private async getCoordinates( + location: string, + ): Promise<{ latitude: number; longitude: number } | null> { // In real implementation, use geocoding service // For now, return mock coordinates const mockCoords: Record = { - 'new york': { latitude: 40.7128, longitude: -74.0060 }, - 'london': { latitude: 51.5074, longitude: -0.1278 }, - 'tokyo': { latitude: 35.6762, longitude: 139.6503 }, - 'sydney': { latitude: -33.8688, longitude: 151.2093 }, - 'lagos': { latitude: 6.5244, longitude: 3.3792 }, + 'new york': { latitude: 40.7128, longitude: -74.006 }, + london: { latitude: 51.5074, longitude: -0.1278 }, + tokyo: { latitude: 35.6762, longitude: 139.6503 }, + sydney: { latitude: -33.8688, longitude: 151.2093 }, + lagos: { latitude: 6.5244, longitude: 3.3792 }, }; return mockCoords[location.toLowerCase()] || null; } - private calculateDistance( - lat1: number, - lon1: number, - lat2: number, - lon2: number, - ): number { + private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371; // Earth's radius in kilometers const dLat = this.toRadians(lat2 - lat1); const dLon = this.toRadians(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); + Math.cos(this.toRadians(lat1)) * + Math.cos(this.toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; diff --git a/src/cdn/optimization/asset-optimization.service.ts b/src/cdn/optimization/asset-optimization.service.ts index c344b38..aee5624 100644 --- a/src/cdn/optimization/asset-optimization.service.ts +++ b/src/cdn/optimization/asset-optimization.service.ts @@ -11,10 +11,7 @@ export interface OptimizationResult { @Injectable() export class AssetOptimizationService { - async optimizeImage( - imageUrl: string, - options: ContentDeliveryOptions, - ): Promise { + async optimizeImage(imageUrl: string, options: ContentDeliveryOptions): Promise { try { // Download image (in real implementation, you'd fetch from storage) // For now, assume we have the buffer diff --git a/src/cdn/providers/aws-cloudfront.service.ts b/src/cdn/providers/aws-cloudfront.service.ts index 31b9eab..d0c5a56 100644 --- a/src/cdn/providers/aws-cloudfront.service.ts +++ b/src/cdn/providers/aws-cloudfront.service.ts @@ -111,7 +111,7 @@ export class AWSCloudFrontService { this.logger.log(`Creating CloudFront invalidation for ${urls.length} URLs`); // Convert full URLs to paths relative to distribution - const paths = urls.map(url => { + const paths = urls.map((url) => { try { const urlObj = new URL(url); return urlObj.pathname; @@ -242,7 +242,7 @@ export class AWSCloudFrontService { return; } - await new Promise(resolve => setTimeout(resolve, 10000)); // Wait 10 seconds + await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds attempts++; } catch (error) { this.logger.error(`Error checking invalidation status:`, error); diff --git a/src/cdn/providers/cloudflare.service.ts b/src/cdn/providers/cloudflare.service.ts index 75a919f..386e398 100644 --- a/src/cdn/providers/cloudflare.service.ts +++ b/src/cdn/providers/cloudflare.service.ts @@ -66,12 +66,9 @@ export class CloudflareService { try { this.logger.log(`Purging ${urls.length} URLs from Cloudflare`); - const response = await this.httpClient.post( - `/zones/${this.config.zoneId}/purge_cache`, - { - files: urls, - }, - ); + const response = await this.httpClient.post(`/zones/${this.config.zoneId}/purge_cache`, { + files: urls, + }); if (response.data.success) { return { @@ -99,12 +96,9 @@ export class CloudflareService { async purgeEverything(): Promise { try { - const response = await this.httpClient.post( - `/zones/${this.config.zoneId}/purge_cache`, - { - purge_everything: true, - }, - ); + const response = await this.httpClient.post(`/zones/${this.config.zoneId}/purge_cache`, { + purge_everything: true, + }); return response.data.success; } catch (error) { @@ -152,9 +146,7 @@ export class CloudflareService { async getZoneSettings(): Promise { try { - const response = await this.httpClient.get( - `/zones/${this.config.zoneId}/settings`, - ); + const response = await this.httpClient.get(`/zones/${this.config.zoneId}/settings`); return response.data.result; } catch (error) { diff --git a/src/collaboration/collaboration.controller.ts b/src/collaboration/collaboration.controller.ts index a24168c..0c6d6d9 100644 --- a/src/collaboration/collaboration.controller.ts +++ b/src/collaboration/collaboration.controller.ts @@ -1,4 +1,15 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; import { CollaborationService } from './collaboration.service'; import { SharedDocumentService, CollaborativeDocument } from './documents/shared-document.service'; import { WhiteboardService, CollaborativeWhiteboard } from './whiteboard/whiteboard.service'; @@ -25,13 +36,17 @@ export class CollaborationController { @Post('session') async createSession( @Request() req, - @Body() body: { sessionId: string; resourceType: 'document' | 'whiteboard' } + @Body() body: { sessionId: string; resourceType: 'document' | 'whiteboard' }, ) { const { sessionId, resourceType } = body; const userId = req.user.id; - const session = await this.collaborationService.initializeSession(sessionId, userId, resourceType); - + const session = await this.collaborationService.initializeSession( + sessionId, + userId, + resourceType, + ); + return { success: true, sessionId, @@ -46,14 +61,18 @@ export class CollaborationController { @Get('document/:id') async getDocument(@Param('id') documentId: string, @Request() req) { const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(documentId, userId, PermissionLevel.READ); - + const hasPermission = await this.permissionsService.hasAccess( + documentId, + userId, + PermissionLevel.READ, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to access document' }; } const document = await this.sharedDocumentService.getDocument(documentId); - + return { success: true, document, @@ -66,14 +85,18 @@ export class CollaborationController { @Get('whiteboard/:id') async getWhiteboard(@Param('id') whiteboardId: string, @Request() req) { const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(whiteboardId, userId, PermissionLevel.READ); - + const hasPermission = await this.permissionsService.hasAccess( + whiteboardId, + userId, + PermissionLevel.READ, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to access whiteboard' }; } const whiteboard = await this.whiteboardService.getWhiteboard(whiteboardId); - + return { success: true, whiteboard, @@ -87,22 +110,22 @@ export class CollaborationController { async updateDocument( @Param('id') documentId: string, @Request() req, - @Body() body: { operation: any } + @Body() body: { operation: any }, ) { const { operation } = body; const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(documentId, userId, PermissionLevel.WRITE); - + const hasPermission = await this.permissionsService.hasAccess( + documentId, + userId, + PermissionLevel.WRITE, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to modify document' }; } - const document = await this.sharedDocumentService.applyOperation( - documentId, - userId, - operation - ); - + const document = await this.sharedDocumentService.applyOperation(documentId, userId, operation); + return { success: true, document, @@ -116,22 +139,22 @@ export class CollaborationController { async updateWhiteboard( @Param('id') whiteboardId: string, @Request() req, - @Body() body: { operation: any } + @Body() body: { operation: any }, ) { const { operation } = body; const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(whiteboardId, userId, PermissionLevel.WRITE); - + const hasPermission = await this.permissionsService.hasAccess( + whiteboardId, + userId, + PermissionLevel.WRITE, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to modify whiteboard' }; } - const whiteboard = await this.whiteboardService.applyOperation( - whiteboardId, - userId, - operation - ); - + const whiteboard = await this.whiteboardService.applyOperation(whiteboardId, userId, operation); + return { success: true, whiteboard, @@ -144,14 +167,18 @@ export class CollaborationController { @Get('document/:id/history') async getDocumentHistory(@Param('id') documentId: string, @Request() req) { const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(documentId, userId, PermissionLevel.READ); - + const hasPermission = await this.permissionsService.hasAccess( + documentId, + userId, + PermissionLevel.READ, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to access history' }; } const history = await this.sharedDocumentService.getDocumentHistory(documentId); - + return { success: true, history, @@ -164,14 +191,18 @@ export class CollaborationController { @Get('whiteboard/:id/history') async getWhiteboardHistory(@Param('id') whiteboardId: string, @Request() req) { const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(whiteboardId, userId, PermissionLevel.READ); - + const hasPermission = await this.permissionsService.hasAccess( + whiteboardId, + userId, + PermissionLevel.READ, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to access history' }; } const history = await this.whiteboardService.getWhiteboardHistory(whiteboardId); - + return { success: true, history, @@ -184,14 +215,18 @@ export class CollaborationController { @Get('version-history/:sessionId') async getVersionHistory(@Param('sessionId') sessionId: string, @Request() req) { const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(sessionId, userId, PermissionLevel.READ); - + const hasPermission = await this.permissionsService.hasAccess( + sessionId, + userId, + PermissionLevel.READ, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to access version history' }; } const history = await this.versionControlService.getVersionHistory(sessionId); - + return { success: true, history, @@ -204,14 +239,18 @@ export class CollaborationController { @Get('version-current/:sessionId') async getCurrentVersion(@Param('sessionId') sessionId: string, @Request() req) { const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(sessionId, userId, PermissionLevel.READ); - + const hasPermission = await this.permissionsService.hasAccess( + sessionId, + userId, + PermissionLevel.READ, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to access current version' }; } const currentVersion = await this.versionControlService.getCurrentVersion(sessionId); - + return { success: true, currentVersion, @@ -226,20 +265,29 @@ export class CollaborationController { @Param('resourceId') resourceId: string, @Param('userId') userId: string, @Request() req, - @Body() body: { permission: PermissionLevel } + @Body() body: { permission: PermissionLevel }, ) { const adminUserId = req.user.id; const { permission } = body; - + // Check if the requesting user has admin permissions - const isAdmin = await this.permissionsService.hasAccess(resourceId, adminUserId, PermissionLevel.ADMIN); - + const isAdmin = await this.permissionsService.hasAccess( + resourceId, + adminUserId, + PermissionLevel.ADMIN, + ); + if (!isAdmin) { return { success: false, message: 'Insufficient permissions to grant permissions' }; } - const grantedPermission = await this.permissionsService.grantAccess(resourceId, userId, permission, adminUserId); - + const grantedPermission = await this.permissionsService.grantAccess( + resourceId, + userId, + permission, + adminUserId, + ); + return { success: true, permission: grantedPermission, @@ -253,19 +301,23 @@ export class CollaborationController { async revokePermission( @Param('resourceId') resourceId: string, @Param('userId') userId: string, - @Request() req + @Request() req, ) { const adminUserId = req.user.id; - + // Check if the requesting user has admin permissions - const isAdmin = await this.permissionsService.hasAccess(resourceId, adminUserId, PermissionLevel.ADMIN); - + const isAdmin = await this.permissionsService.hasAccess( + resourceId, + adminUserId, + PermissionLevel.ADMIN, + ); + if (!isAdmin) { return { success: false, message: 'Insufficient permissions to revoke permissions' }; } const revoked = await this.permissionsService.revokeAccess(resourceId, userId); - + return { success: true, revoked, @@ -278,17 +330,21 @@ export class CollaborationController { @Get('users/:resourceId') async getUsersForResource(@Param('resourceId') resourceId: string, @Request() req) { const userId = req.user.id; - const hasPermission = await this.permissionsService.hasAccess(resourceId, userId, PermissionLevel.READ); - + const hasPermission = await this.permissionsService.hasAccess( + resourceId, + userId, + PermissionLevel.READ, + ); + if (!hasPermission) { return { success: false, message: 'Insufficient permissions to view resource users' }; } const users = await this.permissionsService.getUsersForResource(resourceId); - + return { success: true, users, }; } -} \ No newline at end of file +} diff --git a/src/collaboration/collaboration.module.ts b/src/collaboration/collaboration.module.ts index c832f70..48915c7 100644 --- a/src/collaboration/collaboration.module.ts +++ b/src/collaboration/collaboration.module.ts @@ -26,4 +26,4 @@ import { CollaborationController } from './collaboration.controller'; CollaborationPermissionsService, ], }) -export class CollaborationModule {} \ No newline at end of file +export class CollaborationModule {} diff --git a/src/collaboration/collaboration.service.ts b/src/collaboration/collaboration.service.ts index 3290a7e..b5787ea 100644 --- a/src/collaboration/collaboration.service.ts +++ b/src/collaboration/collaboration.service.ts @@ -16,16 +16,20 @@ export class CollaborationService { /** * Initialize a new collaborative session */ - async initializeSession(sessionId: string, userId: string, resourceType: 'document' | 'whiteboard') { + async initializeSession( + sessionId: string, + userId: string, + resourceType: 'document' | 'whiteboard', + ) { // Set up initial permissions and session tracking await this.permissionsService.grantAccess(sessionId, userId); - + if (resourceType === 'document') { return await this.sharedDocumentService.initializeDocument(sessionId); } else if (resourceType === 'whiteboard') { return await this.whiteboardService.initializeWhiteboard(sessionId); } - + throw new Error(`Unsupported resource type: ${resourceType}`); } @@ -36,7 +40,7 @@ export class CollaborationService { sessionId: string, userId: string, operation: any, - resourceType: 'document' | 'whiteboard' + resourceType: 'document' | 'whiteboard', ) { // Check permissions const hasPermission = await this.permissionsService.hasAccess(sessionId, userId); @@ -49,7 +53,7 @@ export class CollaborationService { } else if (resourceType === 'whiteboard') { return await this.whiteboardService.applyOperation(sessionId, userId, operation); } - + throw new Error(`Unsupported resource type: ${resourceType}`); } @@ -59,4 +63,4 @@ export class CollaborationService { async trackChange(sessionId: string, userId: string, change: any) { return await this.versionControlService.recordChange(sessionId, userId, change); } -} \ No newline at end of file +} diff --git a/src/collaboration/documents/shared-document.service.ts b/src/collaboration/documents/shared-document.service.ts index 953653c..d81665c 100644 --- a/src/collaboration/documents/shared-document.service.ts +++ b/src/collaboration/documents/shared-document.service.ts @@ -40,7 +40,7 @@ export class SharedDocumentService { this.documents.set(documentId, document); this.logger.log(`Initialized document ${documentId}`); - + return document; } @@ -57,7 +57,7 @@ export class SharedDocumentService { async applyOperation( documentId: string, userId: string, - operation: Omit + operation: Omit, ): Promise { const document = this.documents.get(documentId); if (!document) { @@ -88,7 +88,7 @@ export class SharedDocumentService { } this.logger.log(`Applied operation ${transformedOp.id} to document ${documentId}`); - + return document; } @@ -97,7 +97,7 @@ export class SharedDocumentService { */ private transformOperation( operation: DocumentOperation, - concurrentOperations: DocumentOperation[] + concurrentOperations: DocumentOperation[], ): DocumentOperation { let transformedOp = { ...operation }; @@ -123,17 +123,23 @@ export class SharedDocumentService { if (op1.type === 'insert') { // Insert and another operation overlap if insert position is within or adjacent to other operation - return op2.position <= op1.position && op1.position <= op2.position + (op2.length || (op2.content?.length || 0)); + return ( + op2.position <= op1.position && + op1.position <= op2.position + (op2.length || op2.content?.length || 0) + ); } if (op2.type === 'insert') { // Same as above but reversed - return op1.position <= op2.position && op2.position <= op1.position + (op1.length || (op1.content?.length || 0)); + return ( + op1.position <= op2.position && + op2.position <= op1.position + (op1.length || op1.content?.length || 0) + ); } // Both are delete/update operations - overlap if ranges intersect - const op1End = op1.position + (op1.length || (op1.content?.length || 0)); - const op2End = op2.position + (op2.length || (op2.content?.length || 0)); + const op1End = op1.position + (op1.length || op1.content?.length || 0); + const op2End = op2.position + (op2.length || op2.content?.length || 0); return !(op1.position >= op2End || op2.position >= op1End); } @@ -142,7 +148,7 @@ export class SharedDocumentService { */ private transformSingleOperation( operation: DocumentOperation, - concurrentOp: DocumentOperation + concurrentOp: DocumentOperation, ): DocumentOperation { const transformedOp = { ...operation }; @@ -152,7 +158,7 @@ export class SharedDocumentService { transformedOp.position += concurrentOp.content ? concurrentOp.content.length : 0; } else if (concurrentOp.type === 'delete') { const concurrentEnd = concurrentOp.position + concurrentOp.length; - + if (operation.position >= concurrentEnd) { // Operation is after the deleted range, adjust position transformedOp.position -= concurrentOp.length; @@ -173,27 +179,33 @@ export class SharedDocumentService { switch (operation.type) { case 'insert': if (operation.content !== undefined) { - return content.slice(0, operation.position) + - operation.content + - content.slice(operation.position); + return ( + content.slice(0, operation.position) + + operation.content + + content.slice(operation.position) + ); } return content; - + case 'delete': if (operation.length !== undefined) { - return content.slice(0, operation.position) + - content.slice(operation.position + operation.length); + return ( + content.slice(0, operation.position) + + content.slice(operation.position + operation.length) + ); } return content; - + case 'update': if (operation.content !== undefined && operation.length !== undefined) { - return content.slice(0, operation.position) + - operation.content + - content.slice(operation.position + operation.length); + return ( + content.slice(0, operation.position) + + operation.content + + content.slice(operation.position + operation.length) + ); } return content; - + default: return content; } @@ -202,7 +214,10 @@ export class SharedDocumentService { /** * Resolve conflicts between simultaneous edits */ - async resolveConflicts(documentId: string, operations: DocumentOperation[]): Promise { + async resolveConflicts( + documentId: string, + operations: DocumentOperation[], + ): Promise { const document = this.documents.get(documentId); if (!document) { throw new Error(`Document ${documentId} not found`); @@ -222,7 +237,7 @@ export class SharedDocumentService { } document.updatedAt = new Date(); - + return document; } @@ -237,4 +252,4 @@ export class SharedDocumentService { return [...document.operations]; } -} \ No newline at end of file +} diff --git a/src/collaboration/gateway/collaboration.gateway.ts b/src/collaboration/gateway/collaboration.gateway.ts index 74fc8ab..c900f89 100644 --- a/src/collaboration/gateway/collaboration.gateway.ts +++ b/src/collaboration/gateway/collaboration.gateway.ts @@ -14,7 +14,10 @@ import { CollaborationService } from '../collaboration.service'; import { SharedDocumentService } from '../documents/shared-document.service'; import { WhiteboardService } from '../whiteboard/whiteboard.service'; import { VersionControlService } from '../versioning/version-control.service'; -import { CollaborationPermissionsService, PermissionLevel } from '../permissions/collaboration-permissions.service'; +import { + CollaborationPermissionsService, + PermissionLevel, +} from '../permissions/collaboration-permissions.service'; export interface CollaborativeOperation { sessionId: string; @@ -31,7 +34,9 @@ export interface CollaborativeOperation { credentials: true, }, }) -export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { +export class CollaborationGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ @WebSocketServer() server: Server; private logger: Logger = new Logger('CollaborationGateway'); @@ -49,7 +54,7 @@ export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, async handleConnection(@ConnectedSocket() client: Socket) { this.logger.log(`Client connected: ${client.id}`); - + // Optionally authenticate the user here based on token // const token = client.handshake.auth.token; // const user = await this.authService.validateToken(token); @@ -57,7 +62,7 @@ export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, async handleDisconnect(@ConnectedSocket() client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); - + // Clean up any session associations for this client // Remove client from any active sessions } @@ -79,18 +84,26 @@ export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, // Join the room client.join(sessionId); - + // Initialize or get the resource let resource: any; if (resourceType === 'document') { resource = await this.sharedDocumentService.getDocument(sessionId); if (!resource) { - resource = await this.collaborationService.initializeSession(sessionId, userId, 'document' as any); + resource = await this.collaborationService.initializeSession( + sessionId, + userId, + 'document' as any, + ); } } else if (resourceType === 'whiteboard') { resource = await this.whiteboardService.getWhiteboard(sessionId); if (!resource) { - resource = await this.collaborationService.initializeSession(sessionId, userId, 'whiteboard' as any); + resource = await this.collaborationService.initializeSession( + sessionId, + userId, + 'whiteboard' as any, + ); } } @@ -122,11 +135,11 @@ export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, try { // Validate permissions const hasPermission = await this.permissionsService.hasAccess( - sessionId, - userId, - resourceType === 'document' ? PermissionLevel.WRITE : PermissionLevel.WRITE + sessionId, + userId, + resourceType === 'document' ? PermissionLevel.WRITE : PermissionLevel.WRITE, ); - + if (!hasPermission) { client.emit('error', { message: 'Insufficient permissions to perform operation' }); return; @@ -179,7 +192,7 @@ export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, // Send current state to requesting client const document = await this.sharedDocumentService.getDocument(sessionId); const whiteboard = await this.whiteboardService.getWhiteboard(sessionId); - + client.emit('full-sync', { sessionId, document: document || null, @@ -193,14 +206,19 @@ export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, @SubscribeMessage('resolve-conflict') async handleConflictResolution( - @MessageBody() data: { sessionId: string; userId: string; resourceType: string; operations: any[] }, + @MessageBody() + data: { sessionId: string; userId: string; resourceType: string; operations: any[] }, @ConnectedSocket() client: Socket, ) { const { sessionId, userId, resourceType, operations } = data; try { // Only admins/owners can resolve conflicts - const hasPermission = await this.permissionsService.hasAccess(sessionId, userId, PermissionLevel.ADMIN); + const hasPermission = await this.permissionsService.hasAccess( + sessionId, + userId, + PermissionLevel.ADMIN, + ); if (!hasPermission) { client.emit('error', { message: 'Insufficient permissions to resolve conflicts' }); return; @@ -231,4 +249,4 @@ export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, broadcastToSession(sessionId: string, event: string, data: any) { this.server.to(sessionId).emit(event, data); } -} \ No newline at end of file +} diff --git a/src/collaboration/permissions/collaboration-permissions.service.ts b/src/collaboration/permissions/collaboration-permissions.service.ts index ad379fc..79832da 100644 --- a/src/collaboration/permissions/collaboration-permissions.service.ts +++ b/src/collaboration/permissions/collaboration-permissions.service.ts @@ -37,10 +37,10 @@ export class CollaborationPermissionsService { resourceId: string, userId: string, permission: PermissionLevel = PermissionLevel.WRITE, - grantedBy: string = 'system' + grantedBy: string = 'system', ): Promise { let resource = this.resources.get(resourceId); - + if (!resource) { resource = { id: resourceId, @@ -54,8 +54,8 @@ export class CollaborationPermissionsService { } // Check if user already has permission - const existingPermission = resource.permissions.find(p => p.userId === userId); - + const existingPermission = resource.permissions.find((p) => p.userId === userId); + if (existingPermission) { // Update existing permission existingPermission.permission = permission; @@ -75,7 +75,9 @@ export class CollaborationPermissionsService { resource.updatedAt = new Date(); - this.logger.log(`Granted ${permission} permission to user ${userId} for resource ${resourceId}`); + this.logger.log( + `Granted ${permission} permission to user ${userId} for resource ${resourceId}`, + ); return this.getUserPermission(resourceId, userId)!; } @@ -90,8 +92,8 @@ export class CollaborationPermissionsService { } const initialLength = resource.permissions.length; - resource.permissions = resource.permissions.filter(p => p.userId !== userId); - + resource.permissions = resource.permissions.filter((p) => p.userId !== userId); + if (resource.permissions.length !== initialLength) { resource.updatedAt = new Date(); this.logger.log(`Revoked permission for user ${userId} from resource ${resourceId}`); @@ -104,9 +106,13 @@ export class CollaborationPermissionsService { /** * Check if a user has access to a resource */ - async hasAccess(resourceId: string, userId: string, requiredPermission: PermissionLevel = PermissionLevel.READ): Promise { + async hasAccess( + resourceId: string, + userId: string, + requiredPermission: PermissionLevel = PermissionLevel.READ, + ): Promise { const userPermission = this.getUserPermission(resourceId, userId); - + if (!userPermission) { return false; } @@ -123,7 +129,7 @@ export class CollaborationPermissionsService { return undefined; } - return resource.permissions.find(p => p.userId === userId); + return resource.permissions.find((p) => p.userId === userId); } /** @@ -141,12 +147,15 @@ export class CollaborationPermissionsService { /** * Get all resources a user has access to */ - async getResourcesForUser(userId: string, minPermission: PermissionLevel = PermissionLevel.READ): Promise { + async getResourcesForUser( + userId: string, + minPermission: PermissionLevel = PermissionLevel.READ, + ): Promise { const userResources: CollaborativeResource[] = []; for (const resource of this.resources.values()) { - const userPermission = resource.permissions.find(p => p.userId === userId); - + const userPermission = resource.permissions.find((p) => p.userId === userId); + if (userPermission && this.checkPermissionLevel(userPermission.permission, minPermission)) { userResources.push({ ...resource }); } @@ -162,14 +171,14 @@ export class CollaborationPermissionsService { resourceId: string, userId: string, newPermission: PermissionLevel, - updatedBy: string + updatedBy: string, ): Promise { const resource = this.resources.get(resourceId); if (!resource) { return null; } - const userPermission = resource.permissions.find(p => p.userId === userId); + const userPermission = resource.permissions.find((p) => p.userId === userId); if (!userPermission) { return null; } @@ -177,10 +186,12 @@ export class CollaborationPermissionsService { userPermission.permission = newPermission; userPermission.grantedAt = new Date(); userPermission.grantedBy = updatedBy; - + resource.updatedAt = new Date(); - this.logger.log(`Updated permission to ${newPermission} for user ${userId} on resource ${resourceId}`); + this.logger.log( + `Updated permission to ${newPermission} for user ${userId} on resource ${resourceId}`, + ); return { ...userPermission }; } @@ -192,14 +203,18 @@ export class CollaborationPermissionsService { resourceId: string, inviterId: string, inviteeId: string, - permission: PermissionLevel = PermissionLevel.WRITE + permission: PermissionLevel = PermissionLevel.WRITE, ): Promise { // Check if inviter has admin or owner permissions const inviterPermission = this.getUserPermission(resourceId, inviterId); - - if (!inviterPermission || - !this.checkPermissionLevel(inviterPermission.permission, PermissionLevel.ADMIN)) { - throw new Error(`User ${inviterId} does not have permission to invite users to resource ${resourceId}`); + + if ( + !inviterPermission || + !this.checkPermissionLevel(inviterPermission.permission, PermissionLevel.ADMIN) + ) { + throw new Error( + `User ${inviterId} does not have permission to invite users to resource ${resourceId}`, + ); } return await this.grantAccess(resourceId, inviteeId, permission, inviterId); @@ -208,13 +223,21 @@ export class CollaborationPermissionsService { /** * Remove a user from a collaboration session */ - async removeUser(resourceId: string, removerId: string, userIdToRemove: string): Promise { + async removeUser( + resourceId: string, + removerId: string, + userIdToRemove: string, + ): Promise { // Check if remover has admin or owner permissions const removerPermission = this.getUserPermission(resourceId, removerId); - - if (!removerPermission || - !this.checkPermissionLevel(removerPermission.permission, PermissionLevel.ADMIN)) { - throw new Error(`User ${removerId} does not have permission to remove users from resource ${resourceId}`); + + if ( + !removerPermission || + !this.checkPermissionLevel(removerPermission.permission, PermissionLevel.ADMIN) + ) { + throw new Error( + `User ${removerId} does not have permission to remove users from resource ${resourceId}`, + ); } // Can't remove the owner @@ -229,12 +252,15 @@ export class CollaborationPermissionsService { /** * Check if one permission level meets or exceeds another */ - private checkPermissionLevel(userPermission: PermissionLevel, requiredPermission: PermissionLevel): boolean { + private checkPermissionLevel( + userPermission: PermissionLevel, + requiredPermission: PermissionLevel, + ): boolean { const permissionLevels: PermissionLevel[] = [ PermissionLevel.READ, PermissionLevel.WRITE, PermissionLevel.ADMIN, - PermissionLevel.OWNER + PermissionLevel.OWNER, ]; const userIndex = permissionLevels.indexOf(userPermission); @@ -269,4 +295,4 @@ export class CollaborationPermissionsService { getDefaultPermission(): PermissionLevel { return this.defaultPermission; } -} \ No newline at end of file +} diff --git a/src/collaboration/versioning/version-control.service.ts b/src/collaboration/versioning/version-control.service.ts index 494b70c..44b2b49 100644 --- a/src/collaboration/versioning/version-control.service.ts +++ b/src/collaboration/versioning/version-control.service.ts @@ -31,7 +31,7 @@ export class VersionControlService { */ async recordChange(sessionId: string, userId: string, change: any): Promise { let history = this.histories.get(sessionId); - + if (!history) { history = { sessionId, @@ -60,7 +60,9 @@ export class VersionControlService { history.currentVersion = versionNumber; history.updatedAt = new Date(); - this.logger.log(`Recorded change ${changeRecord.id} for session ${sessionId}, version ${versionNumber}`); + this.logger.log( + `Recorded change ${changeRecord.id} for session ${sessionId}, version ${versionNumber}`, + ); return changeRecord; } @@ -86,7 +88,7 @@ export class VersionControlService { return null; } - const version = history.versions.find(v => v.versionNumber === versionNumber); + const version = history.versions.find((v) => v.versionNumber === versionNumber); return version || null; } @@ -115,7 +117,7 @@ export class VersionControlService { throw new Error(`No history found for session ${sessionId}`); } - const versionIndex = history.versions.findIndex(v => v.versionNumber === versionNumber); + const versionIndex = history.versions.findIndex((v) => v.versionNumber === versionNumber); if (versionIndex === -1) { throw new Error(`Version ${versionNumber} not found for session ${sessionId}`); } @@ -133,7 +135,11 @@ export class VersionControlService { /** * Compare two versions */ - async compareVersions(sessionId: string, version1: number, version2: number): Promise<{ + async compareVersions( + sessionId: string, + version1: number, + version2: number, + ): Promise<{ differences: any; version1Data: ChangeRecord | null; version2Data: ChangeRecord | null; @@ -156,7 +162,7 @@ export class VersionControlService { async getChangeStatistics(sessionId: string): Promise<{ totalChanges: number; changesByUser: Map; - changesOverTime: { date: Date; count: number }[]; + changesOverTime: Array<{ date: Date; count: number }>; }> { const history = this.histories.get(sessionId); if (!history) { @@ -168,7 +174,7 @@ export class VersionControlService { } const changesByUser = new Map(); - const changesOverTime: { date: Date; count: number }[] = []; + const changesOverTime: Array<{ date: Date; count: number }> = []; for (const version of history.versions) { // Count changes by user @@ -177,10 +183,10 @@ export class VersionControlService { // Group by day for time series const dateStr = new Date(version.timestamp).toDateString(); - const timeEntry = changesOverTime.find(entry => - entry.date.toDateString() === new Date(version.timestamp).toDateString() + const timeEntry = changesOverTime.find( + (entry) => entry.date.toDateString() === new Date(version.timestamp).toDateString(), ); - + if (timeEntry) { timeEntry.count++; } else { @@ -248,4 +254,4 @@ export class VersionControlService { hasChanges: JSON.stringify(state1) !== JSON.stringify(state2), }; } -} \ No newline at end of file +} diff --git a/src/collaboration/whiteboard/whiteboard.service.ts b/src/collaboration/whiteboard/whiteboard.service.ts index 22ed214..07e7bfb 100644 --- a/src/collaboration/whiteboard/whiteboard.service.ts +++ b/src/collaboration/whiteboard/whiteboard.service.ts @@ -8,7 +8,7 @@ export interface DrawingElement { y: number; width?: number; height?: number; - points?: { x: number; y: number }[]; // For freehand drawings + points?: Array<{ x: number; y: number }>; // For freehand drawings text?: string; // For text elements color: string; strokeWidth: number; @@ -54,7 +54,7 @@ export class WhiteboardService { this.whiteboards.set(whiteboardId, whiteboard); this.logger.log(`Initialized whiteboard ${whiteboardId}`); - + return whiteboard; } @@ -71,7 +71,7 @@ export class WhiteboardService { async applyOperation( whiteboardId: string, userId: string, - operation: Omit + operation: Omit, ): Promise { const whiteboard = this.whiteboards.get(whiteboardId); if (!whiteboard) { @@ -99,36 +99,39 @@ export class WhiteboardService { } this.logger.log(`Applied operation ${opWithMetadata.id} to whiteboard ${whiteboardId}`); - + return whiteboard; } /** * Apply an operation to the whiteboard state */ - private applyOperationToWhiteboard(whiteboard: CollaborativeWhiteboard, operation: WhiteboardOperation): void { + private applyOperationToWhiteboard( + whiteboard: CollaborativeWhiteboard, + operation: WhiteboardOperation, + ): void { switch (operation.type) { case 'addElement': if (operation.element) { whiteboard.elements.push({ ...operation.element }); } break; - + case 'removeElement': if (operation.elementId) { - whiteboard.elements = whiteboard.elements.filter(el => el.id !== operation.elementId); + whiteboard.elements = whiteboard.elements.filter((el) => el.id !== operation.elementId); } break; - + case 'updateElement': if (operation.elementId && operation.element) { - const index = whiteboard.elements.findIndex(el => el.id === operation.elementId); + const index = whiteboard.elements.findIndex((el) => el.id === operation.elementId); if (index !== -1) { whiteboard.elements[index] = { ...operation.element }; } } break; - + case 'clearBoard': whiteboard.elements = []; break; @@ -140,7 +143,7 @@ export class WhiteboardService { */ private transformOperation( operation: WhiteboardOperation, - concurrentOperations: WhiteboardOperation[] + concurrentOperations: WhiteboardOperation[], ): WhiteboardOperation { // For whiteboard operations, transformation is simpler than text operations // We mainly need to handle cases where elements are removed while others try to update them @@ -148,9 +151,11 @@ export class WhiteboardService { for (const concurrentOp of concurrentOperations) { // If a concurrent operation removes an element that this operation tries to update - if (concurrentOp.type === 'removeElement' && - operation.type === 'updateElement' && - concurrentOp.elementId === operation.elementId) { + if ( + concurrentOp.type === 'removeElement' && + operation.type === 'updateElement' && + concurrentOp.elementId === operation.elementId + ) { // Convert the update to an add operation since the element was removed transformedOp = { ...transformedOp, @@ -166,7 +171,10 @@ export class WhiteboardService { /** * Resolve conflicts between simultaneous whiteboard edits */ - async resolveConflicts(whiteboardId: string, operations: WhiteboardOperation[]): Promise { + async resolveConflicts( + whiteboardId: string, + operations: WhiteboardOperation[], + ): Promise { const whiteboard = this.whiteboards.get(whiteboardId); if (!whiteboard) { throw new Error(`Whiteboard ${whiteboardId} not found`); @@ -186,7 +194,7 @@ export class WhiteboardService { } whiteboard.updatedAt = new Date(); - + return whiteboard; } @@ -205,7 +213,11 @@ export class WhiteboardService { /** * Add a drawing element to the whiteboard */ - async addElement(whiteboardId: string, element: Omit, userId: string): Promise { + async addElement( + whiteboardId: string, + element: Omit, + userId: string, + ): Promise { const whiteboard = this.whiteboards.get(whiteboardId); if (!whiteboard) { throw new Error(`Whiteboard ${whiteboardId} not found`); @@ -243,7 +255,7 @@ export class WhiteboardService { throw new Error(`Whiteboard ${whiteboardId} not found`); } - const elementIndex = whiteboard.elements.findIndex(el => el.id === elementId); + const elementIndex = whiteboard.elements.findIndex((el) => el.id === elementId); if (elementIndex === -1) { return false; } @@ -263,4 +275,4 @@ export class WhiteboardService { return true; } -} \ No newline at end of file +} diff --git a/src/common/common.module.ts b/src/common/common.module.ts index aaa3ea5..b1229df 100644 --- a/src/common/common.module.ts +++ b/src/common/common.module.ts @@ -5,4 +5,4 @@ import { TransactionHelperService } from './database/transaction-helper.service' providers: [TransactionHelperService], exports: [TransactionHelperService], }) -export class CommonModule {} \ No newline at end of file +export class CommonModule {} diff --git a/src/common/database/examples/booking-transaction.example.ts b/src/common/database/examples/booking-transaction.example.ts index aea62d5..3e5b409 100644 --- a/src/common/database/examples/booking-transaction.example.ts +++ b/src/common/database/examples/booking-transaction.example.ts @@ -33,26 +33,25 @@ export class BookingTransactionExample { } // 2. Check user balance - const user = await manager.query( - 'SELECT balance FROM users WHERE id = $1 FOR UPDATE', - [userId], - ); + const user = await manager.query('SELECT balance FROM users WHERE id = $1 FOR UPDATE', [ + userId, + ]); if (!user || user.length === 0 || user[0].balance < amount) { throw new Error('Insufficient balance'); } // 3. Deduct payment from user - await manager.query( - 'UPDATE users SET balance = balance - $1 WHERE id = $2', - [amount, userId], - ); + await manager.query('UPDATE users SET balance = balance - $1 WHERE id = $2', [ + amount, + userId, + ]); // 4. Add payment to consultant - await manager.query( - 'UPDATE users SET balance = balance + $1 WHERE id = $2', - [amount, consultantId], - ); + await manager.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [ + amount, + consultantId, + ]); // 5. Mark slot as booked await manager.query( @@ -101,16 +100,16 @@ export class BookingTransactionExample { const { user_id, consultant_id, slot_id, amount } = booking[0]; // 2. Refund user - await manager.query( - 'UPDATE users SET balance = balance + $1 WHERE id = $2', - [refundAmount, user_id], - ); + await manager.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [ + refundAmount, + user_id, + ]); // 3. Deduct from consultant - await manager.query( - 'UPDATE users SET balance = balance - $1 WHERE id = $2', - [refundAmount, consultant_id], - ); + await manager.query('UPDATE users SET balance = balance - $1 WHERE id = $2', [ + refundAmount, + consultant_id, + ]); // 4. Free up the slot await manager.query( @@ -139,10 +138,7 @@ export class BookingTransactionExample { /** * Reschedule booking */ - async rescheduleBooking( - bookingId: string, - newSlotId: string, - ): Promise { + async rescheduleBooking(bookingId: string, newSlotId: string): Promise { return this.transactionService.runInTransaction(async (manager) => { // 1. Get current booking const booking = await manager.query( diff --git a/src/common/database/examples/payment-transaction.example.ts b/src/common/database/examples/payment-transaction.example.ts index babe449..9358486 100644 --- a/src/common/database/examples/payment-transaction.example.ts +++ b/src/common/database/examples/payment-transaction.example.ts @@ -23,11 +23,7 @@ export class PaymentTransactionExample { * Process payment with transaction * Ensures all steps succeed or all fail together */ - async processPayment( - userId: string, - amount: number, - recipientId: string, - ): Promise { + async processPayment(userId: string, amount: number, recipientId: string): Promise { return this.transactionService.runInTransaction(async (manager) => { // 1. Deduct from sender const sender = await manager.query( @@ -40,10 +36,10 @@ export class PaymentTransactionExample { } // 2. Add to recipient - await manager.query( - 'UPDATE users SET balance = balance + $1 WHERE id = $2', - [amount, recipientId], - ); + await manager.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [ + amount, + recipientId, + ]); // 3. Create payment record const payment = await manager.query( @@ -66,11 +62,7 @@ export class PaymentTransactionExample { /** * Process payment with retry on deadlock */ - async processPaymentWithRetry( - userId: string, - amount: number, - recipientId: string, - ): Promise { + async processPaymentWithRetry(userId: string, amount: number, recipientId: string): Promise { return this.transactionService.runWithRetry( async (manager) => { return this.processPaymentLogic(manager, userId, amount, recipientId); @@ -89,12 +81,9 @@ export class PaymentTransactionExample { amount: number, recipientId: string, ): Promise { - return this.transactionService.runWithIsolationLevel( - 'SERIALIZABLE', - async (manager) => { - return this.processPaymentLogic(manager, userId, amount, recipientId); - }, - ); + return this.transactionService.runWithIsolationLevel('SERIALIZABLE', async (manager) => { + return this.processPaymentLogic(manager, userId, amount, recipientId); + }); } /** @@ -103,10 +92,10 @@ export class PaymentTransactionExample { async refundPayment(paymentId: string): Promise { return this.transactionService.runInTransaction(async (manager) => { // 1. Get payment details - const payment = await manager.query( - 'SELECT * FROM payments WHERE id = $1 AND status = $2', - [paymentId, 'completed'], - ); + const payment = await manager.query('SELECT * FROM payments WHERE id = $1 AND status = $2', [ + paymentId, + 'completed', + ]); if (!payment || payment.length === 0) { throw new Error('Payment not found or already refunded'); @@ -115,21 +104,21 @@ export class PaymentTransactionExample { const { user_id, recipient_id, amount } = payment[0]; // 2. Reverse the payment - await manager.query( - 'UPDATE users SET balance = balance + $1 WHERE id = $2', - [amount, user_id], - ); + await manager.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [ + amount, + user_id, + ]); - await manager.query( - 'UPDATE users SET balance = balance - $1 WHERE id = $2', - [amount, recipient_id], - ); + await manager.query('UPDATE users SET balance = balance - $1 WHERE id = $2', [ + amount, + recipient_id, + ]); // 3. Update payment status - await manager.query( - 'UPDATE payments SET status = $1, refunded_at = NOW() WHERE id = $2', - ['refunded', paymentId], - ); + await manager.query('UPDATE payments SET status = $1, refunded_at = NOW() WHERE id = $2', [ + 'refunded', + paymentId, + ]); // 4. Create refund log await manager.query( @@ -161,10 +150,10 @@ export class PaymentTransactionExample { throw new Error('Insufficient balance'); } - await manager.query( - 'UPDATE users SET balance = balance + $1 WHERE id = $2', - [amount, recipientId], - ); + await manager.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [ + amount, + recipientId, + ]); const payment = await manager.query( 'INSERT INTO payments (user_id, recipient_id, amount, status) VALUES ($1, $2, $3, $4) RETURNING *', diff --git a/src/common/database/examples/voting-transaction.example.ts b/src/common/database/examples/voting-transaction.example.ts index 4895a2f..d4d90cc 100644 --- a/src/common/database/examples/voting-transaction.example.ts +++ b/src/common/database/examples/voting-transaction.example.ts @@ -45,10 +45,7 @@ export class VotingTransactionExample { } // 3. Verify user has voting power - const user = await manager.query( - 'SELECT voting_power FROM users WHERE id = $1', - [userId], - ); + const user = await manager.query('SELECT voting_power FROM users WHERE id = $1', [userId]); if (!user || user.length === 0 || user[0].voting_power < votingPower) { throw new Error('Insufficient voting power'); @@ -61,25 +58,28 @@ export class VotingTransactionExample { ); // 5. Update proposal vote counts - const updateField = voteType === 'for' ? 'votes_for' : voteType === 'against' ? 'votes_against' : 'votes_abstain'; + const updateField = + voteType === 'for' + ? 'votes_for' + : voteType === 'against' + ? 'votes_against' + : 'votes_abstain'; await manager.query( `UPDATE proposals SET ${updateField} = ${updateField} + $1, total_votes = total_votes + $1 WHERE id = $2`, [votingPower, proposalId], ); // 6. Check if proposal reached quorum - const updatedProposal = await manager.query( - 'SELECT * FROM proposals WHERE id = $1', - [proposalId], - ); + const updatedProposal = await manager.query('SELECT * FROM proposals WHERE id = $1', [ + proposalId, + ]); const { total_votes, quorum_required } = updatedProposal[0]; - + if (total_votes >= quorum_required) { - await manager.query( - 'UPDATE proposals SET quorum_reached = true WHERE id = $1', - [proposalId], - ); + await manager.query('UPDATE proposals SET quorum_reached = true WHERE id = $1', [ + proposalId, + ]); this.logger.log(`Proposal ${proposalId} reached quorum`); } @@ -133,18 +133,28 @@ export class VotingTransactionExample { } // 3. Update vote counts - remove old vote - const oldField = oldVoteType === 'for' ? 'votes_for' : oldVoteType === 'against' ? 'votes_against' : 'votes_abstain'; - await manager.query( - `UPDATE proposals SET ${oldField} = ${oldField} - $1 WHERE id = $2`, - [voting_power, proposalId], - ); + const oldField = + oldVoteType === 'for' + ? 'votes_for' + : oldVoteType === 'against' + ? 'votes_against' + : 'votes_abstain'; + await manager.query(`UPDATE proposals SET ${oldField} = ${oldField} - $1 WHERE id = $2`, [ + voting_power, + proposalId, + ]); // 4. Update vote counts - add new vote - const newField = newVoteType === 'for' ? 'votes_for' : newVoteType === 'against' ? 'votes_against' : 'votes_abstain'; - await manager.query( - `UPDATE proposals SET ${newField} = ${newField} + $1 WHERE id = $2`, - [voting_power, proposalId], - ); + const newField = + newVoteType === 'for' + ? 'votes_for' + : newVoteType === 'against' + ? 'votes_against' + : 'votes_abstain'; + await manager.query(`UPDATE proposals SET ${newField} = ${newField} + $1 WHERE id = $2`, [ + voting_power, + proposalId, + ]); // 5. Update vote record await manager.query( @@ -155,10 +165,18 @@ export class VotingTransactionExample { // 6. Log the change await manager.query( 'INSERT INTO activity_logs (user_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)', - [userId, 'vote_changed', 'proposal', proposalId, JSON.stringify({ from: oldVoteType, to: newVoteType })], + [ + userId, + 'vote_changed', + 'proposal', + proposalId, + JSON.stringify({ from: oldVoteType, to: newVoteType }), + ], ); - this.logger.log(`Vote changed: ${userId} changed from ${oldVoteType} to ${newVoteType} on ${proposalId}`); + this.logger.log( + `Vote changed: ${userId} changed from ${oldVoteType} to ${newVoteType} on ${proposalId}`, + ); return { success: true, oldVoteType, newVoteType }; }); @@ -179,40 +197,35 @@ export class VotingTransactionExample { throw new Error('Proposal not found or not active'); } - const { - votes_for, - votes_against, - total_votes, - quorum_required, - approval_threshold, - } = proposal[0]; + const { votes_for, votes_against, total_votes, quorum_required, approval_threshold } = + proposal[0]; // 2. Check if voting period ended const now = new Date(); const endTime = new Date(proposal[0].voting_end_time); - + if (now < endTime) { throw new Error('Voting period has not ended'); } // 3. Check quorum if (total_votes < quorum_required) { - await manager.query( - 'UPDATE proposals SET status = $1, executed_at = NOW() WHERE id = $2', - ['failed_quorum', proposalId], - ); + await manager.query('UPDATE proposals SET status = $1, executed_at = NOW() WHERE id = $2', [ + 'failed_quorum', + proposalId, + ]); return { success: false, reason: 'Quorum not reached' }; } // 4. Check approval threshold const approvalRate = (votes_for / total_votes) * 100; - + if (approvalRate < approval_threshold) { - await manager.query( - 'UPDATE proposals SET status = $1, executed_at = NOW() WHERE id = $2', - ['rejected', proposalId], - ); + await manager.query('UPDATE proposals SET status = $1, executed_at = NOW() WHERE id = $2', [ + 'rejected', + proposalId, + ]); return { success: false, reason: 'Approval threshold not met' }; } @@ -229,15 +242,20 @@ export class VotingTransactionExample { } // 6. Mark proposal as executed - await manager.query( - 'UPDATE proposals SET status = $1, executed_at = NOW() WHERE id = $2', - ['executed', proposalId], - ); + await manager.query('UPDATE proposals SET status = $1, executed_at = NOW() WHERE id = $2', [ + 'executed', + proposalId, + ]); // 7. Log execution await manager.query( 'INSERT INTO activity_logs (action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4)', - ['proposal_executed', 'proposal', proposalId, JSON.stringify({ votes_for, votes_against, total_votes })], + [ + 'proposal_executed', + 'proposal', + proposalId, + JSON.stringify({ votes_for, votes_against, total_votes }), + ], ); this.logger.log(`Proposal executed: ${proposalId}`); @@ -254,17 +272,17 @@ export class VotingTransactionExample { switch (action_type) { case 'transfer_funds': - await manager.query( - 'UPDATE users SET balance = balance + $1 WHERE id = $2', - [parameters.amount, parameters.recipient], - ); + await manager.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [ + parameters.amount, + parameters.recipient, + ]); break; case 'update_setting': - await manager.query( - 'UPDATE settings SET value = $1 WHERE key = $2', - [parameters.value, parameters.key], - ); + await manager.query('UPDATE settings SET value = $1 WHERE key = $2', [ + parameters.value, + parameters.key, + ]); break; // Add more action types as needed diff --git a/src/common/database/transaction-helper.service.ts b/src/common/database/transaction-helper.service.ts index a1026b9..e22a0a7 100644 --- a/src/common/database/transaction-helper.service.ts +++ b/src/common/database/transaction-helper.service.ts @@ -5,9 +5,7 @@ import { DataSource, EntityManager } from 'typeorm'; export class TransactionHelperService { constructor(private readonly dataSource: DataSource) {} - async executeInTransaction( - operation: (manager: EntityManager) => Promise, - ): Promise { + async executeInTransaction(operation: (manager: EntityManager) => Promise): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); @@ -25,4 +23,4 @@ export class TransactionHelperService { await queryRunner.release(); } } -} \ No newline at end of file +} diff --git a/src/common/database/transaction.service.ts b/src/common/database/transaction.service.ts index c07e8c0..2266f21 100644 --- a/src/common/database/transaction.service.ts +++ b/src/common/database/transaction.service.ts @@ -15,21 +15,19 @@ export class TransactionService { * Execute operations within a transaction * Automatically handles commit and rollback */ - async runInTransaction( - operation: (manager: EntityManager) => Promise, - ): Promise { + async runInTransaction(operation: (manager: EntityManager) => Promise): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { this.logger.debug('Transaction started'); - + const result = await operation(queryRunner.manager); - + await queryRunner.commitTransaction(); this.logger.debug('Transaction committed successfully'); - + return result; } catch (error) { await queryRunner.rollbackTransaction(); @@ -45,9 +43,7 @@ export class TransactionService { * Execute operations with manual transaction control * Useful for complex scenarios requiring custom logic */ - async withTransaction( - callback: (queryRunner: QueryRunner) => Promise, - ): Promise { + async withTransaction(callback: (queryRunner: QueryRunner) => Promise): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); @@ -104,7 +100,7 @@ export class TransactionService { return await this.runInTransaction(operation); } catch (error) { lastError = error as Error; - + // Check if error is retryable (deadlock, serialization failure, etc.) if (this.isRetryableError(error) && attempt < maxRetries) { this.logger.warn( @@ -143,7 +139,7 @@ export class TransactionService { if (parentManager) { // Use existing transaction with savepoint await parentManager.query(`SAVEPOINT ${savepointName}`); - + try { const result = await operation(parentManager); await parentManager.query(`RELEASE SAVEPOINT ${savepointName}`); diff --git a/src/common/database/transactional.interceptor.ts b/src/common/database/transactional.interceptor.ts index e623c93..20fe8ba 100644 --- a/src/common/database/transactional.interceptor.ts +++ b/src/common/database/transactional.interceptor.ts @@ -1,10 +1,4 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; import { TransactionService } from './transaction.service'; @@ -22,10 +16,7 @@ export class TransactionalInterceptor implements NestInterceptor { private readonly transactionService: TransactionService, ) {} - async intercept( - context: ExecutionContext, - next: CallHandler, - ): Promise> { + async intercept(context: ExecutionContext, next: CallHandler): Promise> { const options = this.reflector.get( TRANSACTIONAL_KEY, context.getHandler(), @@ -39,9 +30,7 @@ export class TransactionalInterceptor implements NestInterceptor { const className = context.getClass().name; const methodName = handler.name; - this.logger.debug( - `Executing transactional method: ${className}.${methodName}`, - ); + this.logger.debug(`Executing transactional method: ${className}.${methodName}`); try { let result; @@ -63,11 +52,9 @@ export class TransactionalInterceptor implements NestInterceptor { }, ); } else { - result = await this.transactionService.runInTransaction( - async (manager) => { - return await next.handle().toPromise(); - }, - ); + result = await this.transactionService.runInTransaction(async (manager) => { + return await next.handle().toPromise(); + }); } return new Observable((subscriber) => { @@ -75,10 +62,7 @@ export class TransactionalInterceptor implements NestInterceptor { subscriber.complete(); }); } catch (error) { - this.logger.error( - `Transaction failed in ${className}.${methodName}:`, - error, - ); + this.logger.error(`Transaction failed in ${className}.${methodName}:`, error); throw error; } } diff --git a/src/common/decorators/roles.decorator.ts b/src/common/decorators/roles.decorator.ts index 852ec53..909f8db 100644 --- a/src/common/decorators/roles.decorator.ts +++ b/src/common/decorators/roles.decorator.ts @@ -25,4 +25,4 @@ export const ROLES_KEY = 'roles'; * \@Delete('posts/:id') * deletePost() {} */ -export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); \ No newline at end of file +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts index c4b4154..89880b5 100644 --- a/src/common/guards/roles.guard.ts +++ b/src/common/guards/roles.guard.ts @@ -1,8 +1,4 @@ -import { - Injectable, - CanActivate, - ExecutionContext, -} from '@nestjs/common'; +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ROLES_KEY } from '../decorators/roles.decorator'; @@ -24,6 +20,6 @@ export class RolesGuard implements CanActivate { return false; } // Support multiple roles per endpoint - return requiredRoles.some(role => user.roles.includes(role)); + return requiredRoles.some((role) => user.roles.includes(role)); } -} \ No newline at end of file +} diff --git a/src/common/guards/throttle.guard.ts b/src/common/guards/throttle.guard.ts index 954eff5..f82f45b 100644 --- a/src/common/guards/throttle.guard.ts +++ b/src/common/guards/throttle.guard.ts @@ -1,10 +1,4 @@ -import { - Injectable, - ExecutionContext, - HttpException, - HttpStatus, - Logger, -} from '@nestjs/common'; +import { Injectable, ExecutionContext, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { ThrottlerGuard, ThrottlerException } from '@nestjs/throttler'; import { Request, Response } from 'express'; @@ -21,18 +15,14 @@ export class CustomThrottleGuard extends ThrottlerGuard { private readonly logger = new Logger(CustomThrottleGuard.name); /** Called by ThrottlerGuard when the limit is exceeded. */ - protected override throwThrottlingException( - context: ExecutionContext, - ): Promise { + protected override throwThrottlingException(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); const ip = this.resolveClientIp(request); const route = request.route?.path ?? request.url; - this.logger.warn( - `Rate limit exceeded: ip=${ip} method=${request.method} route=${route}`, - ); + this.logger.warn(`Rate limit exceeded: ip=${ip} method=${request.method} route=${route}`); // Inject standard rate-limit headers so clients can back off gracefully response.setHeader('Retry-After', '60'); @@ -42,8 +32,7 @@ export class CustomThrottleGuard extends ThrottlerGuard { { statusCode: HttpStatus.TOO_MANY_REQUESTS, error: 'Too Many Requests', - message: - 'You have exceeded the request rate limit. Please wait before retrying.', + message: 'You have exceeded the request rate limit. Please wait before retrying.', retryAfterSeconds: 60, }, HttpStatus.TOO_MANY_REQUESTS, @@ -55,4 +44,4 @@ export class CustomThrottleGuard extends ThrottlerGuard { if (typeof forwarded === 'string') return forwarded.split(',')[0].trim(); return request.ip ?? request.socket?.remoteAddress ?? 'unknown'; } -} \ No newline at end of file +} diff --git a/src/common/interceptors/api-error.interface.ts b/src/common/interceptors/api-error.interface.ts index 0833902..ff1bbb5 100644 --- a/src/common/interceptors/api-error.interface.ts +++ b/src/common/interceptors/api-error.interface.ts @@ -13,4 +13,4 @@ export interface ApiError { export interface ValidationErrorDetail { field: string; constraints: Record; -} \ No newline at end of file +} diff --git a/src/common/interceptors/cache.interceptor.ts b/src/common/interceptors/cache.interceptor.ts index 49a77ac..00e483e 100644 --- a/src/common/interceptors/cache.interceptor.ts +++ b/src/common/interceptors/cache.interceptor.ts @@ -1,9 +1,4 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Cache } from 'cache-manager'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @@ -29,17 +24,17 @@ export class CacheInterceptor implements NestInterceptor { const cached = await this.cacheManager.get(key); if (cached) { res.setHeader('X-Cache', 'HIT'); - return new Observable(observer => { + return new Observable((observer) => { observer.next(cached); observer.complete(); }); } return next.handle().pipe( - tap(async data => { + tap(async (data) => { await this.cacheManager.set(key, data, parseInt(process.env.REDIS_TTL || '60', 10)); res.setHeader('X-Cache', 'MISS'); - }) + }), ); } } diff --git a/src/common/interceptors/global-exception.filter.ts b/src/common/interceptors/global-exception.filter.ts index c9caeea..db85ff1 100644 --- a/src/common/interceptors/global-exception.filter.ts +++ b/src/common/interceptors/global-exception.filter.ts @@ -20,8 +20,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - const { statusCode, message, error, details, stack } = - this.resolveException(exception); + const { statusCode, message, error, details, stack } = this.resolveException(exception); const errorResponse: ApiError = { statusCode, @@ -105,18 +104,15 @@ export class GlobalExceptionFilter implements ExceptionFilter { const stack = exception.stack; // class-validator wraps errors as { message: string[], error: string } - if ( - typeof exceptionResponse === 'object' && - exceptionResponse !== null - ) { + if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { const res = exceptionResponse as Record; const rawMessages = res['message']; const messages: string[] = Array.isArray(rawMessages) ? (rawMessages as string[]) : typeof rawMessages === 'string' - ? [rawMessages] - : [exception.message]; + ? [rawMessages] + : [exception.message]; // Parse class-validator constraint objects when present const details = this.extractValidationDetails(rawMessages); @@ -132,10 +128,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { return { statusCode, - message: - typeof exceptionResponse === 'string' - ? exceptionResponse - : exception.message, + message: typeof exceptionResponse === 'string' ? exceptionResponse : exception.message, error: exception.message, stack, }; @@ -147,8 +140,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { error: string; stack?: string; } { - const driverError = (exception as QueryFailedError & { code?: string }) - .code; + const driverError = (exception as QueryFailedError & { code?: string }).code; // PostgreSQL unique-violation if (driverError === '23505') { @@ -173,9 +165,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { // Generic DB error – never expose query details in production return { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, - message: this.isProduction - ? 'A database error occurred.' - : exception.message, + message: this.isProduction ? 'A database error occurred.' : exception.message, error: 'Database Error', stack: exception.stack, }; @@ -185,9 +175,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { * Converts class-validator nested error objects into structured details when * the raw message array contains constraint objects rather than plain strings. */ - private extractValidationDetails( - raw: unknown, - ): ValidationErrorDetail[] { + private extractValidationDetails(raw: unknown): ValidationErrorDetail[] { if (!Array.isArray(raw)) return []; return raw.reduce((acc, item) => { @@ -199,11 +187,10 @@ export class GlobalExceptionFilter implements ExceptionFilter { ) { acc.push({ property: (item as { property: string }).property, - constraints: (item as { constraints: Record }) - .constraints, + constraints: (item as { constraints: Record }).constraints, }); } return acc; }, []); } -} \ No newline at end of file +} diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts index 2295687..5af3c4b 100644 --- a/src/common/interceptors/logging.interceptor.ts +++ b/src/common/interceptors/logging.interceptor.ts @@ -1,10 +1,4 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { throwError } from 'rxjs'; @@ -139,4 +133,4 @@ export class LoggingInterceptor implements NestInterceptor { const header = response.getHeader('content-length'); return header !== undefined ? Number(header) : undefined; } -} \ No newline at end of file +} diff --git a/src/common/interceptors/monitoring.interceptor.ts b/src/common/interceptors/monitoring.interceptor.ts index 7d5d80b..e8f05f2 100644 --- a/src/common/interceptors/monitoring.interceptor.ts +++ b/src/common/interceptors/monitoring.interceptor.ts @@ -11,10 +11,10 @@ export class MonitoringInterceptor implements NestInterceptor { const now = Date.now(); const httpContext = context.switchToHttp(); const request = httpContext.getRequest(); - + // Some requests might not be HTTP (e.g. WebSocket or Microservice), check if request exists if (!request) { - return next.handle(); + return next.handle(); } const method = request.method || 'UNKNOWN'; @@ -29,11 +29,11 @@ export class MonitoringInterceptor implements NestInterceptor { this.metricsService.recordHttpRequest(method, route, statusCode, duration); }, error: (error) => { - // Track errors too - const duration = (Date.now() - now) / 1000; - const status = error.status || 500; - this.metricsService.recordHttpRequest(method, route, status, duration); - } + // Track errors too + const duration = (Date.now() - now) / 1000; + const status = error.status || 500; + this.metricsService.recordHttpRequest(method, route, status, duration); + }, }), ); } diff --git a/src/common/interceptors/response-transform.interceptor.ts b/src/common/interceptors/response-transform.interceptor.ts index 73c5707..ff650b0 100644 --- a/src/common/interceptors/response-transform.interceptor.ts +++ b/src/common/interceptors/response-transform.interceptor.ts @@ -1,14 +1,8 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Response } from 'express'; - export interface ApiResponse { success: boolean; message?: string; @@ -17,9 +11,7 @@ export interface ApiResponse { } @Injectable() -export class ResponseTransformInterceptor - implements NestInterceptor> -{ +export class ResponseTransformInterceptor implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { const ctx = context.switchToHttp(); const response = ctx.getResponse(); @@ -28,13 +20,12 @@ export class ResponseTransformInterceptor const contentType = response.getHeader('Content-Type'); if ( response.headersSent || - (contentType && ( - contentType.toString().includes('octet-stream') || - contentType.toString().includes('application/pdf') || - contentType.toString().startsWith('image/') || - contentType.toString().startsWith('audio/') || - contentType.toString().startsWith('video/') - )) + (contentType && + (contentType.toString().includes('octet-stream') || + contentType.toString().includes('application/pdf') || + contentType.toString().startsWith('image/') || + contentType.toString().startsWith('audio/') || + contentType.toString().startsWith('video/'))) ) { // Return as Observable> by casting, since we skip transformation return next.handle() as unknown as Observable>; @@ -59,7 +50,7 @@ export class ResponseTransformInterceptor data: responseData, metadata, }; - }) + }), ); } } diff --git a/src/common/interceptors/timeout.interceptor.ts b/src/common/interceptors/timeout.interceptor.ts index f2518f1..0b1d2b0 100644 --- a/src/common/interceptors/timeout.interceptor.ts +++ b/src/common/interceptors/timeout.interceptor.ts @@ -24,7 +24,7 @@ export class TimeoutInterceptor implements NestInterceptor { const timeoutValue = customTimeout || DEFAULT_TIMEOUT; return next.handle().pipe( timeout(timeoutValue), - catchError(err => { + catchError((err) => { if (err instanceof TimeoutError) { throw new BadGatewayException({ statusCode: 504, @@ -34,7 +34,7 @@ export class TimeoutInterceptor implements NestInterceptor { }); } throw err; - }) + }), ); } } diff --git a/src/config/cache.config.ts b/src/config/cache.config.ts index a1624bd..9aa7e44 100644 --- a/src/config/cache.config.ts +++ b/src/config/cache.config.ts @@ -5,4 +5,4 @@ export const cacheConfig = { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), ttl: parseInt(process.env.REDIS_TTL || '60', 10), // default TTL in seconds -}; \ No newline at end of file +}; diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index bc2ea1c..4fe7027 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,9 +1,7 @@ import * as Joi from 'joi'; export const envValidationSchema = Joi.object({ - NODE_ENV: Joi.string() - .valid('development', 'production', 'test') - .default('development'), + NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), PORT: Joi.number().default(3000), diff --git a/src/courses/courses.controller.ts b/src/courses/courses.controller.ts index 1de2f2d..c660438 100644 --- a/src/courses/courses.controller.ts +++ b/src/courses/courses.controller.ts @@ -1,13 +1,4 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, - Query, -} from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; import { CoursesService } from './courses.service'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; @@ -67,20 +58,14 @@ export class CoursesController { // Modules @Post(':id/modules') - createModule( - @Param('id') courseId: string, - @Body() createModuleDto: CreateModuleDto, - ) { + createModule(@Param('id') courseId: string, @Body() createModuleDto: CreateModuleDto) { createModuleDto.courseId = courseId; return this.modulesService.create(createModuleDto); } // Lessons @Post('modules/:moduleId/lessons') - createLesson( - @Param('moduleId') moduleId: string, - @Body() createLessonDto: CreateLessonDto, - ) { + createLesson(@Param('moduleId') moduleId: string, @Body() createLessonDto: CreateLessonDto) { createLessonDto.moduleId = moduleId; return this.lessonsService.create(createLessonDto); } diff --git a/src/courses/courses.module.ts b/src/courses/courses.module.ts index ab572ba..bc885fb 100644 --- a/src/courses/courses.module.ts +++ b/src/courses/courses.module.ts @@ -12,9 +12,7 @@ import { Enrollment } from './entities/enrollment.entity'; import { User } from '../users/entities/user.entity'; @Module({ - imports: [ - TypeOrmModule.forFeature([Course, CourseModuleEntity, Lesson, Enrollment, User]), - ], + imports: [TypeOrmModule.forFeature([Course, CourseModuleEntity, Lesson, Enrollment, User])], controllers: [CoursesController], providers: [CoursesService, ModulesService, LessonsService, EnrollmentsService], exports: [CoursesService], diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 2336841..575d4a1 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -28,10 +28,9 @@ export class CoursesService { query.leftJoinAndSelect('course.instructor', 'instructor'); if (filter?.search) { - query.andWhere( - '(course.title ILIKE :search OR course.description ILIKE :search)', - { search: `%${filter.search}%` }, - ); + query.andWhere('(course.title ILIKE :search OR course.description ILIKE :search)', { + search: `%${filter.search}%`, + }); } if (filter?.status) { diff --git a/src/courses/enrollments/enrollments.service.ts b/src/courses/enrollments/enrollments.service.ts index 1e4c233..443a771 100644 --- a/src/courses/enrollments/enrollments.service.ts +++ b/src/courses/enrollments/enrollments.service.ts @@ -26,7 +26,7 @@ export class EnrollmentsService { const user = await this.usersRepository.findOneBy({ id: userId }); if (!user) throw new NotFoundException('User not found'); - + const course = await this.coursesRepository.findOneBy({ id: courseId }); if (!course) throw new NotFoundException('Course not found'); @@ -47,13 +47,13 @@ export class EnrollmentsService { } async updateProgress(enrollmentId: string, progress: number): Promise { - const enrollment = await this.enrollmentsRepository.findOneBy({ id: enrollmentId }); - if (!enrollment) throw new NotFoundException('Enrollment not found'); - - enrollment.progress = progress; - if (progress >= 100) { - enrollment.status = 'completed'; - } - return this.enrollmentsRepository.save(enrollment); + const enrollment = await this.enrollmentsRepository.findOneBy({ id: enrollmentId }); + if (!enrollment) throw new NotFoundException('Enrollment not found'); + + enrollment.progress = progress; + if (progress >= 100) { + enrollment.status = 'completed'; + } + return this.enrollmentsRepository.save(enrollment); } } diff --git a/src/courses/entities/enrollment.entity.ts b/src/courses/entities/enrollment.entity.ts index 263b756..f628e5d 100644 --- a/src/courses/entities/enrollment.entity.ts +++ b/src/courses/entities/enrollment.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Course } from './course.entity'; diff --git a/src/courses/guards/ws-jwt-auth.guard.ts b/src/courses/guards/ws-jwt-auth.guard.ts index 46fb579..f327851 100644 --- a/src/courses/guards/ws-jwt-auth.guard.ts +++ b/src/courses/guards/ws-jwt-auth.guard.ts @@ -1,6 +1,6 @@ -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; -import { Socket } from "socket.io"; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Socket } from 'socket.io'; @Injectable() export class WsJwtAuthGuard implements CanActivate { @@ -8,7 +8,8 @@ export class WsJwtAuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const client: Socket = context.switchToWs().getClient(); - const token = client.handshake.auth?.token || client.handshake.headers?.authorization?.split(" ")[1]; + const token = + client.handshake.auth?.token || client.handshake.headers?.authorization?.split(' ')[1]; if (!token) { client.disconnect(true); diff --git a/src/courses/modules/modules.service.ts b/src/courses/modules/modules.service.ts index b64f088..af1781b 100644 --- a/src/courses/modules/modules.service.ts +++ b/src/courses/modules/modules.service.ts @@ -32,10 +32,10 @@ export class ModulesService { where: { id }, relations: ['lessons'], order: { - lessons: { - order: 'ASC' - } as any - } + lessons: { + order: 'ASC', + } as any, + }, }); if (!module) { throw new NotFoundException(`Module with ID ${id} not found`); diff --git a/src/data-warehouse/data-warehouse.controller.ts b/src/data-warehouse/data-warehouse.controller.ts index ee2305a..5aa4e4c 100644 --- a/src/data-warehouse/data-warehouse.controller.ts +++ b/src/data-warehouse/data-warehouse.controller.ts @@ -1,4 +1,15 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; import { ETLPipelineService } from './etl/etl-pipeline.service'; import { DimensionalModelingService } from './modeling/dimensional-modeling.service'; import { DataQualityService } from './quality/data-quality.service'; @@ -43,18 +54,20 @@ export class DataWarehouseController { const model = await this.modelingService.createStarSchema( body.name, body.factTable, - body.dimensionTables + body.dimensionTables, ); return { success: true, model }; } @Post('modeling/snowflake-schema') - async createSnowflakeSchema(@Body() body: { name: string; factTable: any; dimensionTables: any[]; subDimensions: any }) { + async createSnowflakeSchema( + @Body() body: { name: string; factTable: any; dimensionTables: any[]; subDimensions: any }, + ) { const model = await this.modelingService.createSnowflakeSchema( body.name, body.factTable, body.dimensionTables, - body.subDimensions + body.subDimensions, ); return { success: true, model }; } @@ -78,10 +91,7 @@ export class DataWarehouseController { } @Post('modeling/query/:id/execute') - async executeQuery( - @Param('id') id: string, - @Body() body: { parameters?: any } - ) { + async executeQuery(@Param('id') id: string, @Body() body: { parameters?: any }) { const results = await this.modelingService.executeQuery(id, body.parameters); return { success: true, results }; } @@ -100,10 +110,7 @@ export class DataWarehouseController { } @Post('quality/check/:profileId') - async runQualityCheck( - @Param('profileId') profileId: string, - @Body() body: { data: any[] } - ) { + async runQualityCheck(@Param('profileId') profileId: string, @Body() body: { data: any[] }) { const check = await this.qualityService.runQualityChecks(profileId, body.data); return { success: true, check }; } @@ -118,7 +125,7 @@ export class DataWarehouseController { async getQualityIssues( @Query('profileId') profileId?: string, @Query('severity') severity?: string, - @Query('resolved') resolved?: string + @Query('resolved') resolved?: string, ) { const resolvedBool = resolved === 'true' ? true : resolved === 'false' ? false : undefined; const issues = await this.qualityService.getQualityIssues(profileId, severity, resolvedBool); @@ -160,7 +167,7 @@ export class DataWarehouseController { async traceLineage( @Param('id') graphId: string, @Param('nodeId') nodeId: string, - @Body() body: { traceType?: 'upstream' | 'downstream' | 'complete' } + @Body() body: { traceType?: 'upstream' | 'downstream' | 'complete' }, ) { const traceType = body.traceType || 'complete'; const trace = await this.lineageService.traceLineage(graphId, nodeId, traceType); @@ -181,7 +188,10 @@ export class DataWarehouseController { } @Post('loading/job/:id/execute') - async executeLoadJob(@Param('id') jobId: string, @Body() body: { sourceTable: string; targetTable: string }) { + async executeLoadJob( + @Param('id') jobId: string, + @Body() body: { sourceTable: string; targetTable: string }, + ) { const job = await this.loaderService.executeLoad(jobId, body.sourceTable, body.targetTable); return { success: true, job }; } @@ -203,14 +213,17 @@ export class DataWarehouseController { const watermark = await this.loaderService.setWatermark( body.tableName, body.columnName, - body.value + body.value, ); return { success: true, watermark }; } @Get('loading/watermark/:tableName/:columnName') - async getWatermark(@Param('tableName') tableName: string, @Param('columnName') columnName: string) { + async getWatermark( + @Param('tableName') tableName: string, + @Param('columnName') columnName: string, + ) { const watermark = await this.loaderService.getWatermark(tableName, columnName); return { success: true, watermark }; } -} \ No newline at end of file +} diff --git a/src/data-warehouse/data-warehouse.module.ts b/src/data-warehouse/data-warehouse.module.ts index c57b06b..841c220 100644 --- a/src/data-warehouse/data-warehouse.module.ts +++ b/src/data-warehouse/data-warehouse.module.ts @@ -24,4 +24,4 @@ import { DataWarehouseController } from './data-warehouse.controller'; IncrementalLoaderService, ], }) -export class DataWarehouseModule {} \ No newline at end of file +export class DataWarehouseModule {} diff --git a/src/data-warehouse/etl/etl-pipeline.service.ts b/src/data-warehouse/etl/etl-pipeline.service.ts index 7401bee..37d9092 100644 --- a/src/data-warehouse/etl/etl-pipeline.service.ts +++ b/src/data-warehouse/etl/etl-pipeline.service.ts @@ -110,11 +110,11 @@ export class ETLPipelineService { // Extract phase this.logger.log(`Starting extraction for job ${jobId}`); const extractedData = await this.extract(job.config.sourceConnection); - + // Transform phase this.logger.log(`Starting transformation for job ${jobId}`); const transformedData = await this.transform(extractedData, job.config.transformations); - + // Load phase this.logger.log(`Starting loading for job ${jobId}`); await this.load(transformedData, job.config.targetConnection); @@ -141,9 +141,9 @@ export class ETLPipelineService { private async extract(sourceConfig: DataSourceConfig): Promise { // This is a simplified implementation // In a real system, this would connect to various data sources - + let data: any[] = []; - + switch (sourceConfig.type) { case 'postgres': // Connect to PostgreSQL and execute query @@ -182,41 +182,46 @@ export class ETLPipelineService { /** * Transform extracted data */ - private async transform(extractedData: ExtractedData, transformations: TransformationRule[]): Promise { + private async transform( + extractedData: ExtractedData, + transformations: TransformationRule[], + ): Promise { let transformedData = [...extractedData.data]; const appliedTransformations: string[] = []; for (const rule of transformations) { switch (rule.transformationType) { case 'map': - transformedData = transformedData.map(item => ({ + transformedData = transformedData.map((item) => ({ ...item, - [rule.targetField]: this.applyMapping(item[rule.sourceField], rule.config) + [rule.targetField]: this.applyMapping(item[rule.sourceField], rule.config), })); break; - + case 'filter': - transformedData = transformedData.filter(item => - this.applyFilter(item[rule.sourceField], rule.config) + transformedData = transformedData.filter((item) => + this.applyFilter(item[rule.sourceField], rule.config), ); break; - + case 'calculate': - transformedData = transformedData.map(item => ({ + transformedData = transformedData.map((item) => ({ ...item, - [rule.targetField]: this.applyCalculation(item, rule.config) + [rule.targetField]: this.applyCalculation(item, rule.config), })); break; - + case 'format': - transformedData = transformedData.map(item => ({ + transformedData = transformedData.map((item) => ({ ...item, - [rule.targetField]: this.applyFormatting(item[rule.sourceField], rule.config) + [rule.targetField]: this.applyFormatting(item[rule.sourceField], rule.config), })); break; } - - appliedTransformations.push(`${rule.transformationType}:${rule.sourceField}->${rule.targetField}`); + + appliedTransformations.push( + `${rule.transformationType}:${rule.sourceField}->${rule.targetField}`, + ); } return { @@ -232,10 +237,13 @@ export class ETLPipelineService { /** * Load transformed data to target */ - private async load(transformedData: TransformedData, targetConfig: DataSourceConfig): Promise { + private async load( + transformedData: TransformedData, + targetConfig: DataSourceConfig, + ): Promise { // This is a simplified implementation // In a real system, this would connect to the target data warehouse - + switch (targetConfig.type) { case 'postgres': await this.loadToPostgres(transformedData, targetConfig); @@ -363,4 +371,4 @@ export class ETLPipelineService { } return value; } -} \ No newline at end of file +} diff --git a/src/data-warehouse/lineage/data-lineage.service.ts b/src/data-warehouse/lineage/data-lineage.service.ts index 0d931d1..d6f88fe 100644 --- a/src/data-warehouse/lineage/data-lineage.service.ts +++ b/src/data-warehouse/lineage/data-lineage.service.ts @@ -67,7 +67,9 @@ export class DataLineageService { /** * Create a new lineage graph */ - async createGraph(graphConfig: Omit): Promise { + async createGraph( + graphConfig: Omit, + ): Promise { const graphId = uuidv4(); const graph: DataLineageGraph = { id: graphId, @@ -101,7 +103,10 @@ export class DataLineageService { /** * Add a node to a lineage graph */ - async addNode(graphId: string, nodeConfig: Omit): Promise { + async addNode( + graphId: string, + nodeConfig: Omit, + ): Promise { const graph = this.graphs.get(graphId); if (!graph) { throw new Error(`Graph ${graphId} not found`); @@ -124,16 +129,19 @@ export class DataLineageService { /** * Add an edge to a lineage graph */ - async addEdge(graphId: string, edgeConfig: Omit): Promise { + async addEdge( + graphId: string, + edgeConfig: Omit, + ): Promise { const graph = this.graphs.get(graphId); if (!graph) { throw new Error(`Graph ${graphId} not found`); } // Validate that source and target nodes exist - const sourceExists = graph.nodes.some(node => node.id === edgeConfig.sourceId); - const targetExists = graph.nodes.some(node => node.id === edgeConfig.targetId); - + const sourceExists = graph.nodes.some((node) => node.id === edgeConfig.sourceId); + const targetExists = graph.nodes.some((node) => node.id === edgeConfig.targetId); + if (!sourceExists || !targetExists) { throw new Error('Source or target node not found in graph'); } @@ -158,7 +166,7 @@ export class DataLineageService { async traceLineage( graphId: string, nodeId: string, - traceType: 'upstream' | 'downstream' | 'complete' = 'complete' + traceType: 'upstream' | 'downstream' | 'complete' = 'complete', ): Promise { const graph = this.graphs.get(graphId); if (!graph) { @@ -237,9 +245,9 @@ export class DataLineageService { */ async getTracesForGraph(graphId: string): Promise { const traces = Array.from(this.traces.values()); - return traces.filter(trace => { + return traces.filter((trace) => { const graph = this.graphs.get(graphId); - return graph && graph.nodes.some(node => node.id === trace.nodeId); + return graph && graph.nodes.some((node) => node.id === trace.nodeId); }); } @@ -248,9 +256,9 @@ export class DataLineageService { */ async getImpactAnalysesForGraph(graphId: string): Promise { const analyses = Array.from(this.impactAnalyses.values()); - return analyses.filter(analysis => { + return analyses.filter((analysis) => { const graph = this.graphs.get(graphId); - return graph && graph.nodes.some(node => node.id === analysis.nodeId); + return graph && graph.nodes.some((node) => node.id === analysis.nodeId); }); } @@ -355,10 +363,7 @@ export class DataLineageService { /** * Search for nodes in lineage graphs */ - async searchNodes( - searchTerm: string, - graphId?: string - ): Promise { + async searchNodes(searchTerm: string, graphId?: string): Promise { let nodes: DataLineageNode[] = []; if (graphId) { @@ -374,17 +379,18 @@ export class DataLineageService { } // Filter by search term - return nodes.filter(node => - node.name.toLowerCase().includes(searchTerm.toLowerCase()) || - node.description.toLowerCase().includes(searchTerm.toLowerCase()) || - node.system.toLowerCase().includes(searchTerm.toLowerCase()) + return nodes.filter( + (node) => + node.name.toLowerCase().includes(searchTerm.toLowerCase()) || + node.description.toLowerCase().includes(searchTerm.toLowerCase()) || + node.system.toLowerCase().includes(searchTerm.toLowerCase()), ); } // Helper methods private traceUpstream(graph: DataLineageGraph, nodeId: string, path: LineagePath[]): void { - const incomingEdges = graph.edges.filter(edge => edge.targetId === nodeId); - + const incomingEdges = graph.edges.filter((edge) => edge.targetId === nodeId); + for (const edge of incomingEdges) { path.push({ fromNode: edge.sourceId, @@ -392,14 +398,14 @@ export class DataLineageService { transformation: edge.transformation || '', timestamp: edge.timestamp, }); - + this.traceUpstream(graph, edge.sourceId, path); } } private traceDownstream(graph: DataLineageGraph, nodeId: string, path: LineagePath[]): void { - const outgoingEdges = graph.edges.filter(edge => edge.sourceId === nodeId); - + const outgoingEdges = graph.edges.filter((edge) => edge.sourceId === nodeId); + for (const edge of outgoingEdges) { path.push({ fromNode: edge.sourceId, @@ -407,7 +413,7 @@ export class DataLineageService { transformation: edge.transformation || '', timestamp: edge.timestamp, }); - + this.traceDownstream(graph, edge.targetId, path); } } @@ -420,7 +426,7 @@ export class DataLineageService { if (visited.has(currentNodeId)) return; visited.add(currentNodeId); - const outgoingEdges = graph.edges.filter(edge => edge.sourceId === currentNodeId); + const outgoingEdges = graph.edges.filter((edge) => edge.sourceId === currentNodeId); for (const edge of outgoingEdges) { if (!affectedNodes.includes(edge.targetId)) { affectedNodes.push(edge.targetId); @@ -433,11 +439,14 @@ export class DataLineageService { return affectedNodes; } - private calculateImpactLevel(affectedCount: number, totalCount: number): 'high' | 'medium' | 'low' { + private calculateImpactLevel( + affectedCount: number, + totalCount: number, + ): 'high' | 'medium' | 'low' { const percentage = (affectedCount / totalCount) * 100; - + if (percentage >= 50) return 'high'; if (percentage >= 20) return 'medium'; return 'low'; } -} \ No newline at end of file +} diff --git a/src/data-warehouse/loading/incremental-loader.service.ts b/src/data-warehouse/loading/incremental-loader.service.ts index b638b45..5ac0575 100644 --- a/src/data-warehouse/loading/incremental-loader.service.ts +++ b/src/data-warehouse/loading/incremental-loader.service.ts @@ -73,12 +73,14 @@ export class IncrementalLoaderService { /** * Create an incremental load job */ - async createLoadJob(config: Omit, - sourceConfig: DataSourceConfig, - targetConfig: DataSourceConfig): Promise { + async createLoadJob( + config: Omit, + sourceConfig: DataSourceConfig, + targetConfig: DataSourceConfig, + ): Promise { const jobId = uuidv4(); const jobName = `Incremental_Load_${config.loadType}_${new Date().toISOString()}`; - + const job: IncrementalLoadJob = { id: jobId, name: jobName, @@ -106,7 +108,11 @@ export class IncrementalLoaderService { /** * Execute incremental load */ - async executeLoad(jobId: string, sourceTable: string, targetTable: string): Promise { + async executeLoad( + jobId: string, + sourceTable: string, + targetTable: string, + ): Promise { const job = this.jobs.get(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); @@ -118,7 +124,9 @@ export class IncrementalLoaderService { job.startTime = new Date(); try { - this.logger.log(`Starting incremental load for job ${jobId}: ${sourceTable} -> ${targetTable}`); + this.logger.log( + `Starting incremental load for job ${jobId}: ${sourceTable} -> ${targetTable}`, + ); let recordsProcessed = 0; let recordsInserted = 0; @@ -165,8 +173,9 @@ export class IncrementalLoaderService { job.recordsDeleted = recordsDeleted; this.logger.log(`Incremental load job ${jobId} completed successfully`); - this.logger.log(`Records processed: ${recordsProcessed}, Inserted: ${recordsInserted}, Updated: ${recordsUpdated}, Deleted: ${recordsDeleted}`); - + this.logger.log( + `Records processed: ${recordsProcessed}, Inserted: ${recordsInserted}, Updated: ${recordsUpdated}, Deleted: ${recordsDeleted}`, + ); } catch (error) { this.logger.error(`Incremental load job ${jobId} failed: ${error.message}`); job.status = 'failed'; @@ -180,14 +189,14 @@ export class IncrementalLoaderService { /** * Load data using timestamp-based approach */ - private async loadByTimestamp(job: IncrementalLoadJob): Promise<{ - processed: number; - inserted: number; - updated: number; + private async loadByTimestamp(job: IncrementalLoadJob): Promise<{ + processed: number; + inserted: number; + updated: number; }> { const timestampColumn = job.config.timestampColumn || 'updated_at'; const lastTimestamp = job.lastProcessedTimestamp || new Date(0); - + this.logger.log(`Loading data newer than ${lastTimestamp.toISOString()}`); // Get incremental data from source @@ -195,7 +204,7 @@ export class IncrementalLoaderService { job.config.sourceConnection, job.sourceTable, timestampColumn, - lastTimestamp + lastTimestamp, ); // Apply changes to target @@ -204,12 +213,14 @@ export class IncrementalLoaderService { job.targetTable, incrementalData, job.config.primaryKey, - 'timestamp' + 'timestamp', ); // Update last processed timestamp if (incrementalData.length > 0) { - const maxTimestamp = Math.max(...incrementalData.map(row => new Date(row[timestampColumn]).getTime())); + const maxTimestamp = Math.max( + ...incrementalData.map((row) => new Date(row[timestampColumn]).getTime()), + ); job.lastProcessedTimestamp = new Date(maxTimestamp); } @@ -219,14 +230,14 @@ export class IncrementalLoaderService { /** * Load data using sequence-based approach */ - private async loadBySequence(job: IncrementalLoadJob): Promise<{ - processed: number; - inserted: number; - updated: number; + private async loadBySequence(job: IncrementalLoadJob): Promise<{ + processed: number; + inserted: number; + updated: number; }> { const sequenceColumn = job.config.sequenceColumn || 'id'; const lastId = job.lastProcessedId || 0; - + this.logger.log(`Loading data with ${sequenceColumn} > ${lastId}`); // Get incremental data from source @@ -234,7 +245,7 @@ export class IncrementalLoaderService { job.config.sourceConnection, job.sourceTable, sequenceColumn, - lastId + lastId, ); // Apply changes to target @@ -243,12 +254,12 @@ export class IncrementalLoaderService { job.targetTable, incrementalData, job.config.primaryKey, - 'sequence' + 'sequence', ); // Update last processed ID if (incrementalData.length > 0) { - const maxId = Math.max(...incrementalData.map(row => row[sequenceColumn])); + const maxId = Math.max(...incrementalData.map((row) => row[sequenceColumn])); job.lastProcessedId = maxId; } @@ -258,10 +269,10 @@ export class IncrementalLoaderService { /** * Load data using watermark approach */ - private async loadByWatermark(job: IncrementalLoadJob): Promise<{ - processed: number; - inserted: number; - updated: number; + private async loadByWatermark(job: IncrementalLoadJob): Promise<{ + processed: number; + inserted: number; + updated: number; }> { const watermarkColumn = job.config.watermarkColumn || 'updated_at'; const watermarkKey = `${job.sourceTable}_${watermarkColumn}`; @@ -275,7 +286,7 @@ export class IncrementalLoaderService { job.config.sourceConnection, job.sourceTable, watermarkColumn, - lastValue + lastValue, ); // Apply changes to target @@ -284,12 +295,12 @@ export class IncrementalLoaderService { job.targetTable, incrementalData, job.config.primaryKey, - 'watermark' + 'watermark', ); // Update watermark if (incrementalData.length > 0) { - const maxValue = Math.max(...incrementalData.map(row => row[watermarkColumn])); + const maxValue = Math.max(...incrementalData.map((row) => row[watermarkColumn])); const newWatermark: Watermark = { id: uuidv4(), tableName: job.sourceTable, @@ -306,15 +317,15 @@ export class IncrementalLoaderService { /** * Load data using CDC (Change Data Capture) */ - private async loadByCDC(job: IncrementalLoadJob): Promise<{ - processed: number; - inserted: number; - updated: number; - deleted: number; + private async loadByCDC(job: IncrementalLoadJob): Promise<{ + processed: number; + inserted: number; + updated: number; + deleted: number; }> { const cdcKey = `${job.sourceTable}_cdc`; const events = this.cdcEvents.get(cdcKey) || []; - + this.logger.log(`Processing ${events.length} CDC events`); let inserted = 0; @@ -325,30 +336,22 @@ export class IncrementalLoaderService { for (const event of events) { switch (event.operation) { case 'INSERT': - await this.insertRecord( - job.config.targetConnection, - job.targetTable, - event.newValues - ); + await this.insertRecord(job.config.targetConnection, job.targetTable, event.newValues); inserted++; break; - + case 'UPDATE': await this.updateRecord( job.config.targetConnection, job.targetTable, event.primaryKey, - event.newValues + event.newValues, ); updated++; break; - + case 'DELETE': - await this.deleteRecord( - job.config.targetConnection, - job.targetTable, - event.primaryKey - ); + await this.deleteRecord(job.config.targetConnection, job.targetTable, event.primaryKey); deleted++; break; } @@ -439,7 +442,7 @@ export class IncrementalLoaderService { connection: DataSourceConfig, table: string, column: string, - timestamp: Date + timestamp: Date, ): Promise { // Implementation would connect to source database and query this.logger.log(`Querying ${table} where ${column} > ${timestamp.toISOString()}`); @@ -450,7 +453,7 @@ export class IncrementalLoaderService { connection: DataSourceConfig, table: string, column: string, - value: any + value: any, ): Promise { // Implementation would connect to source database and query this.logger.log(`Querying ${table} where ${column} > ${value}`); @@ -462,11 +465,11 @@ export class IncrementalLoaderService { table: string, data: any[], primaryKey: string[], - loadType: string + loadType: string, ): Promise<{ processed: number; inserted: number; updated: number }> { // Implementation would apply changes to target database this.logger.log(`Applying ${data.length} records to ${table} using ${loadType} strategy`); - + return { processed: data.length, inserted: data.length, // Simplified logic @@ -474,7 +477,11 @@ export class IncrementalLoaderService { }; } - private async insertRecord(connection: DataSourceConfig, table: string, record: any): Promise { + private async insertRecord( + connection: DataSourceConfig, + table: string, + record: any, + ): Promise { // Implementation would insert record into target this.logger.log(`Inserting record into ${table}`); } @@ -483,7 +490,7 @@ export class IncrementalLoaderService { connection: DataSourceConfig, table: string, primaryKey: { [key: string]: any }, - values: { [key: string]: any } + values: { [key: string]: any }, ): Promise { // Implementation would update record in target this.logger.log(`Updating record in ${table} where ${JSON.stringify(primaryKey)}`); @@ -492,7 +499,7 @@ export class IncrementalLoaderService { private async deleteRecord( connection: DataSourceConfig, table: string, - primaryKey: { [key: string]: any } + primaryKey: { [key: string]: any }, ): Promise { // Implementation would delete record from target this.logger.log(`Deleting record from ${table} where ${JSON.stringify(primaryKey)}`); diff --git a/src/data-warehouse/modeling/dimensional-modeling.service.ts b/src/data-warehouse/modeling/dimensional-modeling.service.ts index 24407d2..74a153c 100644 --- a/src/data-warehouse/modeling/dimensional-modeling.service.ts +++ b/src/data-warehouse/modeling/dimensional-modeling.service.ts @@ -109,7 +109,9 @@ export class DimensionalModelingService { /** * Create a new dimensional model */ - async createModel(modelConfig: Omit): Promise { + async createModel( + modelConfig: Omit, + ): Promise { const modelId = uuidv4(); const model: DimensionalModel = { id: modelId, @@ -141,7 +143,10 @@ export class DimensionalModelingService { /** * Update a dimensional model */ - async updateModel(modelId: string, updates: Partial): Promise { + async updateModel( + modelId: string, + updates: Partial, + ): Promise { const model = this.models.get(modelId); if (!model) { return null; @@ -177,20 +182,20 @@ export class DimensionalModelingService { async createStarSchema( name: string, factTable: Omit, - dimensionTables: Omit[] + dimensionTables: Array>, ): Promise { const factTableWithId: FactTable = { ...factTable, id: uuidv4(), }; - const dimensionTablesWithIds: DimensionTable[] = dimensionTables.map(dim => ({ + const dimensionTablesWithIds: DimensionTable[] = dimensionTables.map((dim) => ({ ...dim, id: uuidv4(), })); // Create foreign keys for each dimension - const foreignKeys: ForeignKey[] = dimensionTablesWithIds.map(dim => ({ + const foreignKeys: ForeignKey[] = dimensionTablesWithIds.map((dim) => ({ id: uuidv4(), name: `${dim.name}_id`, referencedTable: dim.name, @@ -216,15 +221,15 @@ export class DimensionalModelingService { async createSnowflakeSchema( name: string, factTable: Omit, - dimensionTables: Omit[], - subDimensions: { [key: string]: Omit[] } + dimensionTables: Array>, + subDimensions: { [key: string]: Array> }, ): Promise { const factTableWithId: FactTable = { ...factTable, id: uuidv4(), }; - const dimensionTablesWithIds: DimensionTable[] = dimensionTables.map(dim => ({ + const dimensionTablesWithIds: DimensionTable[] = dimensionTables.map((dim) => ({ ...dim, id: uuidv4(), })); @@ -234,17 +239,17 @@ export class DimensionalModelingService { const relationships: Relationship[] = []; for (const [parentDimName, subDims] of Object.entries(subDimensions)) { - const parentDim = dimensionTablesWithIds.find(d => d.name === parentDimName); + const parentDim = dimensionTablesWithIds.find((d) => d.name === parentDimName); if (parentDim) { - const subDimWithIds = subDims.map(sub => ({ + const subDimWithIds = subDims.map((sub) => ({ ...sub, id: uuidv4(), })); - + allDimensions.push(...subDimWithIds); - + // Create relationships between parent and sub-dimensions - subDimWithIds.forEach(subDim => { + subDimWithIds.forEach((subDim) => { relationships.push({ id: uuidv4(), fromTable: parentDim.name, @@ -257,7 +262,7 @@ export class DimensionalModelingService { } // Create foreign keys for fact table - const foreignKeys: ForeignKey[] = allDimensions.map(dim => ({ + const foreignKeys: ForeignKey[] = allDimensions.map((dim) => ({ id: uuidv4(), name: `${dim.name}_id`, referencedTable: dim.name, @@ -314,7 +319,7 @@ export class DimensionalModelingService { // In a real implementation, this would execute against the data warehouse this.logger.log(`Executing query ${queryId} with parameters:`, parameters); - + // Return mock data for demonstration return this.generateMockResults(query, parameters); } @@ -326,7 +331,7 @@ export class DimensionalModelingService { queryId: string, parameters: { [key: string]: any } = {}, page: number = 1, - limit: number = 100 + limit: number = 100, ): Promise<{ data: any[]; total: number; page: number; limit: number }> { const results = await this.executeQuery(queryId, parameters); const total = results.length; @@ -347,7 +352,7 @@ export class DimensionalModelingService { */ async getQueriesForModel(modelId: string): Promise { const queries = Array.from(this.queries.values()); - return queries.filter(query => query.modelId === modelId); + return queries.filter((query) => query.modelId === modelId); } /** @@ -374,11 +379,11 @@ export class DimensionalModelingService { for (const relationship of model.relationships) { const fromTable = this.findTableByName(model, relationship.fromTable); const toTable = this.findTableByName(model, relationship.toTable); - + if (!fromTable) { errors.push(`Relationship references non-existent table: ${relationship.fromTable}`); } - + if (!toTable) { errors.push(`Relationship references non-existent table: ${relationship.toTable}`); } @@ -391,8 +396,11 @@ export class DimensionalModelingService { } // Helper methods - private createStarRelationships(factTable: FactTable, dimensionTables: DimensionTable[]): Relationship[] { - return dimensionTables.map(dim => ({ + private createStarRelationships( + factTable: FactTable, + dimensionTables: DimensionTable[], + ): Relationship[] { + return dimensionTables.map((dim) => ({ id: uuidv4(), fromTable: factTable.name, toTable: dim.name, @@ -401,11 +409,14 @@ export class DimensionalModelingService { })); } - private findTableByName(model: DimensionalModel, tableName: string): FactTable | DimensionTable | undefined { - const factTable = model.factTables.find(ft => ft.name === tableName); + private findTableByName( + model: DimensionalModel, + tableName: string, + ): FactTable | DimensionTable | undefined { + const factTable = model.factTables.find((ft) => ft.name === tableName); if (factTable) return factTable; - - return model.dimensionTables.find(dt => dt.name === tableName); + + return model.dimensionTables.find((dt) => dt.name === tableName); } private generateMockResults(query: AnalyticsQuery, parameters: { [key: string]: any }): any[] { @@ -415,20 +426,20 @@ export class DimensionalModelingService { for (let i = 0; i < rowCount; i++) { const row: any = {}; - + // Add metrics - query.metrics.forEach(metric => { + query.metrics.forEach((metric) => { row[metric] = Math.floor(Math.random() * 10000); }); - + // Add dimensions - query.dimensions.forEach(dimension => { + query.dimensions.forEach((dimension) => { row[dimension] = `Value_${dimension}_${i}`; }); - + results.push(row); } return results; } -} \ No newline at end of file +} diff --git a/src/data-warehouse/quality/data-quality.service.ts b/src/data-warehouse/quality/data-quality.service.ts index 6557721..eecffb9 100644 --- a/src/data-warehouse/quality/data-quality.service.ts +++ b/src/data-warehouse/quality/data-quality.service.ts @@ -79,7 +79,9 @@ export class DataQualityService { /** * Create a data quality profile */ - async createProfile(profileConfig: Omit): Promise { + async createProfile( + profileConfig: Omit, + ): Promise { const profileId = uuidv4(); const profile: DataQualityProfile = { id: profileId, @@ -111,7 +113,10 @@ export class DataQualityService { /** * Update a data quality profile */ - async updateProfile(profileId: string, updates: Partial): Promise { + async updateProfile( + profileId: string, + updates: Partial, + ): Promise { const profile = this.profiles.get(profileId); if (!profile) { return null; @@ -190,10 +195,10 @@ export class DataQualityService { passedRules++; } else { failedRules++; - + // Count severity issues issuesBySeverity[rule.severity]++; - + if (rule.severity === 'critical') { criticalFailures++; } @@ -204,7 +209,8 @@ export class DataQualityService { } // Calculate overall score - const overallScore = profile.rules.length > 0 ? (passedRules / profile.rules.length) * 100 : 0; + const overallScore = + profile.rules.length > 0 ? (passedRules / profile.rules.length) * 100 : 0; // Update check with results check.results = results; @@ -220,7 +226,6 @@ export class DataQualityService { }; this.logger.log(`Data quality check ${checkId} completed with score: ${overallScore}%`); - } catch (error) { this.logger.error(`Data quality check ${checkId} failed: ${error.message}`); check.status = 'failed'; @@ -242,7 +247,7 @@ export class DataQualityService { */ async getChecksForProfile(profileId: string): Promise { const checks = Array.from(this.checks.values()); - return checks.filter(check => check.profileId === profileId); + return checks.filter((check) => check.profileId === profileId); } /** @@ -252,7 +257,7 @@ export class DataQualityService { profileId: string, rule: DataQualityRule, result: DataQualityResult, - data: any[] + data: any[], ): Promise { const issueId = uuidv4(); const issue: DataQualityIssue = { @@ -279,20 +284,20 @@ export class DataQualityService { async getQualityIssues( profileId?: string, severity?: string, - resolved?: boolean + resolved?: boolean, ): Promise { let issues = Array.from(this.issues.values()); if (profileId) { - issues = issues.filter(issue => issue.profileId === profileId); + issues = issues.filter((issue) => issue.profileId === profileId); } if (severity) { - issues = issues.filter(issue => issue.severity === severity); + issues = issues.filter((issue) => issue.severity === severity); } if (resolved !== undefined) { - issues = issues.filter(issue => issue.resolved === resolved); + issues = issues.filter((issue) => issue.resolved === resolved); } return issues; @@ -322,88 +327,94 @@ export class DataQualityService { const profiles: DataQualityProfile[] = []; // Completeness profile - profiles.push(await this.createProfile({ - name: 'Completeness Check', - description: 'Check for missing or null values in critical fields', - rules: [ - { - id: uuidv4(), - name: 'User Email Completeness', - description: 'Ensure user email addresses are not null or empty', - type: 'completeness', - field: 'email', - condition: 'not null', - threshold: 99.5, - severity: 'high', - }, - { - id: uuidv4(), - name: 'Post Content Completeness', - description: 'Ensure post content is not empty', - type: 'completeness', - field: 'content', - condition: 'not empty', - threshold: 98, - severity: 'medium', - }, - ], - })); + profiles.push( + await this.createProfile({ + name: 'Completeness Check', + description: 'Check for missing or null values in critical fields', + rules: [ + { + id: uuidv4(), + name: 'User Email Completeness', + description: 'Ensure user email addresses are not null or empty', + type: 'completeness', + field: 'email', + condition: 'not null', + threshold: 99.5, + severity: 'high', + }, + { + id: uuidv4(), + name: 'Post Content Completeness', + description: 'Ensure post content is not empty', + type: 'completeness', + field: 'content', + condition: 'not empty', + threshold: 98, + severity: 'medium', + }, + ], + }), + ); // Uniqueness profile - profiles.push(await this.createProfile({ - name: 'Uniqueness Check', - description: 'Check for duplicate or duplicate-like values', - rules: [ - { - id: uuidv4(), - name: 'User ID Uniqueness', - description: 'Ensure user IDs are unique', - type: 'uniqueness', - field: 'id', - condition: 'unique', - threshold: 100, - severity: 'critical', - }, - { - id: uuidv4(), - name: 'Email Uniqueness', - description: 'Ensure email addresses are unique', - type: 'uniqueness', - field: 'email', - condition: 'unique', - threshold: 99.9, - severity: 'high', - }, - ], - })); + profiles.push( + await this.createProfile({ + name: 'Uniqueness Check', + description: 'Check for duplicate or duplicate-like values', + rules: [ + { + id: uuidv4(), + name: 'User ID Uniqueness', + description: 'Ensure user IDs are unique', + type: 'uniqueness', + field: 'id', + condition: 'unique', + threshold: 100, + severity: 'critical', + }, + { + id: uuidv4(), + name: 'Email Uniqueness', + description: 'Ensure email addresses are unique', + type: 'uniqueness', + field: 'email', + condition: 'unique', + threshold: 99.9, + severity: 'high', + }, + ], + }), + ); // Validity profile - profiles.push(await this.createProfile({ - name: 'Validity Check', - description: 'Check data against business rules and constraints', - rules: [ - { - id: uuidv4(), - name: 'Email Format Validation', - description: 'Validate email format', - type: 'validity', - field: 'email', - condition: 'valid email format', - threshold: 99, - severity: 'high', - }, - { - id: uuidv4(), - name: 'Date Range Validation', - description: 'Validate dates are within reasonable ranges', - type: 'validity', - field: 'created_at', - condition: 'within last 5 years', - threshold: 99.5, - severity: 'medium', - }, - ], - })); + profiles.push( + await this.createProfile({ + name: 'Validity Check', + description: 'Check data against business rules and constraints', + rules: [ + { + id: uuidv4(), + name: 'Email Format Validation', + description: 'Validate email format', + type: 'validity', + field: 'email', + condition: 'valid email format', + threshold: 99, + severity: 'high', + }, + { + id: uuidv4(), + name: 'Date Range Validation', + description: 'Validate dates are within reasonable ranges', + type: 'validity', + field: 'created_at', + condition: 'within last 5 years', + threshold: 99.5, + severity: 'medium', + }, + ], + }), + ); return profiles; } @@ -423,7 +434,7 @@ export class DataQualityService { passed = actualValue >= rule.threshold; message = `Completeness of ${rule.field}: ${actualValue.toFixed(2)}% (threshold: ${rule.threshold}%)`; if (!passed) { - sampleData = data.filter(item => !item[rule.field] || item[rule.field] === ''); + sampleData = data.filter((item) => !item[rule.field] || item[rule.field] === ''); } break; @@ -442,7 +453,9 @@ export class DataQualityService { passed = actualValue >= rule.threshold; message = `Validity of ${rule.field}: ${actualValue.toFixed(2)}% (threshold: ${rule.threshold}%)`; if (!passed) { - sampleData = data.filter(item => !this.isValid(item[rule.field], rule.condition)).slice(0, 10); + sampleData = data + .filter((item) => !this.isValid(item[rule.field], rule.condition)) + .slice(0, 10); } break; @@ -465,19 +478,21 @@ export class DataQualityService { // Helper methods for quality calculations private calculateCompleteness(data: any[], field: string): number { if (data.length === 0) return 100; - const completeCount = data.filter(item => item[field] !== null && item[field] !== undefined && item[field] !== '').length; + const completeCount = data.filter( + (item) => item[field] !== null && item[field] !== undefined && item[field] !== '', + ).length; return (completeCount / data.length) * 100; } private calculateUniqueness(data: any[], field: string): number { if (data.length === 0) return 100; - const uniqueValues = new Set(data.map(item => item[field])); + const uniqueValues = new Set(data.map((item) => item[field])); return (uniqueValues.size / data.length) * 100; } private calculateValidity(data: any[], field: string, condition: string): number { if (data.length === 0) return 100; - const validCount = data.filter(item => this.isValid(item[field], condition)).length; + const validCount = data.filter((item) => this.isValid(item[field], condition)).length; return (validCount / data.length) * 100; } @@ -505,7 +520,7 @@ export class DataQualityService { const seen = new Map(); const duplicates: any[] = []; - data.forEach(item => { + data.forEach((item) => { const value = item[field]; if (seen.has(value)) { if (seen.get(value) === 1) { @@ -521,4 +536,4 @@ export class DataQualityService { return duplicates; } -} \ No newline at end of file +} diff --git a/src/email-marketing/ab-testing/ab-testing.controller.ts b/src/email-marketing/ab-testing/ab-testing.controller.ts index dbe3efe..13704c7 100644 --- a/src/email-marketing/ab-testing/ab-testing.controller.ts +++ b/src/email-marketing/ab-testing/ab-testing.controller.ts @@ -1,6 +1,4 @@ -import { - Controller, Get, Post, Body, Param, Query, ParseUUIDPipe, -} from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, Query, ParseUUIDPipe } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { ABTestingService } from './ab-testing.service'; @@ -11,48 +9,48 @@ import { ABTest } from '../entities/ab-test.entity'; @ApiBearerAuth() @Controller('email-marketing/ab-tests') export class ABTestingController { - constructor(private readonly abTestingService: ABTestingService) { } - - @Post() - @ApiOperation({ summary: 'Create a new A/B test' }) - @ApiResponse({ status: 201, description: 'A/B test created successfully' }) - async create(@Body() createABTestDto: CreateABTestDto): Promise { - return this.abTestingService.create(createABTestDto); - } - - @Get() - @ApiOperation({ summary: 'Get all A/B tests' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - async findAll(@Query('page') page = 1, @Query('limit') limit = 10) { - return this.abTestingService.findAll(page, limit); - } - - @Get(':id') - @ApiOperation({ summary: 'Get an A/B test by ID' }) - @ApiResponse({ status: 404, description: 'A/B test not found' }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.abTestingService.findOne(id); - } - - @Post(':id/start') - @ApiOperation({ summary: 'Start an A/B test' }) - async startTest(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.abTestingService.startTest(id); - } - - @Get(':id/results') - @ApiOperation({ summary: 'Get A/B test results with statistical analysis' }) - async getResults(@Param('id', ParseUUIDPipe) id: string) { - return this.abTestingService.getTestResults(id); - } - - @Post(':id/winner/:variantId') - @ApiOperation({ summary: 'Declare a winner for the A/B test' }) - async declareWinner( - @Param('id', ParseUUIDPipe) id: string, - @Param('variantId', ParseUUIDPipe) variantId: string, - ): Promise { - return this.abTestingService.declareWinner(id, variantId); - } + constructor(private readonly abTestingService: ABTestingService) {} + + @Post() + @ApiOperation({ summary: 'Create a new A/B test' }) + @ApiResponse({ status: 201, description: 'A/B test created successfully' }) + async create(@Body() createABTestDto: CreateABTestDto): Promise { + return this.abTestingService.create(createABTestDto); + } + + @Get() + @ApiOperation({ summary: 'Get all A/B tests' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findAll(@Query('page') page = 1, @Query('limit') limit = 10) { + return this.abTestingService.findAll(page, limit); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an A/B test by ID' }) + @ApiResponse({ status: 404, description: 'A/B test not found' }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.abTestingService.findOne(id); + } + + @Post(':id/start') + @ApiOperation({ summary: 'Start an A/B test' }) + async startTest(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.abTestingService.startTest(id); + } + + @Get(':id/results') + @ApiOperation({ summary: 'Get A/B test results with statistical analysis' }) + async getResults(@Param('id', ParseUUIDPipe) id: string) { + return this.abTestingService.getTestResults(id); + } + + @Post(':id/winner/:variantId') + @ApiOperation({ summary: 'Declare a winner for the A/B test' }) + async declareWinner( + @Param('id', ParseUUIDPipe) id: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + ): Promise { + return this.abTestingService.declareWinner(id, variantId); + } } diff --git a/src/email-marketing/ab-testing/ab-testing.service.ts b/src/email-marketing/ab-testing/ab-testing.service.ts index 45926dd..10bc794 100644 --- a/src/email-marketing/ab-testing/ab-testing.service.ts +++ b/src/email-marketing/ab-testing/ab-testing.service.ts @@ -10,253 +10,253 @@ import { ABTestStatus } from '../enums/ab-test-status.enum'; import { EmailEventType } from '../enums/email-event-type.enum'; export interface VariantStats { - variantId: string; - name: string; - sent: number; - opened: number; - clicked: number; - openRate: number; - clickRate: number; - isWinner: boolean; - confidenceLevel: number; + variantId: string; + name: string; + sent: number; + opened: number; + clicked: number; + openRate: number; + clickRate: number; + isWinner: boolean; + confidenceLevel: number; } @Injectable() export class ABTestingService { - constructor( - @InjectRepository(ABTest) - private readonly abTestRepository: Repository, - @InjectRepository(ABTestVariant) - private readonly variantRepository: Repository, - @InjectRepository(EmailEvent) - private readonly eventRepository: Repository, - ) { } - - /** - * Create a new A/B test - */ - async create(createABTestDto: CreateABTestDto): Promise { - if (createABTestDto.variants.length < 2) { - throw new BadRequestException('A/B test requires at least 2 variants'); - } - - const totalWeight = createABTestDto.variants.reduce((sum, v) => sum + (v.weight || 50), 0); - if (totalWeight !== 100) { - throw new BadRequestException('Variant weights must sum to 100'); - } - - const abTest = this.abTestRepository.create({ - name: createABTestDto.name, - campaignId: createABTestDto.campaignId, - testField: createABTestDto.testField, - winnerCriteria: createABTestDto.winnerCriteria, - sampleSize: createABTestDto.sampleSize, - status: ABTestStatus.DRAFT, - }); - - const savedTest = await this.abTestRepository.save(abTest); - - const variants = createABTestDto.variants.map((v, index) => - this.variantRepository.create({ - ...v, - abTestId: savedTest.id, - name: v.name || `Variant ${String.fromCharCode(65 + index)}`, - }), - ); - - await this.variantRepository.save(variants); - return this.findOne(savedTest.id); + constructor( + @InjectRepository(ABTest) + private readonly abTestRepository: Repository, + @InjectRepository(ABTestVariant) + private readonly variantRepository: Repository, + @InjectRepository(EmailEvent) + private readonly eventRepository: Repository, + ) {} + + /** + * Create a new A/B test + */ + async create(createABTestDto: CreateABTestDto): Promise { + if (createABTestDto.variants.length < 2) { + throw new BadRequestException('A/B test requires at least 2 variants'); } - /** - * Get all A/B tests - */ - async findAll(page = 1, limit = 10) { - const [tests, total] = await this.abTestRepository.findAndCount({ - skip: (page - 1) * limit, - take: limit, - order: { createdAt: 'DESC' }, - relations: ['variants'], - }); - - return { tests, total, page, totalPages: Math.ceil(total / limit) }; + const totalWeight = createABTestDto.variants.reduce((sum, v) => sum + (v.weight || 50), 0); + if (totalWeight !== 100) { + throw new BadRequestException('Variant weights must sum to 100'); } - /** - * Get a single A/B test by ID - */ - async findOne(id: string): Promise { - const test = await this.abTestRepository.findOne({ - where: { id }, - relations: ['variants'], - }); - - if (!test) { - throw new NotFoundException(`A/B test with ID ${id} not found`); - } - return test; + const abTest = this.abTestRepository.create({ + name: createABTestDto.name, + campaignId: createABTestDto.campaignId, + testField: createABTestDto.testField, + winnerCriteria: createABTestDto.winnerCriteria, + sampleSize: createABTestDto.sampleSize, + status: ABTestStatus.DRAFT, + }); + + const savedTest = await this.abTestRepository.save(abTest); + + const variants = createABTestDto.variants.map((v, index) => + this.variantRepository.create({ + ...v, + abTestId: savedTest.id, + name: v.name || `Variant ${String.fromCharCode(65 + index)}`, + }), + ); + + await this.variantRepository.save(variants); + return this.findOne(savedTest.id); + } + + /** + * Get all A/B tests + */ + async findAll(page = 1, limit = 10) { + const [tests, total] = await this.abTestRepository.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + relations: ['variants'], + }); + + return { tests, total, page, totalPages: Math.ceil(total / limit) }; + } + + /** + * Get a single A/B test by ID + */ + async findOne(id: string): Promise { + const test = await this.abTestRepository.findOne({ + where: { id }, + relations: ['variants'], + }); + + if (!test) { + throw new NotFoundException(`A/B test with ID ${id} not found`); } + return test; + } - /** - * Start an A/B test - */ - async startTest(id: string): Promise { - const test = await this.findOne(id); + /** + * Start an A/B test + */ + async startTest(id: string): Promise { + const test = await this.findOne(id); - if (test.status !== ABTestStatus.DRAFT) { - throw new BadRequestException('Only draft tests can be started'); - } - - test.status = ABTestStatus.RUNNING; - test.startedAt = new Date(); - return this.abTestRepository.save(test); + if (test.status !== ABTestStatus.DRAFT) { + throw new BadRequestException('Only draft tests can be started'); } - /** - * Get test results with statistical analysis - */ - async getTestResults(id: string): Promise<{ - test: ABTest; - variants: VariantStats[]; - isSignificant: boolean; - recommendedWinner: string | null; - }> { - const test = await this.findOne(id); - const variantStats: VariantStats[] = []; - - for (const variant of test.variants) { - const sent = variant.recipientCount || 0; - const opened = await this.countVariantEvents(variant.id, EmailEventType.OPENED); - const clicked = await this.countVariantEvents(variant.id, EmailEventType.CLICKED); - - variantStats.push({ - variantId: variant.id, - name: variant.name, - sent, - opened, - clicked, - openRate: sent > 0 ? (opened / sent) * 100 : 0, - clickRate: opened > 0 ? (clicked / opened) * 100 : 0, - isWinner: false, - confidenceLevel: 0, - }); - } - - // Calculate statistical significance - const { isSignificant, winner, confidenceLevel } = this.calculateSignificance( - variantStats, - test.winnerCriteria, - ); - - if (winner) { - const winnerStats = variantStats.find((v) => v.variantId === winner); - if (winnerStats) { - winnerStats.isWinner = true; - winnerStats.confidenceLevel = confidenceLevel; - } - } - - return { - test, - variants: variantStats, - isSignificant, - recommendedWinner: winner, - }; + test.status = ABTestStatus.RUNNING; + test.startedAt = new Date(); + return this.abTestRepository.save(test); + } + + /** + * Get test results with statistical analysis + */ + async getTestResults(id: string): Promise<{ + test: ABTest; + variants: VariantStats[]; + isSignificant: boolean; + recommendedWinner: string | null; + }> { + const test = await this.findOne(id); + const variantStats: VariantStats[] = []; + + for (const variant of test.variants) { + const sent = variant.recipientCount || 0; + const opened = await this.countVariantEvents(variant.id, EmailEventType.OPENED); + const clicked = await this.countVariantEvents(variant.id, EmailEventType.CLICKED); + + variantStats.push({ + variantId: variant.id, + name: variant.name, + sent, + opened, + clicked, + openRate: sent > 0 ? (opened / sent) * 100 : 0, + clickRate: opened > 0 ? (clicked / opened) * 100 : 0, + isWinner: false, + confidenceLevel: 0, + }); } - /** - * Declare a winner and end the test - */ - async declareWinner(testId: string, variantId: string): Promise { - const test = await this.findOne(testId); - - if (test.status !== ABTestStatus.RUNNING) { - throw new BadRequestException('Only running tests can have a winner declared'); - } + // Calculate statistical significance + const { isSignificant, winner, confidenceLevel } = this.calculateSignificance( + variantStats, + test.winnerCriteria, + ); + + if (winner) { + const winnerStats = variantStats.find((v) => v.variantId === winner); + if (winnerStats) { + winnerStats.isWinner = true; + winnerStats.confidenceLevel = confidenceLevel; + } + } - const variant = test.variants.find((v) => v.id === variantId); - if (!variant) { - throw new BadRequestException('Variant not found in this test'); - } + return { + test, + variants: variantStats, + isSignificant, + recommendedWinner: winner, + }; + } + + /** + * Declare a winner and end the test + */ + async declareWinner(testId: string, variantId: string): Promise { + const test = await this.findOne(testId); + + if (test.status !== ABTestStatus.RUNNING) { + throw new BadRequestException('Only running tests can have a winner declared'); + } - test.status = ABTestStatus.COMPLETED; - test.winnerId = variantId; - test.endedAt = new Date(); + const variant = test.variants.find((v) => v.id === variantId); + if (!variant) { + throw new BadRequestException('Variant not found in this test'); + } - return this.abTestRepository.save(test); + test.status = ABTestStatus.COMPLETED; + test.winnerId = variantId; + test.endedAt = new Date(); + + return this.abTestRepository.save(test); + } + + /** + * Select variant for a recipient (weighted random) + */ + selectVariantForRecipient(test: ABTest): ABTestVariant { + const random = Math.random() * 100; + let cumulative = 0; + + for (const variant of test.variants) { + cumulative += variant.weight; + if (random <= cumulative) { + return variant; + } } - /** - * Select variant for a recipient (weighted random) - */ - selectVariantForRecipient(test: ABTest): ABTestVariant { - const random = Math.random() * 100; - let cumulative = 0; - - for (const variant of test.variants) { - cumulative += variant.weight; - if (random <= cumulative) { - return variant; - } - } - - return test.variants[0]; + return test.variants[0]; + } + + // Private helper methods + private async countVariantEvents(variantId: string, eventType: EmailEventType): Promise { + return this.eventRepository.count({ + where: { metadata: { variantId }, eventType }, + }); + } + + private calculateSignificance( + variants: VariantStats[], + criteria: string, + ): { isSignificant: boolean; winner: string | null; confidenceLevel: number } { + if (variants.length < 2) { + return { isSignificant: false, winner: null, confidenceLevel: 0 }; } - // Private helper methods - private async countVariantEvents(variantId: string, eventType: EmailEventType): Promise { - return this.eventRepository.count({ - where: { metadata: { variantId }, eventType }, - }); + // Sort by the winning criteria + const sorted = [...variants].sort((a, b) => { + const metricA = criteria === 'click_rate' ? a.clickRate : a.openRate; + const metricB = criteria === 'click_rate' ? b.clickRate : b.openRate; + return metricB - metricA; + }); + + const best = sorted[0]; + const second = sorted[1]; + + // Simple z-test approximation + const n1 = best.sent; + const n2 = second.sent; + const p1 = criteria === 'click_rate' ? best.clickRate / 100 : best.openRate / 100; + const p2 = criteria === 'click_rate' ? second.clickRate / 100 : second.openRate / 100; + + if (n1 < 30 || n2 < 30) { + return { isSignificant: false, winner: null, confidenceLevel: 0 }; } - private calculateSignificance( - variants: VariantStats[], - criteria: string, - ): { isSignificant: boolean; winner: string | null; confidenceLevel: number } { - if (variants.length < 2) { - return { isSignificant: false, winner: null, confidenceLevel: 0 }; - } - - // Sort by the winning criteria - const sorted = [...variants].sort((a, b) => { - const metricA = criteria === 'click_rate' ? a.clickRate : a.openRate; - const metricB = criteria === 'click_rate' ? b.clickRate : b.openRate; - return metricB - metricA; - }); - - const best = sorted[0]; - const second = sorted[1]; - - // Simple z-test approximation - const n1 = best.sent; - const n2 = second.sent; - const p1 = criteria === 'click_rate' ? best.clickRate / 100 : best.openRate / 100; - const p2 = criteria === 'click_rate' ? second.clickRate / 100 : second.openRate / 100; - - if (n1 < 30 || n2 < 30) { - return { isSignificant: false, winner: null, confidenceLevel: 0 }; - } - - const pooledP = (p1 * n1 + p2 * n2) / (n1 + n2); - const se = Math.sqrt(pooledP * (1 - pooledP) * (1 / n1 + 1 / n2)); - - if (se === 0) { - return { isSignificant: false, winner: null, confidenceLevel: 0 }; - } - - const zScore = Math.abs(p1 - p2) / se; - - // Z-score to confidence level - let confidenceLevel = 0; - if (zScore >= 2.576) confidenceLevel = 99; - else if (zScore >= 1.96) confidenceLevel = 95; - else if (zScore >= 1.645) confidenceLevel = 90; - - return { - isSignificant: confidenceLevel >= 95, - winner: confidenceLevel >= 95 ? best.variantId : null, - confidenceLevel, - }; + const pooledP = (p1 * n1 + p2 * n2) / (n1 + n2); + const se = Math.sqrt(pooledP * (1 - pooledP) * (1 / n1 + 1 / n2)); + + if (se === 0) { + return { isSignificant: false, winner: null, confidenceLevel: 0 }; } + + const zScore = Math.abs(p1 - p2) / se; + + // Z-score to confidence level + let confidenceLevel = 0; + if (zScore >= 2.576) confidenceLevel = 99; + else if (zScore >= 1.96) confidenceLevel = 95; + else if (zScore >= 1.645) confidenceLevel = 90; + + return { + isSignificant: confidenceLevel >= 95, + winner: confidenceLevel >= 95 ? best.variantId : null, + confidenceLevel, + }; + } } diff --git a/src/email-marketing/analytics/email-analytics.controller.ts b/src/email-marketing/analytics/email-analytics.controller.ts index 6e22969..97fcf22 100644 --- a/src/email-marketing/analytics/email-analytics.controller.ts +++ b/src/email-marketing/analytics/email-analytics.controller.ts @@ -7,49 +7,45 @@ import { EmailAnalyticsService } from './email-analytics.service'; @ApiBearerAuth() @Controller('email-marketing/analytics') export class EmailAnalyticsController { - constructor(private readonly analyticsService: EmailAnalyticsService) { } + constructor(private readonly analyticsService: EmailAnalyticsService) {} - @Get('campaigns/:id') - @ApiOperation({ summary: 'Get campaign performance metrics' }) - @ApiResponse({ status: 200, description: 'Campaign metrics' }) - @ApiResponse({ status: 404, description: 'Campaign not found' }) - async getCampaignMetrics(@Param('id', ParseUUIDPipe) id: string) { - return this.analyticsService.getCampaignMetrics(id); - } + @Get('campaigns/:id') + @ApiOperation({ summary: 'Get campaign performance metrics' }) + @ApiResponse({ status: 200, description: 'Campaign metrics' }) + @ApiResponse({ status: 404, description: 'Campaign not found' }) + async getCampaignMetrics(@Param('id', ParseUUIDPipe) id: string) { + return this.analyticsService.getCampaignMetrics(id); + } - @Get('campaigns/:id/timeline') - @ApiOperation({ summary: 'Get campaign time series data' }) - @ApiQuery({ name: 'startDate', required: true, type: String }) - @ApiQuery({ name: 'endDate', required: true, type: String }) - async getCampaignTimeline( - @Param('id', ParseUUIDPipe) id: string, - @Query('startDate') startDate: string, - @Query('endDate') endDate: string, - ) { - return this.analyticsService.getCampaignTimeSeries( - id, - new Date(startDate), - new Date(endDate), - ); - } + @Get('campaigns/:id/timeline') + @ApiOperation({ summary: 'Get campaign time series data' }) + @ApiQuery({ name: 'startDate', required: true, type: String }) + @ApiQuery({ name: 'endDate', required: true, type: String }) + async getCampaignTimeline( + @Param('id', ParseUUIDPipe) id: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.analyticsService.getCampaignTimeSeries(id, new Date(startDate), new Date(endDate)); + } - @Get('campaigns/:id/links') - @ApiOperation({ summary: 'Get link click analytics for a campaign' }) - async getLinkAnalytics(@Param('id', ParseUUIDPipe) id: string) { - return this.analyticsService.getLinkAnalytics(id); - } + @Get('campaigns/:id/links') + @ApiOperation({ summary: 'Get link click analytics for a campaign' }) + async getLinkAnalytics(@Param('id', ParseUUIDPipe) id: string) { + return this.analyticsService.getLinkAnalytics(id); + } - @Get('overview') - @ApiOperation({ summary: 'Get overall email marketing statistics' }) - @ApiQuery({ name: 'startDate', required: false, type: String }) - @ApiQuery({ name: 'endDate', required: false, type: String }) - async getOverallStats( - @Query('startDate') startDate?: string, - @Query('endDate') endDate?: string, - ) { - return this.analyticsService.getOverallStats( - startDate ? new Date(startDate) : undefined, - endDate ? new Date(endDate) : undefined, - ); - } + @Get('overview') + @ApiOperation({ summary: 'Get overall email marketing statistics' }) + @ApiQuery({ name: 'startDate', required: false, type: String }) + @ApiQuery({ name: 'endDate', required: false, type: String }) + async getOverallStats( + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + return this.analyticsService.getOverallStats( + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + ); + } } diff --git a/src/email-marketing/analytics/email-analytics.service.ts b/src/email-marketing/analytics/email-analytics.service.ts index 9026e1c..89a30fd 100644 --- a/src/email-marketing/analytics/email-analytics.service.ts +++ b/src/email-marketing/analytics/email-analytics.service.ts @@ -7,210 +7,216 @@ import { Campaign } from '../entities/campaign.entity'; import { EmailEventType } from '../enums/email-event-type.enum'; export interface CampaignMetrics { - sent: number; - delivered: number; - opened: number; - clicked: number; - bounced: number; - unsubscribed: number; - openRate: number; - clickRate: number; - bounceRate: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + unsubscribed: number; + openRate: number; + clickRate: number; + bounceRate: number; } export interface TimeSeriesData { - date: string; - opens: number; - clicks: number; - bounces: number; + date: string; + opens: number; + clicks: number; + bounces: number; } @Injectable() export class EmailAnalyticsService { - constructor( - @InjectRepository(EmailEvent) - private readonly eventRepository: Repository, - @InjectRepository(Campaign) - private readonly campaignRepository: Repository, - ) { } - - /** - * Record an email event - */ - async recordEvent( - campaignId: string, - recipientId: string, - eventType: EmailEventType, - metadata?: Record, - ): Promise { - const event = this.eventRepository.create({ - campaignId, - recipientId, - eventType, - metadata, - occurredAt: new Date(), - }); - - return this.eventRepository.save(event); + constructor( + @InjectRepository(EmailEvent) + private readonly eventRepository: Repository, + @InjectRepository(Campaign) + private readonly campaignRepository: Repository, + ) {} + + /** + * Record an email event + */ + async recordEvent( + campaignId: string, + recipientId: string, + eventType: EmailEventType, + metadata?: Record, + ): Promise { + const event = this.eventRepository.create({ + campaignId, + recipientId, + eventType, + metadata, + occurredAt: new Date(), + }); + + return this.eventRepository.save(event); + } + + /** + * Get campaign metrics + */ + async getCampaignMetrics(campaignId: string): Promise { + const campaign = await this.campaignRepository.findOne({ + where: { id: campaignId }, + }); + + if (!campaign) { + throw new NotFoundException(`Campaign ${campaignId} not found`); } - /** - * Get campaign metrics - */ - async getCampaignMetrics(campaignId: string): Promise { - const campaign = await this.campaignRepository.findOne({ - where: { id: campaignId }, - }); - - if (!campaign) { - throw new NotFoundException(`Campaign ${campaignId} not found`); - } - - const sent = campaign.totalRecipients || 0; - - const [delivered, opened, clicked, bounced, unsubscribed] = await Promise.all([ - this.countEvents(campaignId, EmailEventType.DELIVERED), - this.countUniqueEvents(campaignId, EmailEventType.OPENED), - this.countUniqueEvents(campaignId, EmailEventType.CLICKED), - this.countEvents(campaignId, EmailEventType.BOUNCED), - this.countEvents(campaignId, EmailEventType.UNSUBSCRIBED), - ]); - - return { - sent, - delivered, - opened, - clicked, - bounced, - unsubscribed, - openRate: sent > 0 ? (opened / sent) * 100 : 0, - clickRate: opened > 0 ? (clicked / opened) * 100 : 0, - bounceRate: sent > 0 ? (bounced / sent) * 100 : 0, - }; + const sent = campaign.totalRecipients || 0; + + const [delivered, opened, clicked, bounced, unsubscribed] = await Promise.all([ + this.countEvents(campaignId, EmailEventType.DELIVERED), + this.countUniqueEvents(campaignId, EmailEventType.OPENED), + this.countUniqueEvents(campaignId, EmailEventType.CLICKED), + this.countEvents(campaignId, EmailEventType.BOUNCED), + this.countEvents(campaignId, EmailEventType.UNSUBSCRIBED), + ]); + + return { + sent, + delivered, + opened, + clicked, + bounced, + unsubscribed, + openRate: sent > 0 ? (opened / sent) * 100 : 0, + clickRate: opened > 0 ? (clicked / opened) * 100 : 0, + bounceRate: sent > 0 ? (bounced / sent) * 100 : 0, + }; + } + + /** + * Get time series data for a campaign + */ + async getCampaignTimeSeries( + campaignId: string, + startDate: Date, + endDate: Date, + ): Promise { + const events = await this.eventRepository.find({ + where: { + campaignId, + occurredAt: Between(startDate, endDate), + }, + order: { occurredAt: 'ASC' }, + }); + + const dataMap = new Map(); + + for (const event of events) { + const dateKey = event.occurredAt.toISOString().split('T')[0]; + + if (!dataMap.has(dateKey)) { + dataMap.set(dateKey, { date: dateKey, opens: 0, clicks: 0, bounces: 0 }); + } + + const data = dataMap.get(dateKey)!; + + if (event.eventType === EmailEventType.OPENED) data.opens++; + if (event.eventType === EmailEventType.CLICKED) data.clicks++; + if (event.eventType === EmailEventType.BOUNCED) data.bounces++; } - /** - * Get time series data for a campaign - */ - async getCampaignTimeSeries( - campaignId: string, - startDate: Date, - endDate: Date, - ): Promise { - const events = await this.eventRepository.find({ - where: { - campaignId, - occurredAt: Between(startDate, endDate), - }, - order: { occurredAt: 'ASC' }, - }); - - const dataMap = new Map(); - - for (const event of events) { - const dateKey = event.occurredAt.toISOString().split('T')[0]; - - if (!dataMap.has(dateKey)) { - dataMap.set(dateKey, { date: dateKey, opens: 0, clicks: 0, bounces: 0 }); - } - - const data = dataMap.get(dateKey)!; - - if (event.eventType === EmailEventType.OPENED) data.opens++; - if (event.eventType === EmailEventType.CLICKED) data.clicks++; - if (event.eventType === EmailEventType.BOUNCED) data.bounces++; - } - - return Array.from(dataMap.values()); + return Array.from(dataMap.values()); + } + + /** + * Get link click analytics + */ + async getLinkAnalytics(campaignId: string): Promise< + Array<{ + url: string; + clicks: number; + uniqueClicks: number; + }> + > { + const clickEvents = await this.eventRepository.find({ + where: { campaignId, eventType: EmailEventType.CLICKED }, + }); + + const linkMap = new Map }>(); + + for (const event of clickEvents) { + const url = event.metadata?.url || 'unknown'; + + if (!linkMap.has(url)) { + linkMap.set(url, { clicks: 0, recipients: new Set() }); + } + + const data = linkMap.get(url)!; + data.clicks++; + data.recipients.add(event.recipientId); } - /** - * Get link click analytics - */ - async getLinkAnalytics(campaignId: string): Promise> { - const clickEvents = await this.eventRepository.find({ - where: { campaignId, eventType: EmailEventType.CLICKED }, - }); - - const linkMap = new Map }>(); - - for (const event of clickEvents) { - const url = event.metadata?.url || 'unknown'; - - if (!linkMap.has(url)) { - linkMap.set(url, { clicks: 0, recipients: new Set() }); - } - - const data = linkMap.get(url)!; - data.clicks++; - data.recipients.add(event.recipientId); - } - - return Array.from(linkMap.entries()).map(([url, data]) => ({ - url, - clicks: data.clicks, - uniqueClicks: data.recipients.size, - })); + return Array.from(linkMap.entries()).map(([url, data]) => ({ + url, + clicks: data.clicks, + uniqueClicks: data.recipients.size, + })); + } + + /** + * Get overall email marketing stats + */ + async getOverallStats( + startDate?: Date, + endDate?: Date, + ): Promise<{ + totalCampaigns: number; + totalEmailsSent: number; + averageOpenRate: number; + averageClickRate: number; + }> { + const query = this.campaignRepository + .createQueryBuilder('campaign') + .where('campaign.sentAt IS NOT NULL'); + + if (startDate && endDate) { + query.andWhere('campaign.sentAt BETWEEN :start AND :end', { + start: startDate, + end: endDate, + }); } - /** - * Get overall email marketing stats - */ - async getOverallStats(startDate?: Date, endDate?: Date): Promise<{ - totalCampaigns: number; - totalEmailsSent: number; - averageOpenRate: number; - averageClickRate: number; - }> { - const query = this.campaignRepository.createQueryBuilder('campaign') - .where('campaign.sentAt IS NOT NULL'); - - if (startDate && endDate) { - query.andWhere('campaign.sentAt BETWEEN :start AND :end', { - start: startDate, - end: endDate, - }); - } - - const campaigns = await query.getMany(); - - const totalCampaigns = campaigns.length; - const totalEmailsSent = campaigns.reduce((sum, c) => sum + (c.totalRecipients || 0), 0); - - let totalOpenRate = 0; - let totalClickRate = 0; - - for (const campaign of campaigns) { - const metrics = await this.getCampaignMetrics(campaign.id); - totalOpenRate += metrics.openRate; - totalClickRate += metrics.clickRate; - } - - return { - totalCampaigns, - totalEmailsSent, - averageOpenRate: totalCampaigns > 0 ? totalOpenRate / totalCampaigns : 0, - averageClickRate: totalCampaigns > 0 ? totalClickRate / totalCampaigns : 0, - }; - } + const campaigns = await query.getMany(); - // Helper methods - private async countEvents(campaignId: string, eventType: EmailEventType): Promise { - return this.eventRepository.count({ where: { campaignId, eventType } }); - } + const totalCampaigns = campaigns.length; + const totalEmailsSent = campaigns.reduce((sum, c) => sum + (c.totalRecipients || 0), 0); - private async countUniqueEvents(campaignId: string, eventType: EmailEventType): Promise { - const result = await this.eventRepository - .createQueryBuilder('event') - .select('COUNT(DISTINCT event.recipientId)', 'count') - .where('event.campaignId = :campaignId', { campaignId }) - .andWhere('event.eventType = :eventType', { eventType }) - .getRawOne(); + let totalOpenRate = 0; + let totalClickRate = 0; - return parseInt(result?.count || '0', 10); + for (const campaign of campaigns) { + const metrics = await this.getCampaignMetrics(campaign.id); + totalOpenRate += metrics.openRate; + totalClickRate += metrics.clickRate; } + + return { + totalCampaigns, + totalEmailsSent, + averageOpenRate: totalCampaigns > 0 ? totalOpenRate / totalCampaigns : 0, + averageClickRate: totalCampaigns > 0 ? totalClickRate / totalCampaigns : 0, + }; + } + + // Helper methods + private async countEvents(campaignId: string, eventType: EmailEventType): Promise { + return this.eventRepository.count({ where: { campaignId, eventType } }); + } + + private async countUniqueEvents(campaignId: string, eventType: EmailEventType): Promise { + const result = await this.eventRepository + .createQueryBuilder('event') + .select('COUNT(DISTINCT event.recipientId)', 'count') + .where('event.campaignId = :campaignId', { campaignId }) + .andWhere('event.eventType = :eventType', { eventType }) + .getRawOne(); + + return parseInt(result?.count || '0', 10); + } } diff --git a/src/email-marketing/automation/automation.controller.ts b/src/email-marketing/automation/automation.controller.ts index 1f5360d..2519dd7 100644 --- a/src/email-marketing/automation/automation.controller.ts +++ b/src/email-marketing/automation/automation.controller.ts @@ -1,23 +1,17 @@ import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Query, - ParseUUIDPipe, - HttpCode, - HttpStatus, + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, - ApiQuery, -} from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { AutomationService } from './automation.service'; import { CreateAutomationDto } from '../dto/create-automation.dto'; @@ -28,78 +22,75 @@ import { AutomationWorkflow } from '../entities/automation-workflow.entity'; @ApiBearerAuth() @Controller('email-marketing/automation') export class AutomationController { - constructor(private readonly automationService: AutomationService) { } + constructor(private readonly automationService: AutomationService) {} - @Post() - @ApiOperation({ summary: 'Create a new automation workflow' }) - @ApiResponse({ status: 201, description: 'Workflow created successfully' }) - @ApiResponse({ status: 400, description: 'Invalid input data' }) - async create(@Body() createAutomationDto: CreateAutomationDto): Promise { - return this.automationService.create(createAutomationDto); - } + @Post() + @ApiOperation({ summary: 'Create a new automation workflow' }) + @ApiResponse({ status: 201, description: 'Workflow created successfully' }) + @ApiResponse({ status: 400, description: 'Invalid input data' }) + async create(@Body() createAutomationDto: CreateAutomationDto): Promise { + return this.automationService.create(createAutomationDto); + } - @Get() - @ApiOperation({ summary: 'Get all automation workflows' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiResponse({ status: 200, description: 'List of automation workflows' }) - async findAll( - @Query('page') page: number = 1, - @Query('limit') limit: number = 10, - ) { - return this.automationService.findAll(page, limit); - } + @Get() + @ApiOperation({ summary: 'Get all automation workflows' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'List of automation workflows' }) + async findAll(@Query('page') page: number = 1, @Query('limit') limit: number = 10) { + return this.automationService.findAll(page, limit); + } - @Get(':id') - @ApiOperation({ summary: 'Get an automation workflow by ID' }) - @ApiResponse({ status: 200, description: 'Workflow details' }) - @ApiResponse({ status: 404, description: 'Workflow not found' }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.automationService.findOne(id); - } + @Get(':id') + @ApiOperation({ summary: 'Get an automation workflow by ID' }) + @ApiResponse({ status: 200, description: 'Workflow details' }) + @ApiResponse({ status: 404, description: 'Workflow not found' }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.automationService.findOne(id); + } - @Put(':id') - @ApiOperation({ summary: 'Update an automation workflow' }) - @ApiResponse({ status: 200, description: 'Workflow updated successfully' }) - @ApiResponse({ status: 400, description: 'Cannot update active workflow' }) - @ApiResponse({ status: 404, description: 'Workflow not found' }) - async update( - @Param('id', ParseUUIDPipe) id: string, - @Body() updateAutomationDto: UpdateAutomationDto, - ): Promise { - return this.automationService.update(id, updateAutomationDto); - } + @Put(':id') + @ApiOperation({ summary: 'Update an automation workflow' }) + @ApiResponse({ status: 200, description: 'Workflow updated successfully' }) + @ApiResponse({ status: 400, description: 'Cannot update active workflow' }) + @ApiResponse({ status: 404, description: 'Workflow not found' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateAutomationDto: UpdateAutomationDto, + ): Promise { + return this.automationService.update(id, updateAutomationDto); + } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete an automation workflow' }) - @ApiResponse({ status: 204, description: 'Workflow deleted successfully' }) - @ApiResponse({ status: 400, description: 'Cannot delete active workflow' }) - @ApiResponse({ status: 404, description: 'Workflow not found' }) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.automationService.remove(id); - } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete an automation workflow' }) + @ApiResponse({ status: 204, description: 'Workflow deleted successfully' }) + @ApiResponse({ status: 400, description: 'Cannot delete active workflow' }) + @ApiResponse({ status: 404, description: 'Workflow not found' }) + async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.automationService.remove(id); + } - @Post(':id/activate') - @ApiOperation({ summary: 'Activate an automation workflow' }) - @ApiResponse({ status: 200, description: 'Workflow activated' }) - @ApiResponse({ status: 400, description: 'Workflow has no triggers or actions' }) - async activate(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.automationService.activate(id); - } + @Post(':id/activate') + @ApiOperation({ summary: 'Activate an automation workflow' }) + @ApiResponse({ status: 200, description: 'Workflow activated' }) + @ApiResponse({ status: 400, description: 'Workflow has no triggers or actions' }) + async activate(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.automationService.activate(id); + } - @Post(':id/deactivate') - @ApiOperation({ summary: 'Deactivate an automation workflow' }) - @ApiResponse({ status: 200, description: 'Workflow deactivated' }) - async deactivate(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.automationService.deactivate(id); - } + @Post(':id/deactivate') + @ApiOperation({ summary: 'Deactivate an automation workflow' }) + @ApiResponse({ status: 200, description: 'Workflow deactivated' }) + async deactivate(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.automationService.deactivate(id); + } - @Get(':id/stats') - @ApiOperation({ summary: 'Get workflow execution statistics' }) - @ApiResponse({ status: 200, description: 'Workflow statistics' }) - @ApiResponse({ status: 404, description: 'Workflow not found' }) - async getStats(@Param('id', ParseUUIDPipe) id: string) { - return this.automationService.getWorkflowStats(id); - } + @Get(':id/stats') + @ApiOperation({ summary: 'Get workflow execution statistics' }) + @ApiResponse({ status: 200, description: 'Workflow statistics' }) + @ApiResponse({ status: 404, description: 'Workflow not found' }) + async getStats(@Param('id', ParseUUIDPipe) id: string) { + return this.automationService.getWorkflowStats(id); + } } diff --git a/src/email-marketing/automation/automation.service.ts b/src/email-marketing/automation/automation.service.ts index 3632294..bc118e9 100644 --- a/src/email-marketing/automation/automation.service.ts +++ b/src/email-marketing/automation/automation.service.ts @@ -17,371 +17,374 @@ import { WorkflowStatus } from '../enums/workflow-status.enum'; @Injectable() export class AutomationService { - constructor( - @InjectRepository(AutomationWorkflow) - private readonly workflowRepository: Repository, - @InjectRepository(AutomationTrigger) - private readonly triggerRepository: Repository, - @InjectRepository(AutomationAction) - private readonly actionRepository: Repository, - @InjectQueue('email-marketing') - private readonly emailQueue: Queue, - private readonly eventEmitter: EventEmitter2, - ) { } - - /** - * Create a new automation workflow - */ - async create(createAutomationDto: CreateAutomationDto): Promise { - const workflow = this.workflowRepository.create({ - name: createAutomationDto.name, - description: createAutomationDto.description, - status: WorkflowStatus.DRAFT, - }); - - const savedWorkflow = await this.workflowRepository.save(workflow); - - // Create triggers - if (createAutomationDto.triggers?.length) { - const triggers = createAutomationDto.triggers.map((trigger) => - this.triggerRepository.create({ - ...trigger, - workflowId: savedWorkflow.id, - }), - ); - await this.triggerRepository.save(triggers); - } - - // Create actions - if (createAutomationDto.actions?.length) { - const actions = createAutomationDto.actions.map((action, index) => - this.actionRepository.create({ - ...action, - workflowId: savedWorkflow.id, - order: index, - }), - ); - await this.actionRepository.save(actions); - } - - return this.findOne(savedWorkflow.id); + constructor( + @InjectRepository(AutomationWorkflow) + private readonly workflowRepository: Repository, + @InjectRepository(AutomationTrigger) + private readonly triggerRepository: Repository, + @InjectRepository(AutomationAction) + private readonly actionRepository: Repository, + @InjectQueue('email-marketing') + private readonly emailQueue: Queue, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Create a new automation workflow + */ + async create(createAutomationDto: CreateAutomationDto): Promise { + const workflow = this.workflowRepository.create({ + name: createAutomationDto.name, + description: createAutomationDto.description, + status: WorkflowStatus.DRAFT, + }); + + const savedWorkflow = await this.workflowRepository.save(workflow); + + // Create triggers + if (createAutomationDto.triggers?.length) { + const triggers = createAutomationDto.triggers.map((trigger) => + this.triggerRepository.create({ + ...trigger, + workflowId: savedWorkflow.id, + }), + ); + await this.triggerRepository.save(triggers); } - /** - * Get all automation workflows - */ - async findAll(page: number = 1, limit: number = 10): Promise<{ - workflows: AutomationWorkflow[]; - total: number; - page: number; - totalPages: number; - }> { - const [workflows, total] = await this.workflowRepository.findAndCount({ - skip: (page - 1) * limit, - take: limit, - order: { createdAt: 'DESC' }, - relations: ['triggers', 'actions'], - }); - - return { - workflows, - total, - page, - totalPages: Math.ceil(total / limit), - }; + // Create actions + if (createAutomationDto.actions?.length) { + const actions = createAutomationDto.actions.map((action, index) => + this.actionRepository.create({ + ...action, + workflowId: savedWorkflow.id, + order: index, + }), + ); + await this.actionRepository.save(actions); } - /** - * Get a single workflow by ID - */ - async findOne(id: string): Promise { - const workflow = await this.workflowRepository.findOne({ - where: { id }, - relations: ['triggers', 'actions'], - }); - - if (!workflow) { - throw new NotFoundException(`Automation workflow with ID ${id} not found`); - } - - return workflow; + return this.findOne(savedWorkflow.id); + } + + /** + * Get all automation workflows + */ + async findAll( + page: number = 1, + limit: number = 10, + ): Promise<{ + workflows: AutomationWorkflow[]; + total: number; + page: number; + totalPages: number; + }> { + const [workflows, total] = await this.workflowRepository.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + relations: ['triggers', 'actions'], + }); + + return { + workflows, + total, + page, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Get a single workflow by ID + */ + async findOne(id: string): Promise { + const workflow = await this.workflowRepository.findOne({ + where: { id }, + relations: ['triggers', 'actions'], + }); + + if (!workflow) { + throw new NotFoundException(`Automation workflow with ID ${id} not found`); } - /** - * Update a workflow - */ - async update(id: string, updateAutomationDto: UpdateAutomationDto): Promise { - const workflow = await this.findOne(id); + return workflow; + } - if (workflow.status === WorkflowStatus.ACTIVE) { - throw new BadRequestException('Deactivate workflow before making changes'); - } + /** + * Update a workflow + */ + async update(id: string, updateAutomationDto: UpdateAutomationDto): Promise { + const workflow = await this.findOne(id); - Object.assign(workflow, { - name: updateAutomationDto.name ?? workflow.name, - description: updateAutomationDto.description ?? workflow.description, - }); - - await this.workflowRepository.save(workflow); - - // Update triggers if provided - if (updateAutomationDto.triggers) { - await this.triggerRepository.delete({ workflowId: id }); - const triggers = updateAutomationDto.triggers.map((trigger) => - this.triggerRepository.create({ - ...trigger, - workflowId: id, - }), - ); - await this.triggerRepository.save(triggers); - } - - // Update actions if provided - if (updateAutomationDto.actions) { - await this.actionRepository.delete({ workflowId: id }); - const actions = updateAutomationDto.actions.map((action, index) => - this.actionRepository.create({ - ...action, - workflowId: id, - order: index, - }), - ); - await this.actionRepository.save(actions); - } - - return this.findOne(id); + if (workflow.status === WorkflowStatus.ACTIVE) { + throw new BadRequestException('Deactivate workflow before making changes'); } - /** - * Delete a workflow - */ - async remove(id: string): Promise { - const workflow = await this.findOne(id); - - if (workflow.status === WorkflowStatus.ACTIVE) { - throw new BadRequestException('Deactivate workflow before deleting'); - } - - await this.workflowRepository.remove(workflow); + Object.assign(workflow, { + name: updateAutomationDto.name ?? workflow.name, + description: updateAutomationDto.description ?? workflow.description, + }); + + await this.workflowRepository.save(workflow); + + // Update triggers if provided + if (updateAutomationDto.triggers) { + await this.triggerRepository.delete({ workflowId: id }); + const triggers = updateAutomationDto.triggers.map((trigger) => + this.triggerRepository.create({ + ...trigger, + workflowId: id, + }), + ); + await this.triggerRepository.save(triggers); } - /** - * Activate a workflow - */ - async activate(id: string): Promise { - const workflow = await this.findOne(id); - - if (!workflow.triggers?.length) { - throw new BadRequestException('Workflow must have at least one trigger'); - } + // Update actions if provided + if (updateAutomationDto.actions) { + await this.actionRepository.delete({ workflowId: id }); + const actions = updateAutomationDto.actions.map((action, index) => + this.actionRepository.create({ + ...action, + workflowId: id, + order: index, + }), + ); + await this.actionRepository.save(actions); + } - if (!workflow.actions?.length) { - throw new BadRequestException('Workflow must have at least one action'); - } + return this.findOne(id); + } - workflow.status = WorkflowStatus.ACTIVE; - workflow.activatedAt = new Date(); + /** + * Delete a workflow + */ + async remove(id: string): Promise { + const workflow = await this.findOne(id); - return this.workflowRepository.save(workflow); + if (workflow.status === WorkflowStatus.ACTIVE) { + throw new BadRequestException('Deactivate workflow before deleting'); } - /** - * Deactivate a workflow - */ - async deactivate(id: string): Promise { - const workflow = await this.findOne(id); - workflow.status = WorkflowStatus.INACTIVE; - workflow.deactivatedAt = new Date(); + await this.workflowRepository.remove(workflow); + } - return this.workflowRepository.save(workflow); - } + /** + * Activate a workflow + */ + async activate(id: string): Promise { + const workflow = await this.findOne(id); - /** - * Handle user signup event - */ - @OnEvent('user.signup') - async handleUserSignup(payload: { userId: string; email: string }) { - await this.executeTriggeredWorkflows(TriggerType.USER_SIGNUP, payload); + if (!workflow.triggers?.length) { + throw new BadRequestException('Workflow must have at least one trigger'); } - /** - * Handle course enrollment event - */ - @OnEvent('course.enrolled') - async handleCourseEnrollment(payload: { userId: string; courseId: string }) { - await this.executeTriggeredWorkflows(TriggerType.COURSE_ENROLLED, payload); + if (!workflow.actions?.length) { + throw new BadRequestException('Workflow must have at least one action'); } - /** - * Handle course completion event - */ - @OnEvent('course.completed') - async handleCourseCompletion(payload: { userId: string; courseId: string }) { - await this.executeTriggeredWorkflows(TriggerType.COURSE_COMPLETED, payload); + workflow.status = WorkflowStatus.ACTIVE; + workflow.activatedAt = new Date(); + + return this.workflowRepository.save(workflow); + } + + /** + * Deactivate a workflow + */ + async deactivate(id: string): Promise { + const workflow = await this.findOne(id); + workflow.status = WorkflowStatus.INACTIVE; + workflow.deactivatedAt = new Date(); + + return this.workflowRepository.save(workflow); + } + + /** + * Handle user signup event + */ + @OnEvent('user.signup') + async handleUserSignup(payload: { userId: string; email: string }) { + await this.executeTriggeredWorkflows(TriggerType.USER_SIGNUP, payload); + } + + /** + * Handle course enrollment event + */ + @OnEvent('course.enrolled') + async handleCourseEnrollment(payload: { userId: string; courseId: string }) { + await this.executeTriggeredWorkflows(TriggerType.COURSE_ENROLLED, payload); + } + + /** + * Handle course completion event + */ + @OnEvent('course.completed') + async handleCourseCompletion(payload: { userId: string; courseId: string }) { + await this.executeTriggeredWorkflows(TriggerType.COURSE_COMPLETED, payload); + } + + /** + * Handle purchase event + */ + @OnEvent('payment.completed') + async handlePurchase(payload: { userId: string; amount: number; productId: string }) { + await this.executeTriggeredWorkflows(TriggerType.PURCHASE_MADE, payload); + } + + /** + * Handle user inactivity (called by scheduled job) + */ + async handleUserInactivity(payload: { userId: string; daysSinceLastActivity: number }) { + await this.executeTriggeredWorkflows(TriggerType.USER_INACTIVE, payload); + } + + /** + * Execute workflows that match the trigger type + */ + private async executeTriggeredWorkflows( + triggerType: TriggerType, + payload: Record, + ): Promise { + // Find all active workflows with matching trigger + const triggers = await this.triggerRepository.find({ + where: { type: triggerType }, + relations: ['workflow', 'workflow.actions'], + }); + + for (const trigger of triggers) { + if (trigger.workflow.status !== WorkflowStatus.ACTIVE) { + continue; + } + + // Check trigger conditions + if (this.evaluateTriggerConditions(trigger, payload)) { + await this.executeWorkflowActions(trigger.workflow, payload); + } } - - /** - * Handle purchase event - */ - @OnEvent('payment.completed') - async handlePurchase(payload: { userId: string; amount: number; productId: string }) { - await this.executeTriggeredWorkflows(TriggerType.PURCHASE_MADE, payload); + } + + /** + * Evaluate trigger conditions + */ + private evaluateTriggerConditions( + trigger: AutomationTrigger, + payload: Record, + ): boolean { + if (!trigger.conditions || Object.keys(trigger.conditions).length === 0) { + return true; } - /** - * Handle user inactivity (called by scheduled job) - */ - async handleUserInactivity(payload: { userId: string; daysSinceLastActivity: number }) { - await this.executeTriggeredWorkflows(TriggerType.USER_INACTIVE, payload); + // Simple condition matching + for (const [key, value] of Object.entries(trigger.conditions)) { + if (payload[key] !== value) { + return false; + } } - /** - * Execute workflows that match the trigger type - */ - private async executeTriggeredWorkflows( - triggerType: TriggerType, - payload: Record, - ): Promise { - // Find all active workflows with matching trigger - const triggers = await this.triggerRepository.find({ - where: { type: triggerType }, - relations: ['workflow', 'workflow.actions'], - }); + return true; + } - for (const trigger of triggers) { - if (trigger.workflow.status !== WorkflowStatus.ACTIVE) { - continue; - } + /** + * Execute workflow actions in order + */ + private async executeWorkflowActions( + workflow: AutomationWorkflow, + payload: Record, + ): Promise { + const sortedActions = workflow.actions.sort((a, b) => a.order - b.order); - // Check trigger conditions - if (this.evaluateTriggerConditions(trigger, payload)) { - await this.executeWorkflowActions(trigger.workflow, payload); - } - } + for (const action of sortedActions) { + await this.executeAction(action, payload); } - /** - * Evaluate trigger conditions - */ - private evaluateTriggerConditions( - trigger: AutomationTrigger, - payload: Record, - ): boolean { - if (!trigger.conditions || Object.keys(trigger.conditions).length === 0) { - return true; - } - - // Simple condition matching - for (const [key, value] of Object.entries(trigger.conditions)) { - if (payload[key] !== value) { - return false; - } - } - - return true; - } + // Update workflow stats + workflow.executionCount = (workflow.executionCount || 0) + 1; + workflow.lastExecutedAt = new Date(); + await this.workflowRepository.save(workflow); + } + + /** + * Execute a single action + */ + private async executeAction( + action: AutomationAction, + payload: Record, + ): Promise { + switch (action.type) { + case ActionType.SEND_EMAIL: + await this.emailQueue.add('send-automation-email', { + actionId: action.id, + templateId: action.config.templateId, + userId: payload.userId, + variables: { ...payload, ...action.config.variables }, + }); + break; + + case ActionType.WAIT: + await this.emailQueue.add( + 'continue-automation', + { + workflowId: action.workflowId, + nextActionOrder: action.order + 1, + payload, + }, + { delay: action.config.delayMs || 0 }, + ); + break; + + case ActionType.ADD_TAG: + this.eventEmitter.emit('user.addTag', { + userId: payload.userId, + tag: action.config.tag, + }); + break; - /** - * Execute workflow actions in order - */ - private async executeWorkflowActions( - workflow: AutomationWorkflow, - payload: Record, - ): Promise { - const sortedActions = workflow.actions.sort((a, b) => a.order - b.order); - - for (const action of sortedActions) { - await this.executeAction(action, payload); - } - - // Update workflow stats - workflow.executionCount = (workflow.executionCount || 0) + 1; - workflow.lastExecutedAt = new Date(); - await this.workflowRepository.save(workflow); - } + case ActionType.REMOVE_TAG: + this.eventEmitter.emit('user.removeTag', { + userId: payload.userId, + tag: action.config.tag, + }); + break; - /** - * Execute a single action - */ - private async executeAction( - action: AutomationAction, - payload: Record, - ): Promise { - switch (action.type) { - case ActionType.SEND_EMAIL: - await this.emailQueue.add('send-automation-email', { - actionId: action.id, - templateId: action.config.templateId, - userId: payload.userId, - variables: { ...payload, ...action.config.variables }, - }); - break; - - case ActionType.WAIT: - await this.emailQueue.add( - 'continue-automation', - { - workflowId: action.workflowId, - nextActionOrder: action.order + 1, - payload, - }, - { delay: action.config.delayMs || 0 }, - ); - break; - - case ActionType.ADD_TAG: - this.eventEmitter.emit('user.addTag', { - userId: payload.userId, - tag: action.config.tag, - }); - break; - - case ActionType.REMOVE_TAG: - this.eventEmitter.emit('user.removeTag', { - userId: payload.userId, - tag: action.config.tag, - }); - break; - - case ActionType.ADD_TO_SEGMENT: - this.eventEmitter.emit('segment.addUser', { - userId: payload.userId, - segmentId: action.config.segmentId, - }); - break; - - case ActionType.WEBHOOK: - await this.emailQueue.add('call-webhook', { - url: action.config.webhookUrl, - method: action.config.method || 'POST', - payload: { ...payload, ...action.config.webhookPayload }, - }); - break; - - default: - console.warn(`Unknown action type: ${action.type}`); - } - } + case ActionType.ADD_TO_SEGMENT: + this.eventEmitter.emit('segment.addUser', { + userId: payload.userId, + segmentId: action.config.segmentId, + }); + break; + + case ActionType.WEBHOOK: + await this.emailQueue.add('call-webhook', { + url: action.config.webhookUrl, + method: action.config.method || 'POST', + payload: { ...payload, ...action.config.webhookPayload }, + }); + break; - /** - * Get workflow execution statistics - */ - async getWorkflowStats(id: string): Promise<{ - executionCount: number; - lastExecutedAt: Date | null; - emailsSent: number; - openRate: number; - clickRate: number; - }> { - const workflow = await this.findOne(id); - - // TODO: Calculate email stats from analytics - return { - executionCount: workflow.executionCount || 0, - lastExecutedAt: workflow.lastExecutedAt, - emailsSent: 0, - openRate: 0, - clickRate: 0, - }; + default: + console.warn(`Unknown action type: ${action.type}`); } + } + + /** + * Get workflow execution statistics + */ + async getWorkflowStats(id: string): Promise<{ + executionCount: number; + lastExecutedAt: Date | null; + emailsSent: number; + openRate: number; + clickRate: number; + }> { + const workflow = await this.findOne(id); + + // TODO: Calculate email stats from analytics + return { + executionCount: workflow.executionCount || 0, + lastExecutedAt: workflow.lastExecutedAt, + emailsSent: 0, + openRate: 0, + clickRate: 0, + }; + } } diff --git a/src/email-marketing/dto/add-segment-members.dto.ts b/src/email-marketing/dto/add-segment-members.dto.ts index 8e168a4..cd50bbf 100644 --- a/src/email-marketing/dto/add-segment-members.dto.ts +++ b/src/email-marketing/dto/add-segment-members.dto.ts @@ -2,13 +2,13 @@ import { IsArray, IsString, ArrayNotEmpty } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class AddSegmentMembersDto { - @ApiProperty({ - description: 'Array of user IDs to add to the segment', - example: ['user1', 'user2', 'user3'], - type: [String] - }) - @IsArray() - @ArrayNotEmpty() - @IsString({ each: true }) - userIds: string[]; + @ApiProperty({ + description: 'Array of user IDs to add to the segment', + example: ['user1', 'user2', 'user3'], + type: [String], + }) + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + userIds: string[]; } diff --git a/src/email-marketing/dto/create-ab-test.dto.ts b/src/email-marketing/dto/create-ab-test.dto.ts index 38ab5e7..ef81843 100644 --- a/src/email-marketing/dto/create-ab-test.dto.ts +++ b/src/email-marketing/dto/create-ab-test.dto.ts @@ -1,65 +1,79 @@ -import { IsString, IsArray, IsUUID, IsNotEmpty, IsNumber, IsOptional, ValidateNested, Min, Max } from 'class-validator'; +import { + IsString, + IsArray, + IsUUID, + IsNotEmpty, + IsNumber, + IsOptional, + ValidateNested, + Min, + Max, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; export class CreateABTestVariantDto { - @ApiPropertyOptional({ description: 'Variant name' }) - @IsString() - @IsOptional() - name?: string; + @ApiPropertyOptional({ description: 'Variant name' }) + @IsString() + @IsOptional() + name?: string; - @ApiPropertyOptional({ description: 'Subject line for this variant' }) - @IsString() - @IsOptional() - subject?: string; + @ApiPropertyOptional({ description: 'Subject line for this variant' }) + @IsString() + @IsOptional() + subject?: string; - @ApiPropertyOptional({ description: 'Template ID for this variant' }) - @IsUUID() - @IsOptional() - templateId?: string; + @ApiPropertyOptional({ description: 'Template ID for this variant' }) + @IsUUID() + @IsOptional() + templateId?: string; - @ApiPropertyOptional({ description: 'Sender name for this variant' }) - @IsString() - @IsOptional() - senderName?: string; + @ApiPropertyOptional({ description: 'Sender name for this variant' }) + @IsString() + @IsOptional() + senderName?: string; - @ApiProperty({ description: 'Traffic weight (percentage)', example: 50 }) - @IsNumber() - @Min(1) - @Max(99) - weight: number; + @ApiProperty({ description: 'Traffic weight (percentage)', example: 50 }) + @IsNumber() + @Min(1) + @Max(99) + weight: number; } export class CreateABTestDto { - @ApiProperty({ description: 'Test name', example: 'Subject Line Test' }) - @IsString() - @IsNotEmpty() - name: string; + @ApiProperty({ description: 'Test name', example: 'Subject Line Test' }) + @IsString() + @IsNotEmpty() + name: string; - @ApiProperty({ description: 'Campaign ID to run test on' }) - @IsUUID() - campaignId: string; + @ApiProperty({ description: 'Campaign ID to run test on' }) + @IsUUID() + campaignId: string; - @ApiProperty({ description: 'Field to test', example: 'subject' }) - @IsString() - @IsNotEmpty() - testField: string; + @ApiProperty({ description: 'Field to test', example: 'subject' }) + @IsString() + @IsNotEmpty() + testField: string; - @ApiPropertyOptional({ description: 'Winner criteria', enum: ['open_rate', 'click_rate'], default: 'open_rate' }) - @IsString() - @IsOptional() - winnerCriteria?: string; + @ApiPropertyOptional({ + description: 'Winner criteria', + enum: ['open_rate', 'click_rate'], + default: 'open_rate', + }) + @IsString() + @IsOptional() + winnerCriteria?: string; - @ApiPropertyOptional({ description: 'Sample size percentage', default: 20 }) - @IsNumber() - @Min(5) - @Max(50) - @IsOptional() - sampleSize?: number; + @ApiPropertyOptional({ description: 'Sample size percentage', default: 20 }) + @IsNumber() + @Min(5) + @Max(50) + @IsOptional() + sampleSize?: number; - @ApiProperty({ type: [CreateABTestVariantDto], description: 'Test variants (min 2)' }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => CreateABTestVariantDto) - variants: CreateABTestVariantDto[]; + @ApiProperty({ type: [CreateABTestVariantDto], description: 'Test variants (min 2)' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateABTestVariantDto) + variants: CreateABTestVariantDto[]; } diff --git a/src/email-marketing/dto/create-automation.dto.ts b/src/email-marketing/dto/create-automation.dto.ts index b479479..a05503a 100644 --- a/src/email-marketing/dto/create-automation.dto.ts +++ b/src/email-marketing/dto/create-automation.dto.ts @@ -5,56 +5,56 @@ import { TriggerType } from '../enums/trigger-type.enum'; import { ActionType } from '../enums/action-type.enum'; export class CreateTriggerDto { - @ApiProperty({ enum: TriggerType }) - @IsEnum(TriggerType) - type: TriggerType; - - @ApiPropertyOptional({ description: 'Trigger conditions' }) - @IsOptional() - conditions?: Record; - - @ApiPropertyOptional() - @IsString() - @IsOptional() - description?: string; + @ApiProperty({ enum: TriggerType }) + @IsEnum(TriggerType) + type: TriggerType; + + @ApiPropertyOptional({ description: 'Trigger conditions' }) + @IsOptional() + conditions?: Record; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; } export class CreateActionDto { - @ApiProperty({ enum: ActionType }) - @IsEnum(ActionType) - type: ActionType; + @ApiProperty({ enum: ActionType }) + @IsEnum(ActionType) + type: ActionType; - @ApiProperty({ description: 'Action configuration' }) - config: Record; + @ApiProperty({ description: 'Action configuration' }) + config: Record; - @ApiPropertyOptional() - @IsString() - @IsOptional() - description?: string; + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; } export class CreateAutomationDto { - @ApiProperty({ description: 'Workflow name', example: 'Welcome Series' }) - @IsString() - @IsNotEmpty() - name: string; - - @ApiPropertyOptional({ description: 'Workflow description' }) - @IsString() - @IsOptional() - description?: string; - - @ApiPropertyOptional({ type: [CreateTriggerDto] }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => CreateTriggerDto) - @IsOptional() - triggers?: CreateTriggerDto[]; - - @ApiPropertyOptional({ type: [CreateActionDto] }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => CreateActionDto) - @IsOptional() - actions?: CreateActionDto[]; + @ApiProperty({ description: 'Workflow name', example: 'Welcome Series' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ description: 'Workflow description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ type: [CreateTriggerDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateTriggerDto) + @IsOptional() + triggers?: CreateTriggerDto[]; + + @ApiPropertyOptional({ type: [CreateActionDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateActionDto) + @IsOptional() + actions?: CreateActionDto[]; } diff --git a/src/email-marketing/dto/create-campaign.dto.ts b/src/email-marketing/dto/create-campaign.dto.ts index e0d76e8..77dfb2d 100644 --- a/src/email-marketing/dto/create-campaign.dto.ts +++ b/src/email-marketing/dto/create-campaign.dto.ts @@ -2,37 +2,37 @@ import { IsString, IsOptional, IsArray, IsUUID, IsNotEmpty, MaxLength } from 'cl import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateCampaignDto { - @ApiProperty({ description: 'Campaign name', example: 'Welcome Campaign' }) - @IsString() - @IsNotEmpty() - @MaxLength(255) - name: string; + @ApiProperty({ description: 'Campaign name', example: 'Welcome Campaign' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; - @ApiProperty({ description: 'Email subject line', example: 'Welcome to TeachLink!' }) - @IsString() - @IsNotEmpty() - @MaxLength(255) - subject: string; + @ApiProperty({ description: 'Email subject line', example: 'Welcome to TeachLink!' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + subject: string; - @ApiPropertyOptional({ description: 'Preview text shown in inbox' }) - @IsString() - @IsOptional() - @MaxLength(255) - previewText?: string; + @ApiPropertyOptional({ description: 'Preview text shown in inbox' }) + @IsString() + @IsOptional() + @MaxLength(255) + previewText?: string; - @ApiPropertyOptional({ description: 'Raw HTML content (if not using template)' }) - @IsString() - @IsOptional() - content?: string; + @ApiPropertyOptional({ description: 'Raw HTML content (if not using template)' }) + @IsString() + @IsOptional() + content?: string; - @ApiPropertyOptional({ description: 'Template ID to use' }) - @IsUUID() - @IsOptional() - templateId?: string; + @ApiPropertyOptional({ description: 'Template ID to use' }) + @IsUUID() + @IsOptional() + templateId?: string; - @ApiPropertyOptional({ description: 'Segment IDs to target', type: [String] }) - @IsArray() - @IsUUID('4', { each: true }) - @IsOptional() - segmentIds?: string[]; + @ApiPropertyOptional({ description: 'Segment IDs to target', type: [String] }) + @IsArray() + @IsUUID('4', { each: true }) + @IsOptional() + segmentIds?: string[]; } diff --git a/src/email-marketing/dto/create-segment.dto.ts b/src/email-marketing/dto/create-segment.dto.ts index bad23d7..7175f6c 100644 --- a/src/email-marketing/dto/create-segment.dto.ts +++ b/src/email-marketing/dto/create-segment.dto.ts @@ -1,48 +1,56 @@ -import { IsString, IsOptional, IsArray, IsBoolean, IsNotEmpty, ValidateNested, IsEnum } from 'class-validator'; +import { + IsString, + IsOptional, + IsArray, + IsBoolean, + IsNotEmpty, + ValidateNested, + IsEnum, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { SegmentRuleField } from '../enums/segment-rule-field.enum'; import { SegmentRuleOperator } from '../enums/segment-rule-operator.enum'; export class CreateSegmentRuleDto { - @ApiProperty({ enum: SegmentRuleField, example: 'email' }) - @IsEnum(SegmentRuleField) - field: SegmentRuleField; - - @ApiProperty({ enum: SegmentRuleOperator, example: 'contains' }) - @IsEnum(SegmentRuleOperator) - operator: SegmentRuleOperator; - - @ApiProperty({ description: 'Rule value', example: 'gmail.com' }) - @IsNotEmpty() - value: any; - - @ApiPropertyOptional({ enum: ['AND', 'OR'], default: 'AND' }) - @IsOptional() - @IsString() - logicalOperator?: 'AND' | 'OR'; + @ApiProperty({ enum: SegmentRuleField, example: 'email' }) + @IsEnum(SegmentRuleField) + field: SegmentRuleField; + + @ApiProperty({ enum: SegmentRuleOperator, example: 'contains' }) + @IsEnum(SegmentRuleOperator) + operator: SegmentRuleOperator; + + @ApiProperty({ description: 'Rule value', example: 'gmail.com' }) + @IsNotEmpty() + value: any; + + @ApiPropertyOptional({ enum: ['AND', 'OR'], default: 'AND' }) + @IsOptional() + @IsString() + logicalOperator?: 'AND' | 'OR'; } export class CreateSegmentDto { - @ApiProperty({ description: 'Segment name', example: 'Active Users' }) - @IsString() - @IsNotEmpty() - name: string; - - @ApiPropertyOptional({ description: 'Segment description' }) - @IsString() - @IsOptional() - description?: string; - - @ApiPropertyOptional({ description: 'Dynamic or static segment', default: true }) - @IsBoolean() - @IsOptional() - isDynamic?: boolean; - - @ApiPropertyOptional({ type: [CreateSegmentRuleDto] }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => CreateSegmentRuleDto) - @IsOptional() - rules?: CreateSegmentRuleDto[]; + @ApiProperty({ description: 'Segment name', example: 'Active Users' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ description: 'Segment description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ description: 'Dynamic or static segment', default: true }) + @IsBoolean() + @IsOptional() + isDynamic?: boolean; + + @ApiPropertyOptional({ type: [CreateSegmentRuleDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateSegmentRuleDto) + @IsOptional() + rules?: CreateSegmentRuleDto[]; } diff --git a/src/email-marketing/dto/create-template.dto.ts b/src/email-marketing/dto/create-template.dto.ts index fb82120..be37bd7 100644 --- a/src/email-marketing/dto/create-template.dto.ts +++ b/src/email-marketing/dto/create-template.dto.ts @@ -2,31 +2,31 @@ import { IsString, IsOptional, IsArray, IsNotEmpty, MaxLength } from 'class-vali import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateTemplateDto { - @ApiProperty({ description: 'Template name', example: 'Welcome Email' }) - @IsString() - @IsNotEmpty() - @MaxLength(255) - name: string; + @ApiProperty({ description: 'Template name', example: 'Welcome Email' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; - @ApiProperty({ description: 'Email subject with variables', example: 'Welcome, {{firstName}}!' }) - @IsString() - @IsNotEmpty() - @MaxLength(255) - subject: string; + @ApiProperty({ description: 'Email subject with variables', example: 'Welcome, {{firstName}}!' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + subject: string; - @ApiProperty({ description: 'HTML content with Handlebars variables' }) - @IsString() - @IsNotEmpty() - htmlContent: string; + @ApiProperty({ description: 'HTML content with Handlebars variables' }) + @IsString() + @IsNotEmpty() + htmlContent: string; - @ApiPropertyOptional({ description: 'Plain text version' }) - @IsString() - @IsOptional() - textContent?: string; + @ApiPropertyOptional({ description: 'Plain text version' }) + @IsString() + @IsOptional() + textContent?: string; - @ApiPropertyOptional({ description: 'Category for organization' }) - @IsString() - @IsOptional() - @MaxLength(100) - category?: string; + @ApiPropertyOptional({ description: 'Category for organization' }) + @IsString() + @IsOptional() + @MaxLength(100) + category?: string; } diff --git a/src/email-marketing/dto/schedule-campaign.dto.ts b/src/email-marketing/dto/schedule-campaign.dto.ts index 4d7f152..fefab20 100644 --- a/src/email-marketing/dto/schedule-campaign.dto.ts +++ b/src/email-marketing/dto/schedule-campaign.dto.ts @@ -2,8 +2,8 @@ import { IsDateString, IsNotEmpty } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class ScheduleCampaignDto { - @ApiProperty({ description: 'Scheduled send time (ISO 8601)', example: '2026-02-01T10:00:00Z' }) - @IsDateString() - @IsNotEmpty() - scheduledAt: string; + @ApiProperty({ description: 'Scheduled send time (ISO 8601)', example: '2026-02-01T10:00:00Z' }) + @IsDateString() + @IsNotEmpty() + scheduledAt: string; } diff --git a/src/email-marketing/dto/update-automation.dto.ts b/src/email-marketing/dto/update-automation.dto.ts index 48235a0..5f00c82 100644 --- a/src/email-marketing/dto/update-automation.dto.ts +++ b/src/email-marketing/dto/update-automation.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from '@nestjs/swagger'; import { CreateAutomationDto } from './create-automation.dto'; -export class UpdateAutomationDto extends PartialType(CreateAutomationDto) { } +export class UpdateAutomationDto extends PartialType(CreateAutomationDto) {} diff --git a/src/email-marketing/dto/update-campaign.dto.ts b/src/email-marketing/dto/update-campaign.dto.ts index 5d9fd40..e5a8ecd 100644 --- a/src/email-marketing/dto/update-campaign.dto.ts +++ b/src/email-marketing/dto/update-campaign.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from '@nestjs/swagger'; import { CreateCampaignDto } from './create-campaign.dto'; -export class UpdateCampaignDto extends PartialType(CreateCampaignDto) { } +export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {} diff --git a/src/email-marketing/dto/update-segment.dto.ts b/src/email-marketing/dto/update-segment.dto.ts index c4b252f..8cfac3d 100644 --- a/src/email-marketing/dto/update-segment.dto.ts +++ b/src/email-marketing/dto/update-segment.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from '@nestjs/swagger'; import { CreateSegmentDto } from './create-segment.dto'; -export class UpdateSegmentDto extends PartialType(CreateSegmentDto) { } +export class UpdateSegmentDto extends PartialType(CreateSegmentDto) {} diff --git a/src/email-marketing/dto/update-template.dto.ts b/src/email-marketing/dto/update-template.dto.ts index b2221a8..176dc19 100644 --- a/src/email-marketing/dto/update-template.dto.ts +++ b/src/email-marketing/dto/update-template.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from '@nestjs/swagger'; import { CreateTemplateDto } from './create-template.dto'; -export class UpdateTemplateDto extends PartialType(CreateTemplateDto) { } +export class UpdateTemplateDto extends PartialType(CreateTemplateDto) {} diff --git a/src/email-marketing/email-marketing.controller.ts b/src/email-marketing/email-marketing.controller.ts index d6e62c5..4c32297 100644 --- a/src/email-marketing/email-marketing.controller.ts +++ b/src/email-marketing/email-marketing.controller.ts @@ -1,23 +1,17 @@ import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Query, - ParseUUIDPipe, - HttpCode, - HttpStatus, + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, - ApiQuery, -} from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { EmailMarketingService } from './email-marketing.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; @@ -29,106 +23,103 @@ import { Campaign } from './entities/campaign.entity'; @ApiBearerAuth() @Controller('email-marketing/campaigns') export class EmailMarketingController { - constructor(private readonly emailMarketingService: EmailMarketingService) { } + constructor(private readonly emailMarketingService: EmailMarketingService) {} - @Post() - @ApiOperation({ summary: 'Create a new email campaign' }) - @ApiResponse({ status: 201, description: 'Campaign created successfully', type: Campaign }) - @ApiResponse({ status: 400, description: 'Invalid input data' }) - async create(@Body() createCampaignDto: CreateCampaignDto): Promise { - return this.emailMarketingService.createCampaign(createCampaignDto); - } + @Post() + @ApiOperation({ summary: 'Create a new email campaign' }) + @ApiResponse({ status: 201, description: 'Campaign created successfully', type: Campaign }) + @ApiResponse({ status: 400, description: 'Invalid input data' }) + async create(@Body() createCampaignDto: CreateCampaignDto): Promise { + return this.emailMarketingService.createCampaign(createCampaignDto); + } - @Get() - @ApiOperation({ summary: 'Get all campaigns with pagination' }) - @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) - @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) - @ApiResponse({ status: 200, description: 'List of campaigns' }) - async findAll( - @Query('page') page: number = 1, - @Query('limit') limit: number = 10, - ) { - return this.emailMarketingService.findAll(page, limit); - } + @Get() + @ApiOperation({ summary: 'Get all campaigns with pagination' }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ status: 200, description: 'List of campaigns' }) + async findAll(@Query('page') page: number = 1, @Query('limit') limit: number = 10) { + return this.emailMarketingService.findAll(page, limit); + } - @Get(':id') - @ApiOperation({ summary: 'Get a campaign by ID' }) - @ApiResponse({ status: 200, description: 'Campaign details', type: Campaign }) - @ApiResponse({ status: 404, description: 'Campaign not found' }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.emailMarketingService.findOne(id); - } + @Get(':id') + @ApiOperation({ summary: 'Get a campaign by ID' }) + @ApiResponse({ status: 200, description: 'Campaign details', type: Campaign }) + @ApiResponse({ status: 404, description: 'Campaign not found' }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.emailMarketingService.findOne(id); + } - @Put(':id') - @ApiOperation({ summary: 'Update a campaign' }) - @ApiResponse({ status: 200, description: 'Campaign updated successfully', type: Campaign }) - @ApiResponse({ status: 400, description: 'Cannot update sent campaign' }) - @ApiResponse({ status: 404, description: 'Campaign not found' }) - async update( - @Param('id', ParseUUIDPipe) id: string, - @Body() updateCampaignDto: UpdateCampaignDto, - ): Promise { - return this.emailMarketingService.update(id, updateCampaignDto); - } + @Put(':id') + @ApiOperation({ summary: 'Update a campaign' }) + @ApiResponse({ status: 200, description: 'Campaign updated successfully', type: Campaign }) + @ApiResponse({ status: 400, description: 'Cannot update sent campaign' }) + @ApiResponse({ status: 404, description: 'Campaign not found' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateCampaignDto: UpdateCampaignDto, + ): Promise { + return this.emailMarketingService.update(id, updateCampaignDto); + } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete a campaign' }) - @ApiResponse({ status: 204, description: 'Campaign deleted successfully' }) - @ApiResponse({ status: 400, description: 'Cannot delete sending campaign' }) - @ApiResponse({ status: 404, description: 'Campaign not found' }) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.emailMarketingService.remove(id); - } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a campaign' }) + @ApiResponse({ status: 204, description: 'Campaign deleted successfully' }) + @ApiResponse({ status: 400, description: 'Cannot delete sending campaign' }) + @ApiResponse({ status: 404, description: 'Campaign not found' }) + async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.emailMarketingService.remove(id); + } - @Post(':id/schedule') - @ApiOperation({ summary: 'Schedule a campaign for future sending' }) - @ApiResponse({ status: 200, description: 'Campaign scheduled successfully', type: Campaign }) - @ApiResponse({ status: 400, description: 'Invalid schedule or campaign status' }) - async schedule( - @Param('id', ParseUUIDPipe) id: string, - @Body() scheduleDto: ScheduleCampaignDto, - ): Promise { - return this.emailMarketingService.scheduleCampaign(id, scheduleDto); - } + @Post(':id/schedule') + @ApiOperation({ summary: 'Schedule a campaign for future sending' }) + @ApiResponse({ status: 200, description: 'Campaign scheduled successfully', type: Campaign }) + @ApiResponse({ status: 400, description: 'Invalid schedule or campaign status' }) + async schedule( + @Param('id', ParseUUIDPipe) id: string, + @Body() scheduleDto: ScheduleCampaignDto, + ): Promise { + return this.emailMarketingService.scheduleCampaign(id, scheduleDto); + } - @Post(':id/send') - @ApiOperation({ summary: 'Send a campaign immediately' }) - @ApiResponse({ status: 200, description: 'Campaign sending initiated', type: Campaign }) - @ApiResponse({ status: 400, description: 'Campaign cannot be sent' }) - async send(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.emailMarketingService.sendCampaign(id); - } + @Post(':id/send') + @ApiOperation({ summary: 'Send a campaign immediately' }) + @ApiResponse({ status: 200, description: 'Campaign sending initiated', type: Campaign }) + @ApiResponse({ status: 400, description: 'Campaign cannot be sent' }) + async send(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.emailMarketingService.sendCampaign(id); + } - @Post(':id/pause') - @ApiOperation({ summary: 'Pause a scheduled or sending campaign' }) - @ApiResponse({ status: 200, description: 'Campaign paused successfully', type: Campaign }) - @ApiResponse({ status: 400, description: 'Campaign cannot be paused' }) - async pause(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.emailMarketingService.pauseCampaign(id); - } + @Post(':id/pause') + @ApiOperation({ summary: 'Pause a scheduled or sending campaign' }) + @ApiResponse({ status: 200, description: 'Campaign paused successfully', type: Campaign }) + @ApiResponse({ status: 400, description: 'Campaign cannot be paused' }) + async pause(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.emailMarketingService.pauseCampaign(id); + } - @Post(':id/resume') - @ApiOperation({ summary: 'Resume a paused campaign' }) - @ApiResponse({ status: 200, description: 'Campaign resumed successfully', type: Campaign }) - @ApiResponse({ status: 400, description: 'Campaign cannot be resumed' }) - async resume(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.emailMarketingService.resumeCampaign(id); - } + @Post(':id/resume') + @ApiOperation({ summary: 'Resume a paused campaign' }) + @ApiResponse({ status: 200, description: 'Campaign resumed successfully', type: Campaign }) + @ApiResponse({ status: 400, description: 'Campaign cannot be resumed' }) + async resume(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.emailMarketingService.resumeCampaign(id); + } - @Post(':id/duplicate') - @ApiOperation({ summary: 'Duplicate a campaign' }) - @ApiResponse({ status: 201, description: 'Campaign duplicated successfully', type: Campaign }) - @ApiResponse({ status: 404, description: 'Campaign not found' }) - async duplicate(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.emailMarketingService.duplicateCampaign(id); - } + @Post(':id/duplicate') + @ApiOperation({ summary: 'Duplicate a campaign' }) + @ApiResponse({ status: 201, description: 'Campaign duplicated successfully', type: Campaign }) + @ApiResponse({ status: 404, description: 'Campaign not found' }) + async duplicate(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.emailMarketingService.duplicateCampaign(id); + } - @Get(':id/stats') - @ApiOperation({ summary: 'Get campaign statistics' }) - @ApiResponse({ status: 200, description: 'Campaign statistics' }) - @ApiResponse({ status: 404, description: 'Campaign not found' }) - async getStats(@Param('id', ParseUUIDPipe) id: string) { - return this.emailMarketingService.getCampaignStats(id); - } + @Get(':id/stats') + @ApiOperation({ summary: 'Get campaign statistics' }) + @ApiResponse({ status: 200, description: 'Campaign statistics' }) + @ApiResponse({ status: 404, description: 'Campaign not found' }) + async getStats(@Param('id', ParseUUIDPipe) id: string) { + return this.emailMarketingService.getCampaignStats(id); + } } diff --git a/src/email-marketing/email-marketing.module.ts b/src/email-marketing/email-marketing.module.ts index 1c63bd4..418f408 100644 --- a/src/email-marketing/email-marketing.module.ts +++ b/src/email-marketing/email-marketing.module.ts @@ -88,4 +88,4 @@ import { EmailQueueProcessor } from './processors/email-queue.processor'; ABTestingService, ], }) -export class EmailMarketingModule { } +export class EmailMarketingModule {} diff --git a/src/email-marketing/email-marketing.service.ts b/src/email-marketing/email-marketing.service.ts index d301401..d577929 100644 --- a/src/email-marketing/email-marketing.service.ts +++ b/src/email-marketing/email-marketing.service.ts @@ -18,248 +18,254 @@ import { CampaignStatus } from './enums/campaign-status.enum'; @Injectable() export class EmailMarketingService { - constructor( - @InjectRepository(Campaign) - private readonly campaignRepository: Repository, - @InjectRepository(CampaignRecipient) - private readonly recipientRepository: Repository, - @InjectQueue('email-marketing') - private readonly emailQueue: Queue, - private readonly segmentationService: SegmentationService, - private readonly templateService: TemplateManagementService, - private readonly abTestingService: ABTestingService, - private readonly analyticsService: EmailAnalyticsService, - ) { } - - /** - * Create a new email campaign - */ - async createCampaign(createCampaignDto: CreateCampaignDto): Promise { - // Validate template exists - if (createCampaignDto.templateId) { - await this.templateService.findOne(createCampaignDto.templateId); - } - - // Validate segments exist - if (createCampaignDto.segmentIds?.length) { - for (const segmentId of createCampaignDto.segmentIds) { - await this.segmentationService.findOne(segmentId); - } - } - - const campaign = this.campaignRepository.create({ - ...createCampaignDto, - status: CampaignStatus.DRAFT, - }); - - return this.campaignRepository.save(campaign); + constructor( + @InjectRepository(Campaign) + private readonly campaignRepository: Repository, + @InjectRepository(CampaignRecipient) + private readonly recipientRepository: Repository, + @InjectQueue('email-marketing') + private readonly emailQueue: Queue, + private readonly segmentationService: SegmentationService, + private readonly templateService: TemplateManagementService, + private readonly abTestingService: ABTestingService, + private readonly analyticsService: EmailAnalyticsService, + ) {} + + /** + * Create a new email campaign + */ + async createCampaign(createCampaignDto: CreateCampaignDto): Promise { + // Validate template exists + if (createCampaignDto.templateId) { + await this.templateService.findOne(createCampaignDto.templateId); } - /** - * Get all campaigns with pagination - */ - async findAll(page: number = 1, limit: number = 10): Promise<{ - campaigns: Campaign[]; - total: number; - page: number; - totalPages: number; - }> { - const [campaigns, total] = await this.campaignRepository.findAndCount({ - skip: (page - 1) * limit, - take: limit, - order: { createdAt: 'DESC' }, - relations: ['template'], - }); - - return { - campaigns, - total, - page, - totalPages: Math.ceil(total / limit), - }; + // Validate segments exist + if (createCampaignDto.segmentIds?.length) { + for (const segmentId of createCampaignDto.segmentIds) { + await this.segmentationService.findOne(segmentId); + } } - /** - * Get a single campaign by ID - */ - async findOne(id: string): Promise { - const campaign = await this.campaignRepository.findOne({ - where: { id }, - relations: ['template', 'abTest', 'recipients'], - }); + const campaign = this.campaignRepository.create({ + ...createCampaignDto, + status: CampaignStatus.DRAFT, + }); + + return this.campaignRepository.save(campaign); + } + + /** + * Get all campaigns with pagination + */ + async findAll( + page: number = 1, + limit: number = 10, + ): Promise<{ + campaigns: Campaign[]; + total: number; + page: number; + totalPages: number; + }> { + const [campaigns, total] = await this.campaignRepository.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + relations: ['template'], + }); + + return { + campaigns, + total, + page, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Get a single campaign by ID + */ + async findOne(id: string): Promise { + const campaign = await this.campaignRepository.findOne({ + where: { id }, + relations: ['template', 'abTest', 'recipients'], + }); + + if (!campaign) { + throw new NotFoundException(`Campaign with ID ${id} not found`); + } + + return campaign; + } - if (!campaign) { - throw new NotFoundException(`Campaign with ID ${id} not found`); - } + /** + * Update a campaign + */ + async update(id: string, updateCampaignDto: UpdateCampaignDto): Promise { + const campaign = await this.findOne(id); - return campaign; + if (campaign.status === CampaignStatus.SENT) { + throw new BadRequestException('Cannot update a sent campaign'); } - /** - * Update a campaign - */ - async update(id: string, updateCampaignDto: UpdateCampaignDto): Promise { - const campaign = await this.findOne(id); + Object.assign(campaign, updateCampaignDto); + return this.campaignRepository.save(campaign); + } - if (campaign.status === CampaignStatus.SENT) { - throw new BadRequestException('Cannot update a sent campaign'); - } + /** + * Delete a campaign + */ + async remove(id: string): Promise { + const campaign = await this.findOne(id); - Object.assign(campaign, updateCampaignDto); - return this.campaignRepository.save(campaign); + if (campaign.status === CampaignStatus.SENDING) { + throw new BadRequestException('Cannot delete a campaign that is currently sending'); } - /** - * Delete a campaign - */ - async remove(id: string): Promise { - const campaign = await this.findOne(id); + await this.campaignRepository.remove(campaign); + } - if (campaign.status === CampaignStatus.SENDING) { - throw new BadRequestException('Cannot delete a campaign that is currently sending'); - } + /** + * Schedule a campaign for future sending + */ + async scheduleCampaign(id: string, scheduleDto: ScheduleCampaignDto): Promise { + const campaign = await this.findOne(id); - await this.campaignRepository.remove(campaign); + if (campaign.status !== CampaignStatus.DRAFT) { + throw new BadRequestException('Only draft campaigns can be scheduled'); } - /** - * Schedule a campaign for future sending - */ - async scheduleCampaign(id: string, scheduleDto: ScheduleCampaignDto): Promise { - const campaign = await this.findOne(id); - - if (campaign.status !== CampaignStatus.DRAFT) { - throw new BadRequestException('Only draft campaigns can be scheduled'); - } - - const scheduledDate = new Date(scheduleDto.scheduledAt); - if (scheduledDate <= new Date()) { - throw new BadRequestException('Scheduled date must be in the future'); - } - - campaign.scheduledAt = scheduledDate; - campaign.status = CampaignStatus.SCHEDULED; - - // Add to queue with delay - const delay = scheduledDate.getTime() - Date.now(); - await this.emailQueue.add( - 'send-campaign', - { campaignId: id }, - { delay, jobId: `campaign-${id}` }, - ); - - return this.campaignRepository.save(campaign); + const scheduledDate = new Date(scheduleDto.scheduledAt); + if (scheduledDate <= new Date()) { + throw new BadRequestException('Scheduled date must be in the future'); } - /** - * Send a campaign immediately - */ - async sendCampaign(id: string): Promise { - const campaign = await this.findOne(id); - - if (campaign.status === CampaignStatus.SENT || campaign.status === CampaignStatus.SENDING) { - throw new BadRequestException('Campaign has already been sent or is sending'); - } - - // Get recipients from segments - const recipients = await this.segmentationService.getUsersFromSegments( - campaign.segmentIds || [], - ); - - if (recipients.length === 0) { - throw new BadRequestException('No recipients found for this campaign'); - } - - // Update campaign status - campaign.status = CampaignStatus.SENDING; - campaign.sentAt = new Date(); - campaign.totalRecipients = recipients.length; - await this.campaignRepository.save(campaign); - - // Queue emails for sending - await this.emailQueue.add('process-campaign', { - campaignId: id, - recipients: recipients.map((r) => r.id), - }); - - return campaign; - } + campaign.scheduledAt = scheduledDate; + campaign.status = CampaignStatus.SCHEDULED; - /** - * Pause a scheduled or sending campaign - */ - async pauseCampaign(id: string): Promise { - const campaign = await this.findOne(id); + // Add to queue with delay + const delay = scheduledDate.getTime() - Date.now(); + await this.emailQueue.add( + 'send-campaign', + { campaignId: id }, + { delay, jobId: `campaign-${id}` }, + ); - if (campaign.status !== CampaignStatus.SCHEDULED && campaign.status !== CampaignStatus.SENDING) { - throw new BadRequestException('Only scheduled or sending campaigns can be paused'); - } + return this.campaignRepository.save(campaign); + } - // Remove from queue if scheduled - if (campaign.status === CampaignStatus.SCHEDULED) { - await this.emailQueue.removeJobs(`campaign-${id}`); - } + /** + * Send a campaign immediately + */ + async sendCampaign(id: string): Promise { + const campaign = await this.findOne(id); - campaign.status = CampaignStatus.PAUSED; - return this.campaignRepository.save(campaign); + if (campaign.status === CampaignStatus.SENT || campaign.status === CampaignStatus.SENDING) { + throw new BadRequestException('Campaign has already been sent or is sending'); } - /** - * Resume a paused campaign - */ - async resumeCampaign(id: string): Promise { - const campaign = await this.findOne(id); + // Get recipients from segments + const recipients = await this.segmentationService.getUsersFromSegments( + campaign.segmentIds || [], + ); - if (campaign.status !== CampaignStatus.PAUSED) { - throw new BadRequestException('Only paused campaigns can be resumed'); - } - - // If it was scheduled, re-schedule - if (campaign.scheduledAt && campaign.scheduledAt > new Date()) { - return this.scheduleCampaign(id, { scheduledAt: campaign.scheduledAt.toISOString() }); - } + if (recipients.length === 0) { + throw new BadRequestException('No recipients found for this campaign'); + } - // Otherwise, resume sending - campaign.status = CampaignStatus.SENDING; - await this.emailQueue.add('resume-campaign', { campaignId: id }); + // Update campaign status + campaign.status = CampaignStatus.SENDING; + campaign.sentAt = new Date(); + campaign.totalRecipients = recipients.length; + await this.campaignRepository.save(campaign); + + // Queue emails for sending + await this.emailQueue.add('process-campaign', { + campaignId: id, + recipients: recipients.map((r) => r.id), + }); + + return campaign; + } + + /** + * Pause a scheduled or sending campaign + */ + async pauseCampaign(id: string): Promise { + const campaign = await this.findOne(id); + + if ( + campaign.status !== CampaignStatus.SCHEDULED && + campaign.status !== CampaignStatus.SENDING + ) { + throw new BadRequestException('Only scheduled or sending campaigns can be paused'); + } - return this.campaignRepository.save(campaign); + // Remove from queue if scheduled + if (campaign.status === CampaignStatus.SCHEDULED) { + await this.emailQueue.removeJobs(`campaign-${id}`); } - /** - * Duplicate a campaign - */ - async duplicateCampaign(id: string): Promise { - const original = await this.findOne(id); - - const duplicate = this.campaignRepository.create({ - name: `${original.name} (Copy)`, - subject: original.subject, - previewText: original.previewText, - templateId: original.templateId, - segmentIds: original.segmentIds, - content: original.content, - status: CampaignStatus.DRAFT, - }); - - return this.campaignRepository.save(duplicate); + campaign.status = CampaignStatus.PAUSED; + return this.campaignRepository.save(campaign); + } + + /** + * Resume a paused campaign + */ + async resumeCampaign(id: string): Promise { + const campaign = await this.findOne(id); + + if (campaign.status !== CampaignStatus.PAUSED) { + throw new BadRequestException('Only paused campaigns can be resumed'); } - /** - * Get campaign statistics - */ - async getCampaignStats(id: string): Promise<{ - sent: number; - delivered: number; - opened: number; - clicked: number; - bounced: number; - unsubscribed: number; - openRate: number; - clickRate: number; - bounceRate: number; - }> { - await this.findOne(id); - return this.analyticsService.getCampaignMetrics(id); + // If it was scheduled, re-schedule + if (campaign.scheduledAt && campaign.scheduledAt > new Date()) { + return this.scheduleCampaign(id, { scheduledAt: campaign.scheduledAt.toISOString() }); } + + // Otherwise, resume sending + campaign.status = CampaignStatus.SENDING; + await this.emailQueue.add('resume-campaign', { campaignId: id }); + + return this.campaignRepository.save(campaign); + } + + /** + * Duplicate a campaign + */ + async duplicateCampaign(id: string): Promise { + const original = await this.findOne(id); + + const duplicate = this.campaignRepository.create({ + name: `${original.name} (Copy)`, + subject: original.subject, + previewText: original.previewText, + templateId: original.templateId, + segmentIds: original.segmentIds, + content: original.content, + status: CampaignStatus.DRAFT, + }); + + return this.campaignRepository.save(duplicate); + } + + /** + * Get campaign statistics + */ + async getCampaignStats(id: string): Promise<{ + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + unsubscribed: number; + openRate: number; + clickRate: number; + bounceRate: number; + }> { + await this.findOne(id); + return this.analyticsService.getCampaignMetrics(id); + } } diff --git a/src/email-marketing/entities/ab-test-variant.entity.ts b/src/email-marketing/entities/ab-test-variant.entity.ts index 9312c0a..7d4f3d5 100644 --- a/src/email-marketing/entities/ab-test-variant.entity.ts +++ b/src/email-marketing/entities/ab-test-variant.entity.ts @@ -5,39 +5,39 @@ import { ABTest } from './ab-test.entity'; @Entity('ab_test_variants') export class ABTestVariant { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - abTestId: string; + @ApiProperty() + @Column() + abTestId: string; - @ManyToOne(() => ABTest, (abTest) => abTest.variants, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'abTestId' }) - abTest: ABTest; + @ManyToOne(() => ABTest, (abTest) => abTest.variants, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'abTestId' }) + abTest: ABTest; - @ApiProperty() - @Column() - name: string; // e.g., 'Variant A', 'Variant B' + @ApiProperty() + @Column() + name: string; // e.g., 'Variant A', 'Variant B' - @ApiProperty({ required: false }) - @Column({ nullable: true }) - subject?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + subject?: string; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - templateId?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + templateId?: string; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - senderName?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + senderName?: string; - @ApiProperty() - @Column({ default: 50 }) - weight: number; // Percentage of traffic + @ApiProperty() + @Column({ default: 50 }) + weight: number; // Percentage of traffic - @ApiProperty() - @Column({ default: 0 }) - recipientCount: number; + @ApiProperty() + @Column({ default: 0 }) + recipientCount: number; } diff --git a/src/email-marketing/entities/ab-test.entity.ts b/src/email-marketing/entities/ab-test.entity.ts index 04ef5f3..089d8b1 100644 --- a/src/email-marketing/entities/ab-test.entity.ts +++ b/src/email-marketing/entities/ab-test.entity.ts @@ -1,5 +1,11 @@ import { - Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToOne, OneToMany, JoinColumn, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToOne, + OneToMany, + JoinColumn, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @@ -9,54 +15,54 @@ import { ABTestStatus } from '../enums/ab-test-status.enum'; @Entity('ab_tests') export class ABTest { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - name: string; + @ApiProperty() + @Column() + name: string; - @ApiProperty() - @Column() - campaignId: string; + @ApiProperty() + @Column() + campaignId: string; - @OneToOne(() => Campaign) - @JoinColumn({ name: 'campaignId' }) - campaign: Campaign; + @OneToOne(() => Campaign) + @JoinColumn({ name: 'campaignId' }) + campaign: Campaign; - @ApiProperty() - @Column() - testField: string; // 'subject', 'template', 'sender', 'sendTime' + @ApiProperty() + @Column() + testField: string; // 'subject', 'template', 'sender', 'sendTime' - @ApiProperty() - @Column({ default: 'open_rate' }) - winnerCriteria: string; // 'open_rate', 'click_rate' + @ApiProperty() + @Column({ default: 'open_rate' }) + winnerCriteria: string; // 'open_rate', 'click_rate' - @ApiProperty() - @Column({ default: 20 }) - sampleSize: number; // Percentage of total recipients for test + @ApiProperty() + @Column({ default: 20 }) + sampleSize: number; // Percentage of total recipients for test - @ApiProperty({ enum: ABTestStatus }) - @Column({ type: 'enum', enum: ABTestStatus, default: ABTestStatus.DRAFT }) - status: ABTestStatus; + @ApiProperty({ enum: ABTestStatus }) + @Column({ type: 'enum', enum: ABTestStatus, default: ABTestStatus.DRAFT }) + status: ABTestStatus; - @OneToMany(() => ABTestVariant, (variant) => variant.abTest, { cascade: true }) - variants: ABTestVariant[]; + @OneToMany(() => ABTestVariant, (variant) => variant.abTest, { cascade: true }) + variants: ABTestVariant[]; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - winnerId?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + winnerId?: string; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - startedAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + startedAt?: Date; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - endedAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + endedAt?: Date; - @ApiProperty() - @CreateDateColumn() - createdAt: Date; + @ApiProperty() + @CreateDateColumn() + createdAt: Date; } diff --git a/src/email-marketing/entities/automation-action.entity.ts b/src/email-marketing/entities/automation-action.entity.ts index 291a03e..5312cca 100644 --- a/src/email-marketing/entities/automation-action.entity.ts +++ b/src/email-marketing/entities/automation-action.entity.ts @@ -1,6 +1,4 @@ -import { - Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { AutomationWorkflow } from './automation-workflow.entity'; @@ -8,31 +6,31 @@ import { ActionType } from '../enums/action-type.enum'; @Entity('automation_actions') export class AutomationAction { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - workflowId: string; + @ApiProperty() + @Column() + workflowId: string; - @ManyToOne(() => AutomationWorkflow, (workflow) => workflow.actions, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'workflowId' }) - workflow: AutomationWorkflow; + @ManyToOne(() => AutomationWorkflow, (workflow) => workflow.actions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'workflowId' }) + workflow: AutomationWorkflow; - @ApiProperty({ enum: ActionType }) - @Column({ type: 'enum', enum: ActionType }) - type: ActionType; + @ApiProperty({ enum: ActionType }) + @Column({ type: 'enum', enum: ActionType }) + type: ActionType; - @ApiProperty() - @Column({ type: 'jsonb' }) - config: Record; + @ApiProperty() + @Column({ type: 'jsonb' }) + config: Record; - @ApiProperty() - @Column({ default: 0 }) - order: number; + @ApiProperty() + @Column({ default: 0 }) + order: number; - @ApiProperty({ required: false }) - @Column({ type: 'text', nullable: true }) - description?: string; + @ApiProperty({ required: false }) + @Column({ type: 'text', nullable: true }) + description?: string; } diff --git a/src/email-marketing/entities/automation-trigger.entity.ts b/src/email-marketing/entities/automation-trigger.entity.ts index 84730e4..e3a0a13 100644 --- a/src/email-marketing/entities/automation-trigger.entity.ts +++ b/src/email-marketing/entities/automation-trigger.entity.ts @@ -1,6 +1,4 @@ -import { - Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { AutomationWorkflow } from './automation-workflow.entity'; @@ -8,27 +6,27 @@ import { TriggerType } from '../enums/trigger-type.enum'; @Entity('automation_triggers') export class AutomationTrigger { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - workflowId: string; + @ApiProperty() + @Column() + workflowId: string; - @ManyToOne(() => AutomationWorkflow, (workflow) => workflow.triggers, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'workflowId' }) - workflow: AutomationWorkflow; + @ManyToOne(() => AutomationWorkflow, (workflow) => workflow.triggers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'workflowId' }) + workflow: AutomationWorkflow; - @ApiProperty({ enum: TriggerType }) - @Column({ type: 'enum', enum: TriggerType }) - type: TriggerType; + @ApiProperty({ enum: TriggerType }) + @Column({ type: 'enum', enum: TriggerType }) + type: TriggerType; - @ApiProperty({ required: false }) - @Column({ type: 'jsonb', nullable: true }) - conditions?: Record; + @ApiProperty({ required: false }) + @Column({ type: 'jsonb', nullable: true }) + conditions?: Record; - @ApiProperty({ required: false }) - @Column({ type: 'text', nullable: true }) - description?: string; + @ApiProperty({ required: false }) + @Column({ type: 'text', nullable: true }) + description?: string; } diff --git a/src/email-marketing/entities/automation-workflow.entity.ts b/src/email-marketing/entities/automation-workflow.entity.ts index 1feb94d..ddecc7b 100644 --- a/src/email-marketing/entities/automation-workflow.entity.ts +++ b/src/email-marketing/entities/automation-workflow.entity.ts @@ -1,5 +1,10 @@ import { - Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @@ -9,49 +14,49 @@ import { WorkflowStatus } from '../enums/workflow-status.enum'; @Entity('automation_workflows') export class AutomationWorkflow { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - name: string; + @ApiProperty() + @Column() + name: string; - @ApiProperty({ required: false }) - @Column({ type: 'text', nullable: true }) - description?: string; + @ApiProperty({ required: false }) + @Column({ type: 'text', nullable: true }) + description?: string; - @ApiProperty({ enum: WorkflowStatus }) - @Column({ type: 'enum', enum: WorkflowStatus, default: WorkflowStatus.DRAFT }) - status: WorkflowStatus; + @ApiProperty({ enum: WorkflowStatus }) + @Column({ type: 'enum', enum: WorkflowStatus, default: WorkflowStatus.DRAFT }) + status: WorkflowStatus; - @OneToMany(() => AutomationTrigger, (trigger) => trigger.workflow, { cascade: true }) - triggers: AutomationTrigger[]; + @OneToMany(() => AutomationTrigger, (trigger) => trigger.workflow, { cascade: true }) + triggers: AutomationTrigger[]; - @OneToMany(() => AutomationAction, (action) => action.workflow, { cascade: true }) - actions: AutomationAction[]; + @OneToMany(() => AutomationAction, (action) => action.workflow, { cascade: true }) + actions: AutomationAction[]; - @ApiProperty() - @Column({ default: 0 }) - executionCount: number; + @ApiProperty() + @Column({ default: 0 }) + executionCount: number; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - lastExecutedAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + lastExecutedAt?: Date; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - activatedAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + activatedAt?: Date; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - deactivatedAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + deactivatedAt?: Date; - @ApiProperty() - @CreateDateColumn() - createdAt: Date; + @ApiProperty() + @CreateDateColumn() + createdAt: Date; - @ApiProperty() - @UpdateDateColumn() - updatedAt: Date; + @ApiProperty() + @UpdateDateColumn() + updatedAt: Date; } diff --git a/src/email-marketing/entities/campaign-recipient.entity.ts b/src/email-marketing/entities/campaign-recipient.entity.ts index 9136d44..3c5ac38 100644 --- a/src/email-marketing/entities/campaign-recipient.entity.ts +++ b/src/email-marketing/entities/campaign-recipient.entity.ts @@ -7,35 +7,35 @@ import { RecipientStatus } from '../enums/recipient-status.enum'; @Entity('campaign_recipients') @Index(['campaignId', 'status']) export class CampaignRecipient { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - campaignId: string; + @ApiProperty() + @Column() + campaignId: string; - @ManyToOne(() => Campaign, (campaign) => campaign.recipients, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'campaignId' }) - campaign: Campaign; + @ManyToOne(() => Campaign, (campaign) => campaign.recipients, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'campaignId' }) + campaign: Campaign; - @ApiProperty() - @Column() - userId: string; + @ApiProperty() + @Column() + userId: string; - @ApiProperty() - @Column() - email: string; + @ApiProperty() + @Column() + email: string; - @ApiProperty({ enum: RecipientStatus }) - @Column({ type: 'enum', enum: RecipientStatus, default: RecipientStatus.PENDING }) - status: RecipientStatus; + @ApiProperty({ enum: RecipientStatus }) + @Column({ type: 'enum', enum: RecipientStatus, default: RecipientStatus.PENDING }) + status: RecipientStatus; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - sentAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + sentAt?: Date; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - variantId?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + variantId?: string; } diff --git a/src/email-marketing/entities/campaign.entity.ts b/src/email-marketing/entities/campaign.entity.ts index 1ec06b6..6577ee9 100644 --- a/src/email-marketing/entities/campaign.entity.ts +++ b/src/email-marketing/entities/campaign.entity.ts @@ -1,6 +1,13 @@ import { - Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, - ManyToOne, OneToMany, OneToOne, JoinColumn, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + OneToOne, + JoinColumn, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @@ -11,65 +18,65 @@ import { CampaignStatus } from '../enums/campaign-status.enum'; @Entity('email_campaigns') export class Campaign { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - name: string; + @ApiProperty() + @Column() + name: string; - @ApiProperty() - @Column() - subject: string; + @ApiProperty() + @Column() + subject: string; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - previewText?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + previewText?: string; - @ApiProperty({ required: false }) - @Column({ type: 'text', nullable: true }) - content?: string; + @ApiProperty({ required: false }) + @Column({ type: 'text', nullable: true }) + content?: string; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - templateId?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + templateId?: string; - @ManyToOne(() => EmailTemplate, { nullable: true }) - @JoinColumn({ name: 'templateId' }) - template?: EmailTemplate; + @ManyToOne(() => EmailTemplate, { nullable: true }) + @JoinColumn({ name: 'templateId' }) + template?: EmailTemplate; - @ApiProperty({ type: [String] }) - @Column('simple-array', { nullable: true }) - segmentIds?: string[]; + @ApiProperty({ type: [String] }) + @Column('simple-array', { nullable: true }) + segmentIds?: string[]; - @ApiProperty({ enum: CampaignStatus }) - @Column({ type: 'enum', enum: CampaignStatus, default: CampaignStatus.DRAFT }) - status: CampaignStatus; + @ApiProperty({ enum: CampaignStatus }) + @Column({ type: 'enum', enum: CampaignStatus, default: CampaignStatus.DRAFT }) + status: CampaignStatus; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - scheduledAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + scheduledAt?: Date; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - sentAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + sentAt?: Date; - @ApiProperty() - @Column({ default: 0 }) - totalRecipients: number; + @ApiProperty() + @Column({ default: 0 }) + totalRecipients: number; - @OneToOne(() => ABTest, (abTest) => abTest.campaign, { nullable: true }) - abTest?: ABTest; + @OneToOne(() => ABTest, (abTest) => abTest.campaign, { nullable: true }) + abTest?: ABTest; - @OneToMany(() => CampaignRecipient, (recipient) => recipient.campaign) - recipients: CampaignRecipient[]; + @OneToMany(() => CampaignRecipient, (recipient) => recipient.campaign) + recipients: CampaignRecipient[]; - @ApiProperty() - @CreateDateColumn() - createdAt: Date; + @ApiProperty() + @CreateDateColumn() + createdAt: Date; - @ApiProperty() - @UpdateDateColumn() - updatedAt: Date; + @ApiProperty() + @UpdateDateColumn() + updatedAt: Date; } diff --git a/src/email-marketing/entities/email-event.entity.ts b/src/email-marketing/entities/email-event.entity.ts index 6b43b96..632c2f6 100644 --- a/src/email-marketing/entities/email-event.entity.ts +++ b/src/email-marketing/entities/email-event.entity.ts @@ -7,27 +7,27 @@ import { EmailEventType } from '../enums/email-event-type.enum'; @Index(['campaignId', 'eventType']) @Index(['recipientId', 'eventType']) export class EmailEvent { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - campaignId: string; + @ApiProperty() + @Column() + campaignId: string; - @ApiProperty() - @Column() - recipientId: string; + @ApiProperty() + @Column() + recipientId: string; - @ApiProperty({ enum: EmailEventType }) - @Column({ type: 'enum', enum: EmailEventType }) - eventType: EmailEventType; + @ApiProperty({ enum: EmailEventType }) + @Column({ type: 'enum', enum: EmailEventType }) + eventType: EmailEventType; - @ApiProperty({ required: false }) - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; + @ApiProperty({ required: false }) + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; - @ApiProperty() - @CreateDateColumn() - occurredAt: Date; + @ApiProperty() + @CreateDateColumn() + occurredAt: Date; } diff --git a/src/email-marketing/entities/email-subscription.entity.ts b/src/email-marketing/entities/email-subscription.entity.ts index 18defc1..60bcaaf 100644 --- a/src/email-marketing/entities/email-subscription.entity.ts +++ b/src/email-marketing/entities/email-subscription.entity.ts @@ -4,35 +4,35 @@ import { ApiProperty } from '@nestjs/swagger'; @Entity('email_subscriptions') @Index(['email'], { unique: true }) export class EmailSubscription { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column({ unique: true }) - email: string; + @ApiProperty() + @Column({ unique: true }) + email: string; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - userId?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + userId?: string; - @ApiProperty() - @Column({ default: true }) - isSubscribed: boolean; + @ApiProperty() + @Column({ default: true }) + isSubscribed: boolean; - @ApiProperty({ type: [String] }) - @Column('simple-array', { nullable: true }) - preferences?: string[]; // e.g., ['marketing', 'product_updates', 'newsletters'] + @ApiProperty({ type: [String] }) + @Column('simple-array', { nullable: true }) + preferences?: string[]; // e.g., ['marketing', 'product_updates', 'newsletters'] - @ApiProperty({ required: false }) - @Column({ nullable: true }) - unsubscribedAt?: Date; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + unsubscribedAt?: Date; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - unsubscribeReason?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + unsubscribeReason?: string; - @ApiProperty() - @CreateDateColumn() - subscribedAt: Date; + @ApiProperty() + @CreateDateColumn() + subscribedAt: Date; } diff --git a/src/email-marketing/entities/email-template.entity.ts b/src/email-marketing/entities/email-template.entity.ts index eef67c2..1429f8a 100644 --- a/src/email-marketing/entities/email-template.entity.ts +++ b/src/email-marketing/entities/email-template.entity.ts @@ -1,51 +1,55 @@ import { - Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @Entity('email_templates') export class EmailTemplate { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - name: string; + @ApiProperty() + @Column() + name: string; - @ApiProperty() - @Column() - subject: string; + @ApiProperty() + @Column() + subject: string; - @ApiProperty() - @Column({ type: 'text' }) - htmlContent: string; + @ApiProperty() + @Column({ type: 'text' }) + htmlContent: string; - @ApiProperty({ required: false }) - @Column({ type: 'text', nullable: true }) - textContent?: string; + @ApiProperty({ required: false }) + @Column({ type: 'text', nullable: true }) + textContent?: string; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - category?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + category?: string; - @ApiProperty({ type: [String] }) - @Column('simple-array', { nullable: true }) - variables?: string[]; + @ApiProperty({ type: [String] }) + @Column('simple-array', { nullable: true }) + variables?: string[]; - @ApiProperty({ required: false }) - @Column({ nullable: true }) - thumbnailUrl?: string; + @ApiProperty({ required: false }) + @Column({ nullable: true }) + thumbnailUrl?: string; - @ApiProperty() - @Column({ default: true }) - isActive: boolean; + @ApiProperty() + @Column({ default: true }) + isActive: boolean; - @ApiProperty() - @CreateDateColumn() - createdAt: Date; + @ApiProperty() + @CreateDateColumn() + createdAt: Date; - @ApiProperty() - @UpdateDateColumn() - updatedAt: Date; + @ApiProperty() + @UpdateDateColumn() + updatedAt: Date; } diff --git a/src/email-marketing/entities/segment-rule.entity.ts b/src/email-marketing/entities/segment-rule.entity.ts index 8e54f5c..3e6bbb1 100644 --- a/src/email-marketing/entities/segment-rule.entity.ts +++ b/src/email-marketing/entities/segment-rule.entity.ts @@ -7,35 +7,35 @@ import { SegmentRuleField } from '../enums/segment-rule-field.enum'; @Entity('segment_rules') export class SegmentRule { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - segmentId: string; + @ApiProperty() + @Column() + segmentId: string; - @ManyToOne(() => Segment, (segment) => segment.rules, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'segmentId' }) - segment: Segment; + @ManyToOne(() => Segment, (segment) => segment.rules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'segmentId' }) + segment: Segment; - @ApiProperty({ enum: SegmentRuleField }) - @Column({ type: 'enum', enum: SegmentRuleField }) - field: SegmentRuleField; + @ApiProperty({ enum: SegmentRuleField }) + @Column({ type: 'enum', enum: SegmentRuleField }) + field: SegmentRuleField; - @ApiProperty({ enum: SegmentRuleOperator }) - @Column({ type: 'enum', enum: SegmentRuleOperator }) - operator: SegmentRuleOperator; + @ApiProperty({ enum: SegmentRuleOperator }) + @Column({ type: 'enum', enum: SegmentRuleOperator }) + operator: SegmentRuleOperator; - @ApiProperty() - @Column({ type: 'jsonb' }) - value: any; + @ApiProperty() + @Column({ type: 'jsonb' }) + value: any; - @ApiProperty() - @Column({ default: 0 }) - order: number; + @ApiProperty() + @Column({ default: 0 }) + order: number; - @ApiProperty({ default: 'AND' }) - @Column({ default: 'AND' }) - logicalOperator: 'AND' | 'OR'; + @ApiProperty({ default: 'AND' }) + @Column({ default: 'AND' }) + logicalOperator: 'AND' | 'OR'; } diff --git a/src/email-marketing/entities/segment.entity.ts b/src/email-marketing/entities/segment.entity.ts index 60bbb5c..da70270 100644 --- a/src/email-marketing/entities/segment.entity.ts +++ b/src/email-marketing/entities/segment.entity.ts @@ -1,5 +1,10 @@ import { - Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @@ -7,37 +12,37 @@ import { SegmentRule } from './segment-rule.entity'; @Entity('segments') export class Segment { - @ApiProperty() - @PrimaryGeneratedColumn('uuid') - id: string; + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id: string; - @ApiProperty() - @Column() - name: string; + @ApiProperty() + @Column() + name: string; - @ApiProperty({ required: false }) - @Column({ type: 'text', nullable: true }) - description?: string; + @ApiProperty({ required: false }) + @Column({ type: 'text', nullable: true }) + description?: string; - @ApiProperty() - @Column({ default: true }) - isDynamic: boolean; + @ApiProperty() + @Column({ default: true }) + isDynamic: boolean; - @OneToMany(() => SegmentRule, (rule) => rule.segment, { cascade: true }) - rules: SegmentRule[]; + @OneToMany(() => SegmentRule, (rule) => rule.segment, { cascade: true }) + rules: SegmentRule[]; - @ApiProperty({ type: [String] }) - @Column('simple-array', { nullable: true }) - staticMemberIds?: string[]; + @ApiProperty({ type: [String] }) + @Column('simple-array', { nullable: true }) + staticMemberIds?: string[]; - @ApiProperty() - memberCount?: number; // Calculated field + @ApiProperty() + memberCount?: number; // Calculated field - @ApiProperty() - @CreateDateColumn() - createdAt: Date; + @ApiProperty() + @CreateDateColumn() + createdAt: Date; - @ApiProperty() - @UpdateDateColumn() - updatedAt: Date; + @ApiProperty() + @UpdateDateColumn() + updatedAt: Date; } diff --git a/src/email-marketing/enums/ab-test-status.enum.ts b/src/email-marketing/enums/ab-test-status.enum.ts index 2b48f28..e3881a9 100644 --- a/src/email-marketing/enums/ab-test-status.enum.ts +++ b/src/email-marketing/enums/ab-test-status.enum.ts @@ -1,6 +1,6 @@ export enum ABTestStatus { - DRAFT = 'draft', - RUNNING = 'running', - COMPLETED = 'completed', - CANCELLED = 'cancelled', + DRAFT = 'draft', + RUNNING = 'running', + COMPLETED = 'completed', + CANCELLED = 'cancelled', } diff --git a/src/email-marketing/enums/action-type.enum.ts b/src/email-marketing/enums/action-type.enum.ts index 747569c..0078023 100644 --- a/src/email-marketing/enums/action-type.enum.ts +++ b/src/email-marketing/enums/action-type.enum.ts @@ -1,12 +1,12 @@ export enum ActionType { - SEND_EMAIL = 'send_email', - WAIT = 'wait', - ADD_TAG = 'add_tag', - REMOVE_TAG = 'remove_tag', - ADD_TO_SEGMENT = 'add_to_segment', - REMOVE_FROM_SEGMENT = 'remove_from_segment', - UPDATE_PROPERTY = 'update_property', - WEBHOOK = 'webhook', - SEND_SMS = 'send_sms', - SEND_PUSH_NOTIFICATION = 'send_push_notification', + SEND_EMAIL = 'send_email', + WAIT = 'wait', + ADD_TAG = 'add_tag', + REMOVE_TAG = 'remove_tag', + ADD_TO_SEGMENT = 'add_to_segment', + REMOVE_FROM_SEGMENT = 'remove_from_segment', + UPDATE_PROPERTY = 'update_property', + WEBHOOK = 'webhook', + SEND_SMS = 'send_sms', + SEND_PUSH_NOTIFICATION = 'send_push_notification', } diff --git a/src/email-marketing/enums/campaign-status.enum.ts b/src/email-marketing/enums/campaign-status.enum.ts index b6183a1..7dd7550 100644 --- a/src/email-marketing/enums/campaign-status.enum.ts +++ b/src/email-marketing/enums/campaign-status.enum.ts @@ -1,8 +1,8 @@ export enum CampaignStatus { - DRAFT = 'draft', - SCHEDULED = 'scheduled', - SENDING = 'sending', - SENT = 'sent', - PAUSED = 'paused', - CANCELLED = 'cancelled', + DRAFT = 'draft', + SCHEDULED = 'scheduled', + SENDING = 'sending', + SENT = 'sent', + PAUSED = 'paused', + CANCELLED = 'cancelled', } diff --git a/src/email-marketing/enums/email-event-type.enum.ts b/src/email-marketing/enums/email-event-type.enum.ts index d596921..4139f53 100644 --- a/src/email-marketing/enums/email-event-type.enum.ts +++ b/src/email-marketing/enums/email-event-type.enum.ts @@ -1,10 +1,10 @@ export enum EmailEventType { - SENT = 'sent', - DELIVERED = 'delivered', - OPENED = 'opened', - CLICKED = 'clicked', - BOUNCED = 'bounced', - SOFT_BOUNCED = 'soft_bounced', - COMPLAINED = 'complained', - UNSUBSCRIBED = 'unsubscribed', + SENT = 'sent', + DELIVERED = 'delivered', + OPENED = 'opened', + CLICKED = 'clicked', + BOUNCED = 'bounced', + SOFT_BOUNCED = 'soft_bounced', + COMPLAINED = 'complained', + UNSUBSCRIBED = 'unsubscribed', } diff --git a/src/email-marketing/enums/recipient-status.enum.ts b/src/email-marketing/enums/recipient-status.enum.ts index f40c523..82ac783 100644 --- a/src/email-marketing/enums/recipient-status.enum.ts +++ b/src/email-marketing/enums/recipient-status.enum.ts @@ -1,6 +1,6 @@ export enum RecipientStatus { - PENDING = 'pending', - SENT = 'sent', - FAILED = 'failed', - SKIPPED = 'skipped', + PENDING = 'pending', + SENT = 'sent', + FAILED = 'failed', + SKIPPED = 'skipped', } diff --git a/src/email-marketing/enums/segment-rule-field.enum.ts b/src/email-marketing/enums/segment-rule-field.enum.ts index 0af3ef6..585cbd6 100644 --- a/src/email-marketing/enums/segment-rule-field.enum.ts +++ b/src/email-marketing/enums/segment-rule-field.enum.ts @@ -1,12 +1,12 @@ export enum SegmentRuleField { - EMAIL = 'email', - FIRST_NAME = 'first_name', - LAST_NAME = 'last_name', - CREATED_AT = 'created_at', - LAST_LOGIN = 'last_login', - COURSE_COUNT = 'course_count', - TOTAL_SPENT = 'total_spent', - TAG = 'tag', - COUNTRY = 'country', - SUBSCRIPTION_STATUS = 'subscription_status', + EMAIL = 'email', + FIRST_NAME = 'first_name', + LAST_NAME = 'last_name', + CREATED_AT = 'created_at', + LAST_LOGIN = 'last_login', + COURSE_COUNT = 'course_count', + TOTAL_SPENT = 'total_spent', + TAG = 'tag', + COUNTRY = 'country', + SUBSCRIPTION_STATUS = 'subscription_status', } diff --git a/src/email-marketing/enums/segment-rule-operator.enum.ts b/src/email-marketing/enums/segment-rule-operator.enum.ts index 489a06c..69d75fe 100644 --- a/src/email-marketing/enums/segment-rule-operator.enum.ts +++ b/src/email-marketing/enums/segment-rule-operator.enum.ts @@ -1,19 +1,19 @@ export enum SegmentRuleOperator { - EQUALS = 'equals', - NOT_EQUALS = 'not_equals', - CONTAINS = 'contains', - NOT_CONTAINS = 'not_contains', - STARTS_WITH = 'starts_with', - ENDS_WITH = 'ends_with', - GREATER_THAN = 'greater_than', - LESS_THAN = 'less_than', - GREATER_OR_EQUAL = 'greater_or_equal', - LESS_OR_EQUAL = 'less_or_equal', - IS_SET = 'is_set', - IS_NOT_SET = 'is_not_set', - IN_LIST = 'in_list', - NOT_IN_LIST = 'not_in_list', - BEFORE = 'before', - AFTER = 'after', - BETWEEN = 'between', + EQUALS = 'equals', + NOT_EQUALS = 'not_equals', + CONTAINS = 'contains', + NOT_CONTAINS = 'not_contains', + STARTS_WITH = 'starts_with', + ENDS_WITH = 'ends_with', + GREATER_THAN = 'greater_than', + LESS_THAN = 'less_than', + GREATER_OR_EQUAL = 'greater_or_equal', + LESS_OR_EQUAL = 'less_or_equal', + IS_SET = 'is_set', + IS_NOT_SET = 'is_not_set', + IN_LIST = 'in_list', + NOT_IN_LIST = 'not_in_list', + BEFORE = 'before', + AFTER = 'after', + BETWEEN = 'between', } diff --git a/src/email-marketing/enums/trigger-type.enum.ts b/src/email-marketing/enums/trigger-type.enum.ts index 809d60c..fd4012a 100644 --- a/src/email-marketing/enums/trigger-type.enum.ts +++ b/src/email-marketing/enums/trigger-type.enum.ts @@ -1,14 +1,14 @@ export enum TriggerType { - USER_SIGNUP = 'user_signup', - COURSE_ENROLLED = 'course_enrolled', - COURSE_COMPLETED = 'course_completed', - PURCHASE_MADE = 'purchase_made', - USER_INACTIVE = 'user_inactive', - SUBSCRIPTION_CREATED = 'subscription_created', - SUBSCRIPTION_CANCELLED = 'subscription_cancelled', - BIRTHDAY = 'birthday', - CUSTOM_EVENT = 'custom_event', - DATE_BASED = 'date_based', - SEGMENT_ENTERED = 'segment_entered', - SEGMENT_LEFT = 'segment_left', + USER_SIGNUP = 'user_signup', + COURSE_ENROLLED = 'course_enrolled', + COURSE_COMPLETED = 'course_completed', + PURCHASE_MADE = 'purchase_made', + USER_INACTIVE = 'user_inactive', + SUBSCRIPTION_CREATED = 'subscription_created', + SUBSCRIPTION_CANCELLED = 'subscription_cancelled', + BIRTHDAY = 'birthday', + CUSTOM_EVENT = 'custom_event', + DATE_BASED = 'date_based', + SEGMENT_ENTERED = 'segment_entered', + SEGMENT_LEFT = 'segment_left', } diff --git a/src/email-marketing/enums/workflow-status.enum.ts b/src/email-marketing/enums/workflow-status.enum.ts index d23c528..d817956 100644 --- a/src/email-marketing/enums/workflow-status.enum.ts +++ b/src/email-marketing/enums/workflow-status.enum.ts @@ -1,6 +1,6 @@ export enum WorkflowStatus { - DRAFT = 'draft', - ACTIVE = 'active', - INACTIVE = 'inactive', - ARCHIVED = 'archived', + DRAFT = 'draft', + ACTIVE = 'active', + INACTIVE = 'inactive', + ARCHIVED = 'archived', } diff --git a/src/email-marketing/processors/email-queue.processor.ts b/src/email-marketing/processors/email-queue.processor.ts index 65b1515..341bb2d 100644 --- a/src/email-marketing/processors/email-queue.processor.ts +++ b/src/email-marketing/processors/email-queue.processor.ts @@ -14,185 +14,187 @@ import { RecipientStatus } from '../enums/recipient-status.enum'; @Processor('email-marketing') export class EmailQueueProcessor { - private readonly logger = new Logger(EmailQueueProcessor.name); - - constructor( - @InjectRepository(Campaign) - private readonly campaignRepository: Repository, - @InjectRepository(CampaignRecipient) - private readonly recipientRepository: Repository, - private readonly emailSenderService: EmailSenderService, - private readonly segmentationService: SegmentationService, - private readonly abTestingService: ABTestingService, - ) { } - - @Process('send-campaign') - async handleScheduledCampaign(job: Job<{ campaignId: string }>) { - this.logger.log(`Processing scheduled campaign: ${job.data.campaignId}`); - - const campaign = await this.campaignRepository.findOne({ - where: { id: job.data.campaignId }, - relations: ['abTest', 'abTest.variants'], - }); - - if (!campaign || campaign.status !== CampaignStatus.SCHEDULED) { - this.logger.warn(`Campaign ${job.data.campaignId} not found or not scheduled`); - return; - } - - // Get recipients - const users = await this.segmentationService.getUsersFromSegments(campaign.segmentIds || []); - - campaign.status = CampaignStatus.SENDING; - campaign.sentAt = new Date(); - campaign.totalRecipients = users.length; - await this.campaignRepository.save(campaign); - - await this.processRecipients(campaign, users); + private readonly logger = new Logger(EmailQueueProcessor.name); + + constructor( + @InjectRepository(Campaign) + private readonly campaignRepository: Repository, + @InjectRepository(CampaignRecipient) + private readonly recipientRepository: Repository, + private readonly emailSenderService: EmailSenderService, + private readonly segmentationService: SegmentationService, + private readonly abTestingService: ABTestingService, + ) {} + + @Process('send-campaign') + async handleScheduledCampaign(job: Job<{ campaignId: string }>) { + this.logger.log(`Processing scheduled campaign: ${job.data.campaignId}`); + + const campaign = await this.campaignRepository.findOne({ + where: { id: job.data.campaignId }, + relations: ['abTest', 'abTest.variants'], + }); + + if (!campaign || campaign.status !== CampaignStatus.SCHEDULED) { + this.logger.warn(`Campaign ${job.data.campaignId} not found or not scheduled`); + return; } - @Process('process-campaign') - async handleCampaignProcessing(job: Job<{ campaignId: string; recipients: string[] }>) { - this.logger.log(`Processing campaign: ${job.data.campaignId}`); + // Get recipients + const users = await this.segmentationService.getUsersFromSegments(campaign.segmentIds || []); - const campaign = await this.campaignRepository.findOne({ - where: { id: job.data.campaignId }, - relations: ['abTest', 'abTest.variants'], - }); + campaign.status = CampaignStatus.SENDING; + campaign.sentAt = new Date(); + campaign.totalRecipients = users.length; + await this.campaignRepository.save(campaign); - if (!campaign) { - return; - } + await this.processRecipients(campaign, users); + } - // Fetch user details for recipients - // TODO: Integrate with Users module - const users = job.data.recipients.map((id) => ({ - id, - email: `user-${id}@example.com`, // Placeholder - })); + @Process('process-campaign') + async handleCampaignProcessing(job: Job<{ campaignId: string; recipients: string[] }>) { + this.logger.log(`Processing campaign: ${job.data.campaignId}`); - await this.processRecipients(campaign, users); - } + const campaign = await this.campaignRepository.findOne({ + where: { id: job.data.campaignId }, + relations: ['abTest', 'abTest.variants'], + }); - @Process('send-automation-email') - async handleAutomationEmail(job: Job<{ - actionId: string; - templateId: string; - userId: string; - variables: Record; - }>) { - this.logger.log(`Sending automation email for action: ${job.data.actionId}`); - - // TODO: Get user email from Users module - const userEmail = `user-${job.data.userId}@example.com`; - - await this.emailSenderService.sendEmail({ - to: userEmail, - templateId: job.data.templateId, - variables: job.data.variables, - trackOpens: true, - trackClicks: true, - }); + if (!campaign) { + return; } - @Process('resume-campaign') - async handleResumeCampaign(job: Job<{ campaignId: string }>) { - this.logger.log(`Resuming campaign: ${job.data.campaignId}`); - - const pendingRecipients = await this.recipientRepository.find({ - where: { - campaignId: job.data.campaignId, - status: RecipientStatus.PENDING, - }, - }); - - const campaign = await this.campaignRepository.findOne({ - where: { id: job.data.campaignId }, - relations: ['abTest', 'abTest.variants'], - }); - - if (!campaign) return; - - for (const recipient of pendingRecipients) { - await this.sendToRecipient(campaign, recipient); - } - - await this.finalizeCampaign(job.data.campaignId); + // Fetch user details for recipients + // TODO: Integrate with Users module + const users = job.data.recipients.map((id) => ({ + id, + email: `user-${id}@example.com`, // Placeholder + })); + + await this.processRecipients(campaign, users); + } + + @Process('send-automation-email') + async handleAutomationEmail( + job: Job<{ + actionId: string; + templateId: string; + userId: string; + variables: Record; + }>, + ) { + this.logger.log(`Sending automation email for action: ${job.data.actionId}`); + + // TODO: Get user email from Users module + const userEmail = `user-${job.data.userId}@example.com`; + + await this.emailSenderService.sendEmail({ + to: userEmail, + templateId: job.data.templateId, + variables: job.data.variables, + trackOpens: true, + trackClicks: true, + }); + } + + @Process('resume-campaign') + async handleResumeCampaign(job: Job<{ campaignId: string }>) { + this.logger.log(`Resuming campaign: ${job.data.campaignId}`); + + const pendingRecipients = await this.recipientRepository.find({ + where: { + campaignId: job.data.campaignId, + status: RecipientStatus.PENDING, + }, + }); + + const campaign = await this.campaignRepository.findOne({ + where: { id: job.data.campaignId }, + relations: ['abTest', 'abTest.variants'], + }); + + if (!campaign) return; + + for (const recipient of pendingRecipients) { + await this.sendToRecipient(campaign, recipient); } - // Private helper methods - private async processRecipients( - campaign: Campaign, - users: Array<{ id: string; email: string }>, - ): Promise { - // Create recipient records - const recipients = users.map((user) => - this.recipientRepository.create({ - campaignId: campaign.id, - userId: user.id, - email: user.email, - status: RecipientStatus.PENDING, - }), - ); - - await this.recipientRepository.save(recipients); - - // Process in batches - const batchSize = 100; - for (let i = 0; i < recipients.length; i += batchSize) { - const batch = recipients.slice(i, i + batchSize); - - await Promise.all(batch.map((recipient) => this.sendToRecipient(campaign, recipient))); - - // Update progress - const progress = Math.round(((i + batch.length) / recipients.length) * 100); - this.logger.log(`Campaign ${campaign.id} progress: ${progress}%`); - } - - await this.finalizeCampaign(campaign.id); + await this.finalizeCampaign(job.data.campaignId); + } + + // Private helper methods + private async processRecipients( + campaign: Campaign, + users: Array<{ id: string; email: string }>, + ): Promise { + // Create recipient records + const recipients = users.map((user) => + this.recipientRepository.create({ + campaignId: campaign.id, + userId: user.id, + email: user.email, + status: RecipientStatus.PENDING, + }), + ); + + await this.recipientRepository.save(recipients); + + // Process in batches + const batchSize = 100; + for (let i = 0; i < recipients.length; i += batchSize) { + const batch = recipients.slice(i, i + batchSize); + + await Promise.all(batch.map((recipient) => this.sendToRecipient(campaign, recipient))); + + // Update progress + const progress = Math.round(((i + batch.length) / recipients.length) * 100); + this.logger.log(`Campaign ${campaign.id} progress: ${progress}%`); } - private async sendToRecipient(campaign: Campaign, recipient: CampaignRecipient): Promise { - try { - // Select A/B test variant if applicable - let variantId: string | undefined; - let templateId = campaign.templateId; - - if (campaign.abTest) { - const variant = this.abTestingService.selectVariantForRecipient(campaign.abTest); - variantId = variant.id; - templateId = variant.templateId || templateId; - } - - const result = await this.emailSenderService.sendEmail({ - to: recipient.email, - templateId, - variables: { userId: recipient.userId }, - campaignId: campaign.id, - recipientId: recipient.id, - variantId, - trackOpens: true, - trackClicks: true, - }); - - recipient.status = result.success ? RecipientStatus.SENT : RecipientStatus.FAILED; - recipient.sentAt = new Date(); - - await this.recipientRepository.save(recipient); - } catch (error) { - this.logger.error(`Failed to send to ${recipient.email}:`, error); - recipient.status = RecipientStatus.FAILED; - await this.recipientRepository.save(recipient); - } + await this.finalizeCampaign(campaign.id); + } + + private async sendToRecipient(campaign: Campaign, recipient: CampaignRecipient): Promise { + try { + // Select A/B test variant if applicable + let variantId: string | undefined; + let templateId = campaign.templateId; + + if (campaign.abTest) { + const variant = this.abTestingService.selectVariantForRecipient(campaign.abTest); + variantId = variant.id; + templateId = variant.templateId || templateId; + } + + const result = await this.emailSenderService.sendEmail({ + to: recipient.email, + templateId, + variables: { userId: recipient.userId }, + campaignId: campaign.id, + recipientId: recipient.id, + variantId, + trackOpens: true, + trackClicks: true, + }); + + recipient.status = result.success ? RecipientStatus.SENT : RecipientStatus.FAILED; + recipient.sentAt = new Date(); + + await this.recipientRepository.save(recipient); + } catch (error) { + this.logger.error(`Failed to send to ${recipient.email}:`, error); + recipient.status = RecipientStatus.FAILED; + await this.recipientRepository.save(recipient); } + } - private async finalizeCampaign(campaignId: string): Promise { - const campaign = await this.campaignRepository.findOne({ where: { id: campaignId } }); + private async finalizeCampaign(campaignId: string): Promise { + const campaign = await this.campaignRepository.findOne({ where: { id: campaignId } }); - if (campaign && campaign.status === CampaignStatus.SENDING) { - campaign.status = CampaignStatus.SENT; - await this.campaignRepository.save(campaign); - this.logger.log(`Campaign ${campaignId} completed`); - } + if (campaign && campaign.status === CampaignStatus.SENDING) { + campaign.status = CampaignStatus.SENT; + await this.campaignRepository.save(campaign); + this.logger.log(`Campaign ${campaignId} completed`); } + } } diff --git a/src/email-marketing/segmentation/segment.controller.ts b/src/email-marketing/segmentation/segment.controller.ts index 8aa6e8e..12e499e 100644 --- a/src/email-marketing/segmentation/segment.controller.ts +++ b/src/email-marketing/segmentation/segment.controller.ts @@ -1,23 +1,17 @@ import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Query, - ParseUUIDPipe, - HttpCode, - HttpStatus, + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, - ApiQuery, -} from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { SegmentationService } from './segmentation.service'; import { CreateSegmentDto } from '../dto/create-segment.dto'; @@ -29,69 +23,69 @@ import { Segment } from '../entities/segment.entity'; @ApiBearerAuth() @Controller('email-marketing/segments') export class SegmentController { - constructor(private readonly segmentationService: SegmentationService) { } + constructor(private readonly segmentationService: SegmentationService) {} - @Post() - @ApiOperation({ summary: 'Create a new audience segment' }) - @ApiResponse({ status: 201, description: 'Segment created successfully' }) - async create(@Body() createSegmentDto: CreateSegmentDto): Promise { - return this.segmentationService.create(createSegmentDto); - } + @Post() + @ApiOperation({ summary: 'Create a new audience segment' }) + @ApiResponse({ status: 201, description: 'Segment created successfully' }) + async create(@Body() createSegmentDto: CreateSegmentDto): Promise { + return this.segmentationService.create(createSegmentDto); + } - @Get() - @ApiOperation({ summary: 'Get all segments with pagination' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - async findAll(@Query('page') page = 1, @Query('limit') limit = 10) { - return this.segmentationService.findAll(page, limit); - } + @Get() + @ApiOperation({ summary: 'Get all segments with pagination' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findAll(@Query('page') page = 1, @Query('limit') limit = 10) { + return this.segmentationService.findAll(page, limit); + } - @Get(':id') - @ApiOperation({ summary: 'Get a segment by ID' }) - @ApiResponse({ status: 404, description: 'Segment not found' }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.segmentationService.findOne(id); - } + @Get(':id') + @ApiOperation({ summary: 'Get a segment by ID' }) + @ApiResponse({ status: 404, description: 'Segment not found' }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.segmentationService.findOne(id); + } - @Put(':id') - @ApiOperation({ summary: 'Update a segment' }) - async update( - @Param('id', ParseUUIDPipe) id: string, - @Body() updateSegmentDto: UpdateSegmentDto, - ): Promise { - return this.segmentationService.update(id, updateSegmentDto); - } + @Put(':id') + @ApiOperation({ summary: 'Update a segment' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateSegmentDto: UpdateSegmentDto, + ): Promise { + return this.segmentationService.update(id, updateSegmentDto); + } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete a segment' }) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.segmentationService.remove(id); - } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a segment' }) + async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.segmentationService.remove(id); + } - @Get(':id/members') - @ApiOperation({ summary: 'Get members of a segment' }) - async getMembers(@Param('id', ParseUUIDPipe) id: string) { - return this.segmentationService.getSegmentMembers(id); - } + @Get(':id/members') + @ApiOperation({ summary: 'Get members of a segment' }) + async getMembers(@Param('id', ParseUUIDPipe) id: string) { + return this.segmentationService.getSegmentMembers(id); + } - @Post(':id/members') - @ApiOperation({ summary: 'Add users to a static segment' }) - @ApiResponse({ status: 200, description: 'Users added successfully' }) - async addMembers( - @Param('id', ParseUUIDPipe) id: string, - @Body() addMembersDto: AddSegmentMembersDto, - ): Promise<{ message: string; addedCount: number }> { - await this.segmentationService.addUsersToSegment(id, addMembersDto.userIds); - return { - message: 'Users added successfully', - addedCount: addMembersDto.userIds.length - }; - } + @Post(':id/members') + @ApiOperation({ summary: 'Add users to a static segment' }) + @ApiResponse({ status: 200, description: 'Users added successfully' }) + async addMembers( + @Param('id', ParseUUIDPipe) id: string, + @Body() addMembersDto: AddSegmentMembersDto, + ): Promise<{ message: string; addedCount: number }> { + await this.segmentationService.addUsersToSegment(id, addMembersDto.userIds); + return { + message: 'Users added successfully', + addedCount: addMembersDto.userIds.length, + }; + } - @Post('preview') - @ApiOperation({ summary: 'Preview segment members without saving' }) - async preview(@Body() createSegmentDto: CreateSegmentDto) { - return this.segmentationService.previewSegment(createSegmentDto.rules); - } + @Post('preview') + @ApiOperation({ summary: 'Preview segment members without saving' }) + async preview(@Body() createSegmentDto: CreateSegmentDto) { + return this.segmentationService.previewSegment(createSegmentDto.rules); + } } diff --git a/src/email-marketing/segmentation/segmentation.service.ts b/src/email-marketing/segmentation/segmentation.service.ts index 18b5064..b3ce000 100644 --- a/src/email-marketing/segmentation/segmentation.service.ts +++ b/src/email-marketing/segmentation/segmentation.service.ts @@ -11,438 +11,442 @@ import { SegmentRuleField } from '../enums/segment-rule-field.enum'; // Note: Import User entity from users module when integrating export interface UserProfile { - id: string; - email: string; - firstName?: string; - lastName?: string; - createdAt: Date; - lastLoginAt?: Date; - tags?: string[]; - preferences?: Record; + id: string; + email: string; + firstName?: string; + lastName?: string; + createdAt: Date; + lastLoginAt?: Date; + tags?: string[]; + preferences?: Record; } @Injectable() export class SegmentationService { - constructor( - @InjectRepository(Segment) - private readonly segmentRepository: Repository, - @InjectRepository(SegmentRule) - private readonly ruleRepository: Repository, - ) { } - - /** - * Create a new segment - */ - async create(createSegmentDto: CreateSegmentDto): Promise { - const segment = this.segmentRepository.create({ - name: createSegmentDto.name, - description: createSegmentDto.description, - isDynamic: createSegmentDto.isDynamic ?? true, - }); - - const savedSegment = await this.segmentRepository.save(segment); - - // Create rules - if (createSegmentDto.rules?.length) { - const rules = createSegmentDto.rules.map((rule, index) => - this.ruleRepository.create({ - ...rule, - segmentId: savedSegment.id, - order: index, - }), - ); - await this.ruleRepository.save(rules); - } - - return this.findOne(savedSegment.id); + constructor( + @InjectRepository(Segment) + private readonly segmentRepository: Repository, + @InjectRepository(SegmentRule) + private readonly ruleRepository: Repository, + ) {} + + /** + * Create a new segment + */ + async create(createSegmentDto: CreateSegmentDto): Promise { + const segment = this.segmentRepository.create({ + name: createSegmentDto.name, + description: createSegmentDto.description, + isDynamic: createSegmentDto.isDynamic ?? true, + }); + + const savedSegment = await this.segmentRepository.save(segment); + + // Create rules + if (createSegmentDto.rules?.length) { + const rules = createSegmentDto.rules.map((rule, index) => + this.ruleRepository.create({ + ...rule, + segmentId: savedSegment.id, + order: index, + }), + ); + await this.ruleRepository.save(rules); } - /** - * Get all segments - */ - async findAll(page: number = 1, limit: number = 10): Promise<{ - segments: Segment[]; - total: number; - page: number; - totalPages: number; - }> { - const [segments, total] = await this.segmentRepository.findAndCount({ - skip: (page - 1) * limit, - take: limit, - order: { createdAt: 'DESC' }, - relations: ['rules'], - }); - - // Calculate member count for each segment - for (const segment of segments) { - segment.memberCount = await this.calculateMemberCount(segment.id); - } - - return { - segments, - total, - page, - totalPages: Math.ceil(total / limit), - }; + return this.findOne(savedSegment.id); + } + + /** + * Get all segments + */ + async findAll( + page: number = 1, + limit: number = 10, + ): Promise<{ + segments: Segment[]; + total: number; + page: number; + totalPages: number; + }> { + const [segments, total] = await this.segmentRepository.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + relations: ['rules'], + }); + + // Calculate member count for each segment + for (const segment of segments) { + segment.memberCount = await this.calculateMemberCount(segment.id); } - /** - * Get a single segment by ID - */ - async findOne(id: string): Promise { - const segment = await this.segmentRepository.findOne({ - where: { id }, - relations: ['rules'], - }); - - if (!segment) { - throw new NotFoundException(`Segment with ID ${id} not found`); - } - - // Calculate member count without recursive call - segment.memberCount = await this.calculateMemberCountForSegment(segment); - return segment; + return { + segments, + total, + page, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Get a single segment by ID + */ + async findOne(id: string): Promise { + const segment = await this.segmentRepository.findOne({ + where: { id }, + relations: ['rules'], + }); + + if (!segment) { + throw new NotFoundException(`Segment with ID ${id} not found`); } - /** - * Update a segment - */ - async update(id: string, updateSegmentDto: UpdateSegmentDto): Promise { - const segment = await this.findOne(id); - - Object.assign(segment, { - name: updateSegmentDto.name ?? segment.name, - description: updateSegmentDto.description ?? segment.description, - isDynamic: updateSegmentDto.isDynamic ?? segment.isDynamic, - }); - - await this.segmentRepository.save(segment); - - // Update rules if provided - if (updateSegmentDto.rules) { - await this.ruleRepository.delete({ segmentId: id }); - const rules = updateSegmentDto.rules.map((rule, index) => - this.ruleRepository.create({ - ...rule, - segmentId: id, - order: index, - }), - ); - await this.ruleRepository.save(rules); - } - - return this.findOne(id); + // Calculate member count without recursive call + segment.memberCount = await this.calculateMemberCountForSegment(segment); + return segment; + } + + /** + * Update a segment + */ + async update(id: string, updateSegmentDto: UpdateSegmentDto): Promise { + const segment = await this.findOne(id); + + Object.assign(segment, { + name: updateSegmentDto.name ?? segment.name, + description: updateSegmentDto.description ?? segment.description, + isDynamic: updateSegmentDto.isDynamic ?? segment.isDynamic, + }); + + await this.segmentRepository.save(segment); + + // Update rules if provided + if (updateSegmentDto.rules) { + await this.ruleRepository.delete({ segmentId: id }); + const rules = updateSegmentDto.rules.map((rule, index) => + this.ruleRepository.create({ + ...rule, + segmentId: id, + order: index, + }), + ); + await this.ruleRepository.save(rules); } - /** - * Delete a segment - */ - async remove(id: string): Promise { - const segment = await this.findOne(id); - await this.segmentRepository.remove(segment); + return this.findOne(id); + } + + /** + * Delete a segment + */ + async remove(id: string): Promise { + const segment = await this.findOne(id); + await this.segmentRepository.remove(segment); + } + + /** + * Get users from multiple segments + */ + async getUsersFromSegments(segmentIds: string[]): Promise { + if (!segmentIds.length) { + return []; } - /** - * Get users from multiple segments - */ - async getUsersFromSegments(segmentIds: string[]): Promise { - if (!segmentIds.length) { - return []; - } - - const segments = await this.segmentRepository.find({ - where: { id: In(segmentIds) }, - relations: ['rules'], - }); - - const userSets = await Promise.all( - segments.map((segment) => this.getSegmentMembers(segment)), - ); - - // Combine all users (union) - const userMap = new Map(); - for (const users of userSets) { - for (const user of users) { - userMap.set(user.id, user); - } - } - - return Array.from(userMap.values()); - } + const segments = await this.segmentRepository.find({ + where: { id: In(segmentIds) }, + relations: ['rules'], + }); - /** - * Get members of a specific segment - */ - async getSegmentMembers(segmentOrId: Segment | string): Promise { - let segment: Segment; - - if (typeof segmentOrId === 'string') { - // Direct query to avoid infinite recursion - const found = await this.segmentRepository.findOne({ - where: { id: segmentOrId }, - relations: ['rules'], - }); - if (!found) { - throw new NotFoundException(`Segment with ID ${segmentOrId} not found`); - } - segment = found; - } else { - segment = segmentOrId; - } - - if (!segment.isDynamic) { - // Static segment - return manually added members - return this.getStaticSegmentMembers(segment.id); - } - - // Dynamic segment - evaluate rules - return this.evaluateSegmentRules(segment.rules); - } + const userSets = await Promise.all(segments.map((segment) => this.getSegmentMembers(segment))); - /** - * Preview segment members without saving - */ - async previewSegment(rules: CreateSegmentDto['rules']): Promise<{ - count: number; - sample: UserProfile[]; - }> { - const ruleEntities = rules.map((rule, index) => - this.ruleRepository.create({ ...rule, order: index }), - ); - - const members = await this.evaluateSegmentRules(ruleEntities); - - return { - count: members.length, - sample: members.slice(0, 10), - }; + // Combine all users (union) + const userMap = new Map(); + for (const users of userSets) { + for (const user of users) { + userMap.set(user.id, user); + } } - /** - * Add users manually to a static segment - */ - async addUsersToSegment(segmentId: string, userIds: string[]): Promise { - const segment = await this.findOne(segmentId); - - if (segment.isDynamic) { - throw new BadRequestException('Cannot manually add users to a dynamic segment'); - } - - // Add to static member list - const currentMembers = segment.staticMemberIds || []; - const newMembers = [...new Set([...currentMembers, ...userIds])]; - - await this.segmentRepository.update(segmentId, { - staticMemberIds: newMembers, - }); + return Array.from(userMap.values()); + } + + /** + * Get members of a specific segment + */ + async getSegmentMembers(segmentOrId: Segment | string): Promise { + let segment: Segment; + + if (typeof segmentOrId === 'string') { + // Direct query to avoid infinite recursion + const found = await this.segmentRepository.findOne({ + where: { id: segmentOrId }, + relations: ['rules'], + }); + if (!found) { + throw new NotFoundException(`Segment with ID ${segmentOrId} not found`); + } + segment = found; + } else { + segment = segmentOrId; } - /** - * Remove users from a static segment - */ - async removeUsersFromSegment(segmentId: string, userIds: string[]): Promise { - const segment = await this.findOne(segmentId); - - if (segment.isDynamic) { - throw new BadRequestException('Cannot manually remove users from a dynamic segment'); - } - - const currentMembers = segment.staticMemberIds || []; - const newMembers = currentMembers.filter((id) => !userIds.includes(id)); - - await this.segmentRepository.update(segmentId, { - staticMemberIds: newMembers, - }); + if (!segment.isDynamic) { + // Static segment - return manually added members + return this.getStaticSegmentMembers(segment.id); } - /** - * Check if a user belongs to a segment - */ - async isUserInSegment(userId: string, segmentId: string): Promise { - const members = await this.getSegmentMembers(segmentId); - return members.some((member) => member.id === userId); + // Dynamic segment - evaluate rules + return this.evaluateSegmentRules(segment.rules); + } + + /** + * Preview segment members without saving + */ + async previewSegment(rules: CreateSegmentDto['rules']): Promise<{ + count: number; + sample: UserProfile[]; + }> { + const ruleEntities = rules.map((rule, index) => + this.ruleRepository.create({ ...rule, order: index }), + ); + + const members = await this.evaluateSegmentRules(ruleEntities); + + return { + count: members.length, + sample: members.slice(0, 10), + }; + } + + /** + * Add users manually to a static segment + */ + async addUsersToSegment(segmentId: string, userIds: string[]): Promise { + const segment = await this.findOne(segmentId); + + if (segment.isDynamic) { + throw new BadRequestException('Cannot manually add users to a dynamic segment'); } - /** - * Get all segments a user belongs to - */ - async getUserSegments(userId: string): Promise { - const allSegments = await this.segmentRepository.find({ - relations: ['rules'], - }); + // Add to static member list + const currentMembers = segment.staticMemberIds || []; + const newMembers = [...new Set([...currentMembers, ...userIds])]; - const userSegments: Segment[] = []; + await this.segmentRepository.update(segmentId, { + staticMemberIds: newMembers, + }); + } - for (const segment of allSegments) { - if (await this.isUserInSegment(userId, segment.id)) { - userSegments.push(segment); - } - } + /** + * Remove users from a static segment + */ + async removeUsersFromSegment(segmentId: string, userIds: string[]): Promise { + const segment = await this.findOne(segmentId); - return userSegments; + if (segment.isDynamic) { + throw new BadRequestException('Cannot manually remove users from a dynamic segment'); } - // ==================== Private Helper Methods ==================== - - /** - * Calculate member count for a segment (by ID - may cause recursion, use carefully) - */ - private async calculateMemberCount(segmentId: string): Promise { - const members = await this.getSegmentMembers(segmentId); - return members.length; + const currentMembers = segment.staticMemberIds || []; + const newMembers = currentMembers.filter((id) => !userIds.includes(id)); + + await this.segmentRepository.update(segmentId, { + staticMemberIds: newMembers, + }); + } + + /** + * Check if a user belongs to a segment + */ + async isUserInSegment(userId: string, segmentId: string): Promise { + const members = await this.getSegmentMembers(segmentId); + return members.some((member) => member.id === userId); + } + + /** + * Get all segments a user belongs to + */ + async getUserSegments(userId: string): Promise { + const allSegments = await this.segmentRepository.find({ + relations: ['rules'], + }); + + const userSegments: Segment[] = []; + + for (const segment of allSegments) { + if (await this.isUserInSegment(userId, segment.id)) { + userSegments.push(segment); + } } - /** - * Calculate member count for a segment (accepts segment object to avoid recursion) - */ - private async calculateMemberCountForSegment(segment: Segment): Promise { - if (!segment.isDynamic) { - return segment.staticMemberIds?.length || 0; - } - const members = await this.evaluateSegmentRules(segment.rules); - return members.length; + return userSegments; + } + + // ==================== Private Helper Methods ==================== + + /** + * Calculate member count for a segment (by ID - may cause recursion, use carefully) + */ + private async calculateMemberCount(segmentId: string): Promise { + const members = await this.getSegmentMembers(segmentId); + return members.length; + } + + /** + * Calculate member count for a segment (accepts segment object to avoid recursion) + */ + private async calculateMemberCountForSegment(segment: Segment): Promise { + if (!segment.isDynamic) { + return segment.staticMemberIds?.length || 0; + } + const members = await this.evaluateSegmentRules(segment.rules); + return members.length; + } + + /** + * Get members of a static segment + */ + private async getStaticSegmentMembers(segmentId: string): Promise { + const segment = await this.segmentRepository.findOne({ + where: { id: segmentId }, + }); + + if (!segment?.staticMemberIds?.length) { + return []; } - /** - * Get members of a static segment - */ - private async getStaticSegmentMembers(segmentId: string): Promise { - const segment = await this.segmentRepository.findOne({ - where: { id: segmentId }, - }); - - if (!segment?.staticMemberIds?.length) { - return []; - } - - // TODO: Fetch actual user data from Users module - // For now, return mock data - return segment.staticMemberIds.map((id) => ({ - id, - email: `user-${id}@example.com`, - createdAt: new Date(), - })); + // TODO: Fetch actual user data from Users module + // For now, return mock data + return segment.staticMemberIds.map((id) => ({ + id, + email: `user-${id}@example.com`, + createdAt: new Date(), + })); + } + + /** + * Evaluate segment rules and return matching users + */ + private async evaluateSegmentRules(rules: SegmentRule[]): Promise { + if (!rules.length) { + return []; } - /** - * Evaluate segment rules and return matching users - */ - private async evaluateSegmentRules(rules: SegmentRule[]): Promise { - if (!rules.length) { - return []; - } + // TODO: Build actual query against User entity + // This is a placeholder implementation showing the query building logic - // TODO: Build actual query against User entity - // This is a placeholder implementation showing the query building logic + // Group rules by AND/OR logic + const sortedRules = [...rules].sort((a, b) => a.order - b.order); - // Group rules by AND/OR logic - const sortedRules = [...rules].sort((a, b) => a.order - b.order); + // Build query conditions + const conditions: string[] = []; - // Build query conditions - const conditions: string[] = []; + for (const rule of sortedRules) { + const condition = this.buildRuleCondition(rule); + if (condition) { + conditions.push(condition); + } + } - for (const rule of sortedRules) { - const condition = this.buildRuleCondition(rule); - if (condition) { - conditions.push(condition); - } - } + // For MVP, return empty array - actual implementation requires User repository + console.log('Segment rules would evaluate:', conditions); - // For MVP, return empty array - actual implementation requires User repository - console.log('Segment rules would evaluate:', conditions); + // TODO: Execute query and return real users + return []; + } - // TODO: Execute query and return real users - return []; - } + /** + * Build SQL condition from a rule + */ + private buildRuleCondition(rule: SegmentRule): string | null { + const field = this.getFieldMapping(rule.field); + const operator = this.getOperatorMapping(rule.operator); + const value = this.formatValue(rule.value, rule.operator); - /** - * Build SQL condition from a rule - */ - private buildRuleCondition(rule: SegmentRule): string | null { - const field = this.getFieldMapping(rule.field); - const operator = this.getOperatorMapping(rule.operator); - const value = this.formatValue(rule.value, rule.operator); + if (!field || !operator) { + return null; + } - if (!field || !operator) { - return null; - } + return `${field} ${operator} ${value}`; + } + + /** + * Map rule field to database column + */ + private getFieldMapping(field: SegmentRuleField): string | null { + const mapping: Record = { + [SegmentRuleField.EMAIL]: 'user.email', + [SegmentRuleField.FIRST_NAME]: 'user.firstName', + [SegmentRuleField.LAST_NAME]: 'user.lastName', + [SegmentRuleField.CREATED_AT]: 'user.createdAt', + [SegmentRuleField.LAST_LOGIN]: 'user.lastLoginAt', + [SegmentRuleField.COURSE_COUNT]: 'enrollments.count', + [SegmentRuleField.TOTAL_SPENT]: 'payments.total', + [SegmentRuleField.TAG]: 'user.tags', + [SegmentRuleField.COUNTRY]: 'user.country', + [SegmentRuleField.SUBSCRIPTION_STATUS]: 'subscription.status', + }; + + return mapping[field] || null; + } + + /** + * Map rule operator to SQL operator + */ + private getOperatorMapping(operator: SegmentRuleOperator): string | null { + const mapping: Record = { + [SegmentRuleOperator.EQUALS]: '=', + [SegmentRuleOperator.NOT_EQUALS]: '!=', + [SegmentRuleOperator.CONTAINS]: 'LIKE', + [SegmentRuleOperator.NOT_CONTAINS]: 'NOT LIKE', + [SegmentRuleOperator.STARTS_WITH]: 'LIKE', + [SegmentRuleOperator.ENDS_WITH]: 'LIKE', + [SegmentRuleOperator.GREATER_THAN]: '>', + [SegmentRuleOperator.LESS_THAN]: '<', + [SegmentRuleOperator.GREATER_OR_EQUAL]: '>=', + [SegmentRuleOperator.LESS_OR_EQUAL]: '<=', + [SegmentRuleOperator.IS_SET]: 'IS NOT NULL', + [SegmentRuleOperator.IS_NOT_SET]: 'IS NULL', + [SegmentRuleOperator.IN_LIST]: 'IN', + [SegmentRuleOperator.NOT_IN_LIST]: 'NOT IN', + [SegmentRuleOperator.BEFORE]: '<', + [SegmentRuleOperator.AFTER]: '>', + [SegmentRuleOperator.BETWEEN]: 'BETWEEN', + }; + + return mapping[operator] || null; + } + + /** + * Format value based on operator + */ + private formatValue(value: any, operator: SegmentRuleOperator): string { + if ( + operator === SegmentRuleOperator.CONTAINS || + operator === SegmentRuleOperator.NOT_CONTAINS + ) { + return `'%${value}%'`; + } - return `${field} ${operator} ${value}`; + if (operator === SegmentRuleOperator.STARTS_WITH) { + return `'${value}%'`; } - /** - * Map rule field to database column - */ - private getFieldMapping(field: SegmentRuleField): string | null { - const mapping: Record = { - [SegmentRuleField.EMAIL]: 'user.email', - [SegmentRuleField.FIRST_NAME]: 'user.firstName', - [SegmentRuleField.LAST_NAME]: 'user.lastName', - [SegmentRuleField.CREATED_AT]: 'user.createdAt', - [SegmentRuleField.LAST_LOGIN]: 'user.lastLoginAt', - [SegmentRuleField.COURSE_COUNT]: 'enrollments.count', - [SegmentRuleField.TOTAL_SPENT]: 'payments.total', - [SegmentRuleField.TAG]: 'user.tags', - [SegmentRuleField.COUNTRY]: 'user.country', - [SegmentRuleField.SUBSCRIPTION_STATUS]: 'subscription.status', - }; - - return mapping[field] || null; + if (operator === SegmentRuleOperator.ENDS_WITH) { + return `'%${value}'`; } - /** - * Map rule operator to SQL operator - */ - private getOperatorMapping(operator: SegmentRuleOperator): string | null { - const mapping: Record = { - [SegmentRuleOperator.EQUALS]: '=', - [SegmentRuleOperator.NOT_EQUALS]: '!=', - [SegmentRuleOperator.CONTAINS]: 'LIKE', - [SegmentRuleOperator.NOT_CONTAINS]: 'NOT LIKE', - [SegmentRuleOperator.STARTS_WITH]: 'LIKE', - [SegmentRuleOperator.ENDS_WITH]: 'LIKE', - [SegmentRuleOperator.GREATER_THAN]: '>', - [SegmentRuleOperator.LESS_THAN]: '<', - [SegmentRuleOperator.GREATER_OR_EQUAL]: '>=', - [SegmentRuleOperator.LESS_OR_EQUAL]: '<=', - [SegmentRuleOperator.IS_SET]: 'IS NOT NULL', - [SegmentRuleOperator.IS_NOT_SET]: 'IS NULL', - [SegmentRuleOperator.IN_LIST]: 'IN', - [SegmentRuleOperator.NOT_IN_LIST]: 'NOT IN', - [SegmentRuleOperator.BEFORE]: '<', - [SegmentRuleOperator.AFTER]: '>', - [SegmentRuleOperator.BETWEEN]: 'BETWEEN', - }; - - return mapping[operator] || null; + if (operator === SegmentRuleOperator.IN_LIST || operator === SegmentRuleOperator.NOT_IN_LIST) { + if (Array.isArray(value)) { + return `(${value.map((v) => `'${v}'`).join(', ')})`; + } } - /** - * Format value based on operator - */ - private formatValue(value: any, operator: SegmentRuleOperator): string { - if (operator === SegmentRuleOperator.CONTAINS || operator === SegmentRuleOperator.NOT_CONTAINS) { - return `'%${value}%'`; - } - - if (operator === SegmentRuleOperator.STARTS_WITH) { - return `'${value}%'`; - } - - if (operator === SegmentRuleOperator.ENDS_WITH) { - return `'%${value}'`; - } - - if (operator === SegmentRuleOperator.IN_LIST || operator === SegmentRuleOperator.NOT_IN_LIST) { - if (Array.isArray(value)) { - return `(${value.map((v) => `'${v}'`).join(', ')})`; - } - } - - if (typeof value === 'string') { - return `'${value}'`; - } - - return String(value); + if (typeof value === 'string') { + return `'${value}'`; } + + return String(value); + } } diff --git a/src/email-marketing/sender/email-sender.service.ts b/src/email-marketing/sender/email-sender.service.ts index 1070869..9a32c30 100644 --- a/src/email-marketing/sender/email-sender.service.ts +++ b/src/email-marketing/sender/email-sender.service.ts @@ -7,189 +7,189 @@ import { EmailAnalyticsService } from '../analytics/email-analytics.service'; import { EmailEventType } from '../enums/email-event-type.enum'; export interface SendEmailOptions { - to: string; - subject?: string; - templateId?: string; - html?: string; - text?: string; - variables?: Record; - campaignId?: string; - recipientId?: string; - variantId?: string; - trackOpens?: boolean; - trackClicks?: boolean; + to: string; + subject?: string; + templateId?: string; + html?: string; + text?: string; + variables?: Record; + campaignId?: string; + recipientId?: string; + variantId?: string; + trackOpens?: boolean; + trackClicks?: boolean; } export interface SendEmailResult { - success: boolean; - messageId?: string; - error?: string; + success: boolean; + messageId?: string; + error?: string; } @Injectable() export class EmailSenderService { - private readonly logger = new Logger(EmailSenderService.name); - private transporter: nodemailer.Transporter; - - constructor( - private readonly configService: ConfigService, - private readonly templateService: TemplateManagementService, - private readonly analyticsService: EmailAnalyticsService, - ) { - this.initializeTransporter(); - } + private readonly logger = new Logger(EmailSenderService.name); + private transporter: nodemailer.Transporter; + + constructor( + private readonly configService: ConfigService, + private readonly templateService: TemplateManagementService, + private readonly analyticsService: EmailAnalyticsService, + ) { + this.initializeTransporter(); + } + + private initializeTransporter(): void { + this.transporter = nodemailer.createTransport({ + host: this.configService.get('SMTP_HOST', 'smtp.mailtrap.io'), + port: this.configService.get('SMTP_PORT', 587), + secure: this.configService.get('SMTP_SECURE', false), + auth: { + user: this.configService.get('SMTP_USER'), + pass: this.configService.get('SMTP_PASS'), + }, + }); + } + + /** + * Send a single email + */ + async sendEmail(options: SendEmailOptions): Promise { + try { + let html = options.html; + let text = options.text; + let subject = options.subject; + + // Render template if provided + if (options.templateId) { + const rendered = await this.templateService.renderTemplate( + options.templateId, + options.variables || {}, + ); + html = rendered.html; + text = rendered.text; + subject = rendered.subject; + } + + // Add tracking pixel for opens + if (options.trackOpens && options.campaignId && options.recipientId) { + html = this.addOpenTrackingPixel(html, options.campaignId, options.recipientId); + } + + // Track link clicks + if (options.trackClicks && options.campaignId && options.recipientId) { + html = this.wrapLinksForTracking(html, options.campaignId, options.recipientId); + } + + const fromEmail = this.configService.get('EMAIL_FROM', 'noreply@teachlink.io'); + const fromName = this.configService.get('EMAIL_FROM_NAME', 'TeachLink'); + + const result = await this.transporter.sendMail({ + from: `"${fromName}" <${fromEmail}>`, + to: options.to, + subject, + html, + text, + }); + + // Record sent event + if (options.campaignId && options.recipientId) { + await this.analyticsService.recordEvent( + options.campaignId, + options.recipientId, + EmailEventType.SENT, + { messageId: result.messageId, variantId: options.variantId }, + ); + } - private initializeTransporter(): void { - this.transporter = nodemailer.createTransport({ - host: this.configService.get('SMTP_HOST', 'smtp.mailtrap.io'), - port: this.configService.get('SMTP_PORT', 587), - secure: this.configService.get('SMTP_SECURE', false), - auth: { - user: this.configService.get('SMTP_USER'), - pass: this.configService.get('SMTP_PASS'), - }, - }); - } + this.logger.log(`Email sent to ${options.to}, messageId: ${result.messageId}`); - /** - * Send a single email - */ - async sendEmail(options: SendEmailOptions): Promise { - try { - let html = options.html; - let text = options.text; - let subject = options.subject; - - // Render template if provided - if (options.templateId) { - const rendered = await this.templateService.renderTemplate( - options.templateId, - options.variables || {}, - ); - html = rendered.html; - text = rendered.text; - subject = rendered.subject; - } - - // Add tracking pixel for opens - if (options.trackOpens && options.campaignId && options.recipientId) { - html = this.addOpenTrackingPixel(html, options.campaignId, options.recipientId); - } - - // Track link clicks - if (options.trackClicks && options.campaignId && options.recipientId) { - html = this.wrapLinksForTracking(html, options.campaignId, options.recipientId); - } - - const fromEmail = this.configService.get('EMAIL_FROM', 'noreply@teachlink.io'); - const fromName = this.configService.get('EMAIL_FROM_NAME', 'TeachLink'); - - const result = await this.transporter.sendMail({ - from: `"${fromName}" <${fromEmail}>`, - to: options.to, - subject, - html, - text, - }); - - // Record sent event - if (options.campaignId && options.recipientId) { - await this.analyticsService.recordEvent( - options.campaignId, - options.recipientId, - EmailEventType.SENT, - { messageId: result.messageId, variantId: options.variantId }, - ); - } - - this.logger.log(`Email sent to ${options.to}, messageId: ${result.messageId}`); - - return { success: true, messageId: result.messageId }; - } catch (error) { - this.logger.error(`Failed to send email to ${options.to}:`, error); - - // Record bounce event - if (options.campaignId && options.recipientId) { - await this.analyticsService.recordEvent( - options.campaignId, - options.recipientId, - EmailEventType.BOUNCED, - { error: error.message }, - ); - } - - return { success: false, error: error.message }; - } - } + return { success: true, messageId: result.messageId }; + } catch (error) { + this.logger.error(`Failed to send email to ${options.to}:`, error); - /** - * Send bulk emails - */ - async sendBulkEmails( - recipients: Array<{ email: string; id: string; variables?: Record }>, - options: Omit, - ): Promise<{ sent: number; failed: number; results: SendEmailResult[] }> { - const results: SendEmailResult[] = []; - let sent = 0; - let failed = 0; - - for (const recipient of recipients) { - const result = await this.sendEmail({ - ...options, - to: recipient.email, - recipientId: recipient.id, - variables: { ...options.variables, ...recipient.variables }, - }); - - results.push(result); - if (result.success) sent++; - else failed++; - - // Small delay to avoid rate limiting - await this.delay(50); - } + // Record bounce event + if (options.campaignId && options.recipientId) { + await this.analyticsService.recordEvent( + options.campaignId, + options.recipientId, + EmailEventType.BOUNCED, + { error: error.message }, + ); + } - return { sent, failed, results }; + return { success: false, error: error.message }; + } + } + + /** + * Send bulk emails + */ + async sendBulkEmails( + recipients: Array<{ email: string; id: string; variables?: Record }>, + options: Omit, + ): Promise<{ sent: number; failed: number; results: SendEmailResult[] }> { + const results: SendEmailResult[] = []; + let sent = 0; + let failed = 0; + + for (const recipient of recipients) { + const result = await this.sendEmail({ + ...options, + to: recipient.email, + recipientId: recipient.id, + variables: { ...options.variables, ...recipient.variables }, + }); + + results.push(result); + if (result.success) sent++; + else failed++; + + // Small delay to avoid rate limiting + await this.delay(50); } - /** - * Verify SMTP connection - */ - async verifyConnection(): Promise { - try { - await this.transporter.verify(); - return true; - } catch (error) { - this.logger.error('SMTP connection verification failed:', error); - return false; - } + return { sent, failed, results }; + } + + /** + * Verify SMTP connection + */ + async verifyConnection(): Promise { + try { + await this.transporter.verify(); + return true; + } catch (error) { + this.logger.error('SMTP connection verification failed:', error); + return false; } + } - // Private helper methods - private addOpenTrackingPixel(html: string, campaignId: string, recipientId: string): string { - const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - const trackingUrl = `${baseUrl}/email-marketing/track/open?c=${campaignId}&r=${recipientId}`; - const pixel = ``; + // Private helper methods + private addOpenTrackingPixel(html: string, campaignId: string, recipientId: string): string { + const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); + const trackingUrl = `${baseUrl}/email-marketing/track/open?c=${campaignId}&r=${recipientId}`; + const pixel = ``; - return html.replace('', `${pixel}`); - } + return html.replace('', `${pixel}`); + } - private wrapLinksForTracking(html: string, campaignId: string, recipientId: string): string { - const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - - return html.replace( - /]*href=["'])([^"']+)(["'][^>]*)>/gi, - (match, prefix, url, suffix) => { - if (url.startsWith('mailto:') || url.startsWith('#')) { - return match; - } - const trackingUrl = `${baseUrl}/email-marketing/track/click?c=${campaignId}&r=${recipientId}&url=${encodeURIComponent(url)}`; - return ``; - }, - ); - } + private wrapLinksForTracking(html: string, campaignId: string, recipientId: string): string { + const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } + return html.replace( + /]*href=["'])([^"']+)(["'][^>]*)>/gi, + (match, prefix, url, suffix) => { + if (url.startsWith('mailto:') || url.startsWith('#')) { + return match; + } + const trackingUrl = `${baseUrl}/email-marketing/track/click?c=${campaignId}&r=${recipientId}&url=${encodeURIComponent(url)}`; + return ``; + }, + ); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } } diff --git a/src/email-marketing/templates/template-management.service.ts b/src/email-marketing/templates/template-management.service.ts index 03c569a..f9e649c 100644 --- a/src/email-marketing/templates/template-management.service.ts +++ b/src/email-marketing/templates/template-management.service.ts @@ -9,185 +9,189 @@ import { UpdateTemplateDto } from '../dto/update-template.dto'; @Injectable() export class TemplateManagementService { - constructor( - @InjectRepository(EmailTemplate) - private readonly templateRepository: Repository, - ) { - this.registerHandlebarsHelpers(); + constructor( + @InjectRepository(EmailTemplate) + private readonly templateRepository: Repository, + ) { + this.registerHandlebarsHelpers(); + } + + /** + * Create a new email template + */ + async create(createTemplateDto: CreateTemplateDto): Promise { + // Validate template syntax + this.validateTemplateSyntax(createTemplateDto.htmlContent); + if (createTemplateDto.textContent) { + this.validateTemplateSyntax(createTemplateDto.textContent); } - /** - * Create a new email template - */ - async create(createTemplateDto: CreateTemplateDto): Promise { - // Validate template syntax - this.validateTemplateSyntax(createTemplateDto.htmlContent); - if (createTemplateDto.textContent) { - this.validateTemplateSyntax(createTemplateDto.textContent); - } - - const template = this.templateRepository.create({ - ...createTemplateDto, - variables: this.extractVariables(createTemplateDto.htmlContent), - }); - - return this.templateRepository.save(template); + const template = this.templateRepository.create({ + ...createTemplateDto, + variables: this.extractVariables(createTemplateDto.htmlContent), + }); + + return this.templateRepository.save(template); + } + + /** + * Get all templates + */ + async findAll( + page = 1, + limit = 10, + ): Promise<{ + templates: EmailTemplate[]; + total: number; + page: number; + totalPages: number; + }> { + const [templates, total] = await this.templateRepository.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { templates, total, page, totalPages: Math.ceil(total / limit) }; + } + + /** + * Get a single template by ID + */ + async findOne(id: string): Promise { + const template = await this.templateRepository.findOne({ where: { id } }); + if (!template) { + throw new NotFoundException(`Template with ID ${id} not found`); } - - /** - * Get all templates - */ - async findAll(page = 1, limit = 10): Promise<{ - templates: EmailTemplate[]; - total: number; - page: number; - totalPages: number; - }> { - const [templates, total] = await this.templateRepository.findAndCount({ - skip: (page - 1) * limit, - take: limit, - order: { createdAt: 'DESC' }, - }); - - return { templates, total, page, totalPages: Math.ceil(total / limit) }; - } - - /** - * Get a single template by ID - */ - async findOne(id: string): Promise { - const template = await this.templateRepository.findOne({ where: { id } }); - if (!template) { - throw new NotFoundException(`Template with ID ${id} not found`); - } - return template; - } - - /** - * Update a template - */ - async update(id: string, updateTemplateDto: UpdateTemplateDto): Promise { - const template = await this.findOne(id); - - if (updateTemplateDto.htmlContent) { - this.validateTemplateSyntax(updateTemplateDto.htmlContent); - updateTemplateDto['variables'] = this.extractVariables(updateTemplateDto.htmlContent); - } - - Object.assign(template, updateTemplateDto); - return this.templateRepository.save(template); - } - - /** - * Delete a template - */ - async remove(id: string): Promise { - const template = await this.findOne(id); - await this.templateRepository.remove(template); + return template; + } + + /** + * Update a template + */ + async update(id: string, updateTemplateDto: UpdateTemplateDto): Promise { + const template = await this.findOne(id); + + if (updateTemplateDto.htmlContent) { + this.validateTemplateSyntax(updateTemplateDto.htmlContent); + updateTemplateDto['variables'] = this.extractVariables(updateTemplateDto.htmlContent); } - /** - * Duplicate a template - */ - async duplicate(id: string): Promise { - const original = await this.findOne(id); - - const duplicate = this.templateRepository.create({ - name: `${original.name} (Copy)`, - subject: original.subject, - htmlContent: original.htmlContent, - textContent: original.textContent, - category: original.category, - variables: original.variables, - }); - - return this.templateRepository.save(duplicate); + Object.assign(template, updateTemplateDto); + return this.templateRepository.save(template); + } + + /** + * Delete a template + */ + async remove(id: string): Promise { + const template = await this.findOne(id); + await this.templateRepository.remove(template); + } + + /** + * Duplicate a template + */ + async duplicate(id: string): Promise { + const original = await this.findOne(id); + + const duplicate = this.templateRepository.create({ + name: `${original.name} (Copy)`, + subject: original.subject, + htmlContent: original.htmlContent, + textContent: original.textContent, + category: original.category, + variables: original.variables, + }); + + return this.templateRepository.save(duplicate); + } + + /** + * Render a template with variables + */ + async renderTemplate( + templateId: string, + variables: Record, + ): Promise<{ html: string; text: string; subject: string }> { + const template = await this.findOne(templateId); + + const htmlTemplate = Handlebars.compile(template.htmlContent); + const subjectTemplate = Handlebars.compile(template.subject); + const textTemplate = template.textContent ? Handlebars.compile(template.textContent) : null; + + return { + html: htmlTemplate(variables), + text: textTemplate ? textTemplate(variables) : this.stripHtml(htmlTemplate(variables)), + subject: subjectTemplate(variables), + }; + } + + /** + * Preview a template with sample data + */ + async previewTemplate(id: string, sampleData?: Record) { + const template = await this.findOne(id); + + const defaultData = this.generateSampleData(template.variables || []); + const data = { ...defaultData, ...sampleData }; + + return this.renderTemplate(id, data); + } + + // Private helper methods + private validateTemplateSyntax(content: string): void { + try { + Handlebars.compile(content); + } catch (error) { + throw new BadRequestException(`Invalid template syntax: ${error.message}`); } - - /** - * Render a template with variables - */ - async renderTemplate( - templateId: string, - variables: Record, - ): Promise<{ html: string; text: string; subject: string }> { - const template = await this.findOne(templateId); - - const htmlTemplate = Handlebars.compile(template.htmlContent); - const subjectTemplate = Handlebars.compile(template.subject); - const textTemplate = template.textContent - ? Handlebars.compile(template.textContent) - : null; - - return { - html: htmlTemplate(variables), - text: textTemplate ? textTemplate(variables) : this.stripHtml(htmlTemplate(variables)), - subject: subjectTemplate(variables), - }; + } + + private extractVariables(content: string): string[] { + const regex = /\{\{([^}]+)\}\}/g; + const variables = new Set(); + let match; + + while ((match = regex.exec(content)) !== null) { + const variable = match[1].trim().split(' ')[0]; + if (!variable.startsWith('#') && !variable.startsWith('/')) { + variables.add(variable); + } } - /** - * Preview a template with sample data - */ - async previewTemplate(id: string, sampleData?: Record) { - const template = await this.findOne(id); - - const defaultData = this.generateSampleData(template.variables || []); - const data = { ...defaultData, ...sampleData }; + return Array.from(variables); + } - return this.renderTemplate(id, data); - } + private generateSampleData(variables: string[]): Record { + const sampleData: Record = {}; - // Private helper methods - private validateTemplateSyntax(content: string): void { - try { - Handlebars.compile(content); - } catch (error) { - throw new BadRequestException(`Invalid template syntax: ${error.message}`); - } + for (const variable of variables) { + if (variable.includes('name')) sampleData[variable] = 'John Doe'; + else if (variable.includes('email')) sampleData[variable] = 'john@example.com'; + else if (variable.includes('url') || variable.includes('link')) { + sampleData[variable] = 'https://example.com'; + } else { + sampleData[variable] = `[${variable}]`; + } } - private extractVariables(content: string): string[] { - const regex = /\{\{([^}]+)\}\}/g; - const variables = new Set(); - let match; - - while ((match = regex.exec(content)) !== null) { - const variable = match[1].trim().split(' ')[0]; - if (!variable.startsWith('#') && !variable.startsWith('/')) { - variables.add(variable); - } - } - - return Array.from(variables); - } - - private generateSampleData(variables: string[]): Record { - const sampleData: Record = {}; - - for (const variable of variables) { - if (variable.includes('name')) sampleData[variable] = 'John Doe'; - else if (variable.includes('email')) sampleData[variable] = 'john@example.com'; - else if (variable.includes('url') || variable.includes('link')) { - sampleData[variable] = 'https://example.com'; - } else { - sampleData[variable] = `[${variable}]`; - } - } - - return sampleData; - } - - private stripHtml(html: string): string { - return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); - } - - private registerHandlebarsHelpers(): void { - Handlebars.registerHelper('uppercase', (str) => str?.toUpperCase()); - Handlebars.registerHelper('lowercase', (str) => str?.toLowerCase()); - Handlebars.registerHelper('formatDate', (date) => new Date(date).toLocaleDateString()); - Handlebars.registerHelper('ifEquals', function (a, b, options) { - return a === b ? options.fn(this) : options.inverse(this); - }); - } + return sampleData; + } + + private stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + + private registerHandlebarsHelpers(): void { + Handlebars.registerHelper('uppercase', (str) => str?.toUpperCase()); + Handlebars.registerHelper('lowercase', (str) => str?.toLowerCase()); + Handlebars.registerHelper('formatDate', (date) => new Date(date).toLocaleDateString()); + Handlebars.registerHelper('ifEquals', function (a, b, options) { + return a === b ? options.fn(this) : options.inverse(this); + }); + } } diff --git a/src/email-marketing/templates/template.controller.ts b/src/email-marketing/templates/template.controller.ts index 8cd3b83..6e17894 100644 --- a/src/email-marketing/templates/template.controller.ts +++ b/src/email-marketing/templates/template.controller.ts @@ -1,5 +1,15 @@ import { - Controller, Get, Post, Put, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; @@ -12,67 +22,61 @@ import { EmailTemplate } from '../entities/email-template.entity'; @ApiBearerAuth() @Controller('email-marketing/templates') export class TemplateController { - constructor(private readonly templateService: TemplateManagementService) { } + constructor(private readonly templateService: TemplateManagementService) {} - @Post() - @ApiOperation({ summary: 'Create a new email template' }) - @ApiResponse({ status: 201, description: 'Template created successfully' }) - async create(@Body() createTemplateDto: CreateTemplateDto): Promise { - return this.templateService.create(createTemplateDto); - } + @Post() + @ApiOperation({ summary: 'Create a new email template' }) + @ApiResponse({ status: 201, description: 'Template created successfully' }) + async create(@Body() createTemplateDto: CreateTemplateDto): Promise { + return this.templateService.create(createTemplateDto); + } - @Get() - @ApiOperation({ summary: 'Get all email templates' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - async findAll(@Query('page') page = 1, @Query('limit') limit = 10) { - return this.templateService.findAll(page, limit); - } + @Get() + @ApiOperation({ summary: 'Get all email templates' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findAll(@Query('page') page = 1, @Query('limit') limit = 10) { + return this.templateService.findAll(page, limit); + } - @Get(':id') - @ApiOperation({ summary: 'Get a template by ID' }) - @ApiResponse({ status: 404, description: 'Template not found' }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.templateService.findOne(id); - } + @Get(':id') + @ApiOperation({ summary: 'Get a template by ID' }) + @ApiResponse({ status: 404, description: 'Template not found' }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.templateService.findOne(id); + } - @Put(':id') - @ApiOperation({ summary: 'Update a template' }) - async update( - @Param('id', ParseUUIDPipe) id: string, - @Body() updateTemplateDto: UpdateTemplateDto, - ): Promise { - return this.templateService.update(id, updateTemplateDto); - } + @Put(':id') + @ApiOperation({ summary: 'Update a template' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateTemplateDto: UpdateTemplateDto, + ): Promise { + return this.templateService.update(id, updateTemplateDto); + } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete a template' }) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.templateService.remove(id); - } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a template' }) + async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.templateService.remove(id); + } - @Post(':id/duplicate') - @ApiOperation({ summary: 'Duplicate a template' }) - async duplicate(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.templateService.duplicate(id); - } + @Post(':id/duplicate') + @ApiOperation({ summary: 'Duplicate a template' }) + async duplicate(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.templateService.duplicate(id); + } - @Post(':id/preview') - @ApiOperation({ summary: 'Preview a template with sample data' }) - async preview( - @Param('id', ParseUUIDPipe) id: string, - @Body() sampleData?: Record, - ) { - return this.templateService.previewTemplate(id, sampleData); - } + @Post(':id/preview') + @ApiOperation({ summary: 'Preview a template with sample data' }) + async preview(@Param('id', ParseUUIDPipe) id: string, @Body() sampleData?: Record) { + return this.templateService.previewTemplate(id, sampleData); + } - @Post(':id/render') - @ApiOperation({ summary: 'Render a template with provided variables' }) - async render( - @Param('id', ParseUUIDPipe) id: string, - @Body() variables: Record, - ) { - return this.templateService.renderTemplate(id, variables); - } + @Post(':id/render') + @ApiOperation({ summary: 'Render a template with provided variables' }) + async render(@Param('id', ParseUUIDPipe) id: string, @Body() variables: Record) { + return this.templateService.renderTemplate(id, variables); + } } diff --git a/src/email-marketing/tracking/tracking.controller.ts b/src/email-marketing/tracking/tracking.controller.ts index 51aaf65..047c419 100644 --- a/src/email-marketing/tracking/tracking.controller.ts +++ b/src/email-marketing/tracking/tracking.controller.ts @@ -12,157 +12,143 @@ import { EmailEventType } from '../enums/email-event-type.enum'; @ApiTags('Email Marketing - Tracking') @Controller('email-marketing/track') export class TrackingController { - // 1x1 transparent GIF pixel - private readonly TRACKING_PIXEL = Buffer.from( - 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', - 'base64', - ); - - constructor(private readonly analyticsService: EmailAnalyticsService) { } - - /** - * Track email open via 1x1 tracking pixel - * Called when email client loads the tracking image - */ - @Get('open') - @ApiExcludeEndpoint() // Hide from Swagger as it's for internal use - async trackOpen( - @Query('c') campaignId: string, - @Query('r') recipientId: string, - @Res() res: Response, - ): Promise { - // Record the open event asynchronously - if (campaignId && recipientId) { - this.analyticsService.recordEvent( - campaignId, - recipientId, - EmailEventType.OPENED, - { timestamp: new Date().toISOString() }, - ).catch((error) => { - console.error('Failed to record open event:', error); - }); - } - - // Return the tracking pixel - res.set({ - 'Content-Type': 'image/gif', - 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', + // 1x1 transparent GIF pixel + private readonly TRACKING_PIXEL = Buffer.from( + 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + 'base64', + ); + + constructor(private readonly analyticsService: EmailAnalyticsService) {} + + /** + * Track email open via 1x1 tracking pixel + * Called when email client loads the tracking image + */ + @Get('open') + @ApiExcludeEndpoint() // Hide from Swagger as it's for internal use + async trackOpen( + @Query('c') campaignId: string, + @Query('r') recipientId: string, + @Res() res: Response, + ): Promise { + // Record the open event asynchronously + if (campaignId && recipientId) { + this.analyticsService + .recordEvent(campaignId, recipientId, EmailEventType.OPENED, { + timestamp: new Date().toISOString(), + }) + .catch((error) => { + console.error('Failed to record open event:', error); }); - res.send(this.TRACKING_PIXEL); } - /** - * Track link click and redirect to original URL - * Called when user clicks a tracked link in the email - */ - @Get('click') - @ApiOperation({ summary: 'Track email link click and redirect' }) - async trackClick( - @Query('c') campaignId: string, - @Query('r') recipientId: string, - @Query('url') url: string, - @Res() res: Response, - ): Promise { - // Validate URL to prevent open redirect vulnerability - const decodedUrl = decodeURIComponent(url || ''); - - if (!this.isValidRedirectUrl(decodedUrl)) { - res.status(400).send('Invalid URL'); - return; - } - - // Record the click event asynchronously - if (campaignId && recipientId) { - this.analyticsService.recordEvent( - campaignId, - recipientId, - EmailEventType.CLICKED, - { - url: decodedUrl, - timestamp: new Date().toISOString(), - }, - ).catch((error) => { - console.error('Failed to record click event:', error); - }); - } - - // Redirect to the original URL - res.redirect(302, decodedUrl); + // Return the tracking pixel + res.set({ + 'Content-Type': 'image/gif', + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + Pragma: 'no-cache', + Expires: '0', + }); + res.send(this.TRACKING_PIXEL); + } + + /** + * Track link click and redirect to original URL + * Called when user clicks a tracked link in the email + */ + @Get('click') + @ApiOperation({ summary: 'Track email link click and redirect' }) + async trackClick( + @Query('c') campaignId: string, + @Query('r') recipientId: string, + @Query('url') url: string, + @Res() res: Response, + ): Promise { + // Validate URL to prevent open redirect vulnerability + const decodedUrl = decodeURIComponent(url || ''); + + if (!this.isValidRedirectUrl(decodedUrl)) { + res.status(400).send('Invalid URL'); + return; } - /** - * Track email delivery (called by email service provider webhook) - */ - @Get('delivered') - @ApiExcludeEndpoint() - async trackDelivered( - @Query('c') campaignId: string, - @Query('r') recipientId: string, - @Res() res: Response, - ): Promise { - if (campaignId && recipientId) { - await this.analyticsService.recordEvent( - campaignId, - recipientId, - EmailEventType.DELIVERED, - ); - } - res.status(200).send('OK'); + // Record the click event asynchronously + if (campaignId && recipientId) { + this.analyticsService + .recordEvent(campaignId, recipientId, EmailEventType.CLICKED, { + url: decodedUrl, + timestamp: new Date().toISOString(), + }) + .catch((error) => { + console.error('Failed to record click event:', error); + }); } - /** - * Track email bounce (called by email service provider webhook) - */ - @Get('bounce') - @ApiExcludeEndpoint() - async trackBounce( - @Query('c') campaignId: string, - @Query('r') recipientId: string, - @Query('type') bounceType: string, - @Res() res: Response, - ): Promise { - if (campaignId && recipientId) { - const eventType = bounceType === 'soft' - ? EmailEventType.SOFT_BOUNCED - : EmailEventType.BOUNCED; - - await this.analyticsService.recordEvent( - campaignId, - recipientId, - eventType, - { bounceType }, - ); - } - res.status(200).send('OK'); + // Redirect to the original URL + res.redirect(302, decodedUrl); + } + + /** + * Track email delivery (called by email service provider webhook) + */ + @Get('delivered') + @ApiExcludeEndpoint() + async trackDelivered( + @Query('c') campaignId: string, + @Query('r') recipientId: string, + @Res() res: Response, + ): Promise { + if (campaignId && recipientId) { + await this.analyticsService.recordEvent(campaignId, recipientId, EmailEventType.DELIVERED); + } + res.status(200).send('OK'); + } + + /** + * Track email bounce (called by email service provider webhook) + */ + @Get('bounce') + @ApiExcludeEndpoint() + async trackBounce( + @Query('c') campaignId: string, + @Query('r') recipientId: string, + @Query('type') bounceType: string, + @Res() res: Response, + ): Promise { + if (campaignId && recipientId) { + const eventType = + bounceType === 'soft' ? EmailEventType.SOFT_BOUNCED : EmailEventType.BOUNCED; + + await this.analyticsService.recordEvent(campaignId, recipientId, eventType, { bounceType }); + } + res.status(200).send('OK'); + } + + /** + * Handle unsubscribe requests + */ + @Get('unsubscribe') + @ApiOperation({ summary: 'Unsubscribe from email list' }) + async unsubscribe( + @Query('c') campaignId: string, + @Query('r') recipientId: string, + @Query('email') email: string, + @Res() res: Response, + ): Promise { + if (campaignId && recipientId) { + await this.analyticsService.recordEvent( + campaignId, + recipientId, + EmailEventType.UNSUBSCRIBED, + { email }, + ); } - /** - * Handle unsubscribe requests - */ - @Get('unsubscribe') - @ApiOperation({ summary: 'Unsubscribe from email list' }) - async unsubscribe( - @Query('c') campaignId: string, - @Query('r') recipientId: string, - @Query('email') email: string, - @Res() res: Response, - ): Promise { - if (campaignId && recipientId) { - await this.analyticsService.recordEvent( - campaignId, - recipientId, - EmailEventType.UNSUBSCRIBED, - { email }, - ); - } - - // TODO: Actually unsubscribe the user in the subscription service - - // Return a simple confirmation page - res.set('Content-Type', 'text/html'); - res.send(` + // TODO: Actually unsubscribe the user in the subscription service + + // Return a simple confirmation page + res.set('Content-Type', 'text/html'); + res.send(` @@ -180,31 +166,31 @@ export class TrackingController { `); + } + + /** + * Validate redirect URL to prevent open redirect attacks + */ + private isValidRedirectUrl(url: string): boolean { + if (!url) return false; + + try { + const parsed = new URL(url); + + // Only allow http and https protocols + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false; + } + + // Optional: Add domain whitelist for extra security + // const allowedDomains = ['teachlink.io', 'www.teachlink.io']; + // if (!allowedDomains.includes(parsed.hostname)) { + // return false; + // } + + return true; + } catch { + return false; } - - /** - * Validate redirect URL to prevent open redirect attacks - */ - private isValidRedirectUrl(url: string): boolean { - if (!url) return false; - - try { - const parsed = new URL(url); - - // Only allow http and https protocols - if (!['http:', 'https:'].includes(parsed.protocol)) { - return false; - } - - // Optional: Add domain whitelist for extra security - // const allowedDomains = ['teachlink.io', 'www.teachlink.io']; - // if (!allowedDomains.includes(parsed.hostname)) { - // return false; - // } - - return true; - } catch { - return false; - } - } + } } diff --git a/src/feature-flags/analytics/flag-analytics.service.ts b/src/feature-flags/analytics/flag-analytics.service.ts index 19d6079..f3bfd7b 100644 --- a/src/feature-flags/analytics/flag-analytics.service.ts +++ b/src/feature-flags/analytics/flag-analytics.service.ts @@ -134,10 +134,7 @@ export class FlagAnalyticsService { /** * Returns impression and conversion stats for all variants in an experiment. */ - getExperimentStats( - experimentId: string, - controlVariantKey?: string, - ): ExperimentStats { + getExperimentStats(experimentId: string, controlVariantKey?: string): ExperimentStats { const impressions = this.experimentImpressions.get(experimentId) ?? new Map(); const conversions = this.experimentConversions.get(experimentId) ?? new Map(); @@ -178,9 +175,7 @@ export class FlagAnalyticsService { }); } - return summaries - .sort((a, b) => b.totalEvaluations - a.totalEvaluations) - .slice(0, limit); + return summaries.sort((a, b) => b.totalEvaluations - a.totalEvaluations).slice(0, limit); } /** diff --git a/src/feature-flags/rollout/rollout.service.ts b/src/feature-flags/rollout/rollout.service.ts index 20f5c2f..0576401 100644 --- a/src/feature-flags/rollout/rollout.service.ts +++ b/src/feature-flags/rollout/rollout.service.ts @@ -33,9 +33,7 @@ export class RolloutService { } const now = new Date(); - const sortedSteps = [...config.rampSchedule].sort( - (a, b) => a.at.getTime() - b.at.getTime(), - ); + const sortedSteps = [...config.rampSchedule].sort((a, b) => a.at.getTime() - b.at.getTime()); let effective = 0; for (const step of sortedSteps) { diff --git a/src/gamification/gamification.service.ts b/src/gamification/gamification.service.ts index aad613b..5c3bef5 100644 --- a/src/gamification/gamification.service.ts +++ b/src/gamification/gamification.service.ts @@ -23,7 +23,7 @@ export class GamificationService { // 3. Update active challenges based on activity // This would normally involve complex logic to match activityType to challenge goals - + return progress; } } diff --git a/src/gateways/messaging.gateway.ts b/src/gateways/messaging.gateway.ts index b2bbf25..a2be2c1 100644 --- a/src/gateways/messaging.gateway.ts +++ b/src/gateways/messaging.gateway.ts @@ -4,19 +4,19 @@ import { MessageBody, ConnectedSocket, OnGatewayConnection, - UseGuards, -} from "@nestjs/websockets"; -import { Socket } from "socket.io"; -import { WsJwtAuthGuard } from "../common/guards/ws-jwt-auth.guard"; +} from '@nestjs/websockets'; +import { UseGuards } from '@nestjs/common'; +import { Socket } from 'socket.io'; +import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard'; -@WebSocketGateway({ namespace: "/messaging" }) +@WebSocketGateway({ namespace: '/messaging' }) export class MessagingGateway implements OnGatewayConnection { async handleConnection(client: Socket) { // Guard will disconnect unauthorized clients } @UseGuards(WsJwtAuthGuard) - @SubscribeMessage("send_message") + @SubscribeMessage('send_message') async handleMessage(@MessageBody() data: any, @ConnectedSocket() client: Socket) { const user = (client as any).user; return { userId: user.sub, message: data }; diff --git a/src/gateways/notifications.gateway.ts b/src/gateways/notifications.gateway.ts index f8f1c27..bae625a 100644 --- a/src/gateways/notifications.gateway.ts +++ b/src/gateways/notifications.gateway.ts @@ -1,16 +1,12 @@ -import { - WebSocketGateway, - SubscribeMessage, - ConnectedSocket, - UseGuards, -} from "@nestjs/websockets"; -import { Socket } from "socket.io"; -import { WsJwtAuthGuard } from "../common/guards/ws-jwt-auth.guard"; +import { WebSocketGateway, SubscribeMessage, ConnectedSocket } from '@nestjs/websockets'; +import { UseGuards } from '@nestjs/common'; +import { Socket } from 'socket.io'; +import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard'; -@WebSocketGateway({ namespace: "/notifications" }) +@WebSocketGateway({ namespace: '/notifications' }) export class NotificationsGateway { @UseGuards(WsJwtAuthGuard) - @SubscribeMessage("subscribe_notifications") + @SubscribeMessage('subscribe_notifications') async handleSubscribe(@ConnectedSocket() client: Socket) { const user = (client as any).user; return { userId: user.sub, subscribed: true }; diff --git a/src/graphql/resolvers/course.resolver.ts b/src/graphql/resolvers/course.resolver.ts index 7e3fcaf..f1ab6a6 100644 --- a/src/graphql/resolvers/course.resolver.ts +++ b/src/graphql/resolvers/course.resolver.ts @@ -21,21 +21,20 @@ export class CourseResolver { } const { userLoader } = context.loaders || {}; - + // If instructor is already loaded with full data, return it if (typeof course.instructor === 'object' && course.instructor.id) { return course.instructor; } // Otherwise, use DataLoader to fetch instructor - const instructorId = typeof course.instructor === 'string' - ? course.instructor - : course.instructor.id; + const instructorId = + typeof course.instructor === 'string' ? course.instructor : course.instructor.id; if (userLoader) { return userLoader.load(instructorId); } - + return this.usersService.findOne(instructorId); } } diff --git a/src/graphql/resolvers/mutation.resolver.ts b/src/graphql/resolvers/mutation.resolver.ts index 6ae6473..bc297be 100644 --- a/src/graphql/resolvers/mutation.resolver.ts +++ b/src/graphql/resolvers/mutation.resolver.ts @@ -7,18 +7,9 @@ import { AssessmentsService } from '../../assessment/assessments.service'; import { UserType } from '../types/user.type'; import { CourseType } from '../types/course.type'; import { AssessmentType } from '../types/assessment.type'; -import { - CreateUserInput, - UpdateUserInput, -} from '../inputs/user.input'; -import { - CreateCourseInput, - UpdateCourseInput, -} from '../inputs/course.input'; -import { - CreateAssessmentInput, - UpdateAssessmentInput, -} from '../inputs/assessment.input'; +import { CreateUserInput, UpdateUserInput } from '../inputs/user.input'; +import { CreateCourseInput, UpdateCourseInput } from '../inputs/course.input'; +import { CreateAssessmentInput, UpdateAssessmentInput } from '../inputs/assessment.input'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; /** @@ -36,9 +27,7 @@ export class MutationResolver { // User Mutations @Mutation(() => UserType) - async createUser( - @Args('input') input: CreateUserInput, - ): Promise { + async createUser(@Args('input') input: CreateUserInput): Promise { const user = await this.usersService.create(input); await this.pubSub.publish('userCreated', { userCreated: user }); return user; @@ -57,9 +46,7 @@ export class MutationResolver { @Mutation(() => Boolean) @UseGuards(JwtAuthGuard) - async deleteUser( - @Args('id', { type: () => ID }) id: string, - ): Promise { + async deleteUser(@Args('id', { type: () => ID }) id: string): Promise { await this.usersService.remove(id); await this.pubSub.publish('userDeleted', { userDeleted: { id } }); return true; @@ -68,9 +55,7 @@ export class MutationResolver { // Course Mutations @Mutation(() => CourseType) @UseGuards(JwtAuthGuard) - async createCourse( - @Args('input') input: CreateCourseInput, - ): Promise { + async createCourse(@Args('input') input: CreateCourseInput): Promise { const course = await this.coursesService.create(input); await this.pubSub.publish('courseCreated', { courseCreated: course }); return course; @@ -89,9 +74,7 @@ export class MutationResolver { @Mutation(() => Boolean) @UseGuards(JwtAuthGuard) - async deleteCourse( - @Args('id', { type: () => ID }) id: string, - ): Promise { + async deleteCourse(@Args('id', { type: () => ID }) id: string): Promise { await this.coursesService.remove(id); await this.pubSub.publish('courseDeleted', { courseDeleted: { id } }); return true; @@ -100,9 +83,7 @@ export class MutationResolver { // Assessment Mutations @Mutation(() => AssessmentType) @UseGuards(JwtAuthGuard) - async createAssessment( - @Args('input') input: CreateAssessmentInput, - ): Promise { + async createAssessment(@Args('input') input: CreateAssessmentInput): Promise { const assessment = await this.assessmentsService.create(input); await this.pubSub.publish('assessmentCreated', { assessmentCreated: assessment, @@ -125,9 +106,7 @@ export class MutationResolver { @Mutation(() => Boolean) @UseGuards(JwtAuthGuard) - async deleteAssessment( - @Args('id', { type: () => ID }) id: string, - ): Promise { + async deleteAssessment(@Args('id', { type: () => ID }) id: string): Promise { await this.assessmentsService.remove(id); await this.pubSub.publish('assessmentDeleted', { assessmentDeleted: { id }, diff --git a/src/graphql/resolvers/user.resolver.ts b/src/graphql/resolvers/user.resolver.ts index 7abc866..b588a0d 100644 --- a/src/graphql/resolvers/user.resolver.ts +++ b/src/graphql/resolvers/user.resolver.ts @@ -12,16 +12,13 @@ export class UserResolver { constructor(private readonly coursesService: CoursesService) {} @ResolveField(() => [CourseType]) - async courses( - @Parent() user: UserType, - @Context() context: any, - ): Promise { + async courses(@Parent() user: UserType, @Context() context: any): Promise { const { coursesByInstructorLoader } = context.loaders || {}; - + if (coursesByInstructorLoader) { return coursesByInstructorLoader.load(user.id); } - + return this.coursesService.findByInstructor(user.id); } } diff --git a/src/graphql/services/dataloader.service.ts b/src/graphql/services/dataloader.service.ts index 9ba38eb..4703931 100644 --- a/src/graphql/services/dataloader.service.ts +++ b/src/graphql/services/dataloader.service.ts @@ -34,61 +34,44 @@ export class DataLoaderService { * Create a new DataLoader for batching course queries by ID */ createCourseLoader(): DataLoader { - return new DataLoader( - async (courseIds: readonly string[]) => { - const courses = await this.coursesService.findByIds( - Array.from(courseIds), - ); - const courseMap = new Map(courses.map((course) => [course.id, course])); - return courseIds.map((id) => courseMap.get(id) || null); - }, - ); + return new DataLoader(async (courseIds: readonly string[]) => { + const courses = await this.coursesService.findByIds(Array.from(courseIds)); + const courseMap = new Map(courses.map((course) => [course.id, course])); + return courseIds.map((id) => courseMap.get(id) || null); + }); } /** * Create a new DataLoader for batching assessment queries by ID */ createAssessmentLoader(): DataLoader { - return new DataLoader( - async (assessmentIds: readonly string[]) => { - const assessments = await this.assessmentsService.findByIds( - Array.from(assessmentIds), - ); - const assessmentMap = new Map( - assessments.map((assessment) => [assessment.id, assessment]), - ); - return assessmentIds.map((id) => assessmentMap.get(id) || null); - }, - ); + return new DataLoader(async (assessmentIds: readonly string[]) => { + const assessments = await this.assessmentsService.findByIds(Array.from(assessmentIds)); + const assessmentMap = new Map(assessments.map((assessment) => [assessment.id, assessment])); + return assessmentIds.map((id) => assessmentMap.get(id) || null); + }); } /** * Create a new DataLoader for batching courses by instructor ID */ createCoursesByInstructorLoader(): DataLoader { - return new DataLoader( - async (instructorIds: readonly string[]) => { - const courses = - await this.coursesService.findByInstructorIds( - Array.from(instructorIds), - ); + return new DataLoader(async (instructorIds: readonly string[]) => { + const courses = await this.coursesService.findByInstructorIds(Array.from(instructorIds)); - const coursesByInstructor = new Map(); - courses.forEach((course) => { - const instructorId = course.instructor?.id; - if (instructorId) { - if (!coursesByInstructor.has(instructorId)) { - coursesByInstructor.set(instructorId, []); - } - coursesByInstructor.get(instructorId).push(course); + const coursesByInstructor = new Map(); + courses.forEach((course) => { + const instructorId = course.instructor?.id; + if (instructorId) { + if (!coursesByInstructor.has(instructorId)) { + coursesByInstructor.set(instructorId, []); } - }); + coursesByInstructor.get(instructorId).push(course); + } + }); - return instructorIds.map( - (id) => coursesByInstructor.get(id) || [], - ); - }, - ); + return instructorIds.map((id) => coursesByInstructor.get(id) || []); + }); } /** diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index c98ea63..466aad4 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -48,4 +48,4 @@ export class HealthController { return healthStatus; } -} \ No newline at end of file +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts index e161404..7476abe 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -4,4 +4,4 @@ import { HealthController } from './health.controller'; @Module({ controllers: [HealthController], }) -export class HealthModule {} \ No newline at end of file +export class HealthModule {} diff --git a/src/learning-paths/learning-paths.controller.ts b/src/learning-paths/learning-paths.controller.ts index 659df08..4467c4f 100644 --- a/src/learning-paths/learning-paths.controller.ts +++ b/src/learning-paths/learning-paths.controller.ts @@ -3,9 +3,7 @@ import { LearningPathsService } from './learning-paths.service'; @Controller('learning-paths') export class LearningPathsController { - constructor( - private readonly learningPathsService: LearningPathsService, - ) {} + constructor(private readonly learningPathsService: LearningPathsService) {} @Post('generate') generateLearningPath(@Body() payload: any) { diff --git a/src/media/media.controller.ts b/src/media/media.controller.ts index 08f6d60..46e6754 100644 --- a/src/media/media.controller.ts +++ b/src/media/media.controller.ts @@ -59,7 +59,12 @@ export class MediaController { if (!meta) throw new HttpException('Not found', HttpStatus.NOT_FOUND); // Access control: owner or same tenant or admin - if (meta.ownerId && meta.ownerId !== user?.id && user?.role !== 'admin' && meta.tenantId !== user?.tenantId) { + if ( + meta.ownerId && + meta.ownerId !== user?.id && + user?.role !== 'admin' && + meta.tenantId !== user?.tenantId + ) { throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); } diff --git a/src/media/media.module.ts b/src/media/media.module.ts index 8f0dcf7..703ac5b 100644 --- a/src/media/media.module.ts +++ b/src/media/media.module.ts @@ -26,4 +26,3 @@ import { VideoProcessor } from './processing/video.processor'; exports: [MediaService, FileStorageService, VideoProcessingService], }) export class MediaModule {} - diff --git a/src/media/media.service.ts b/src/media/media.service.ts index c1e675e..f8238ea 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -1,7 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { ContentMetadata, ContentStatus, ContentType } from '../cdn/entities/content-metadata.entity'; +import { + ContentMetadata, + ContentStatus, + ContentType, +} from '../cdn/entities/content-metadata.entity'; import { FileStorageService } from './storage/file-storage.service'; import { VideoProcessingService } from './processing/video-processing.service'; diff --git a/src/media/processing/document-processing.service.ts b/src/media/processing/document-processing.service.ts index 46181d9..2e884a3 100644 --- a/src/media/processing/document-processing.service.ts +++ b/src/media/processing/document-processing.service.ts @@ -25,7 +25,8 @@ export class DocumentProcessingService { try { const parsed = await pdfParse(buffer); meta.metadata = meta.metadata || {}; - meta.metadata.text = parsed.text; + // Extend metadata type to include text for documents + (meta.metadata as any).text = parsed.text; await this.contentRepo.save(meta); return parsed.text; } catch (err) { diff --git a/src/media/processing/video-processing.service.ts b/src/media/processing/video-processing.service.ts index 104abe3..f7cfba8 100644 --- a/src/media/processing/video-processing.service.ts +++ b/src/media/processing/video-processing.service.ts @@ -10,15 +10,19 @@ export class VideoProcessingService { constructor(@InjectQueue('media-processing') private readonly queue: Queue) {} async enqueueTranscode(content: ContentMetadata) { - await this.queue.add('transcode-video', { - contentId: content.contentId, - url: content.cdnUrl, - fileName: content.fileName, - mimeType: content.mimeType, - }, { - attempts: 3, - backoff: { type: 'exponential', delay: 5000 }, - }); + await this.queue.add( + 'transcode-video', + { + contentId: content.contentId, + url: content.cdnUrl, + fileName: content.fileName, + mimeType: content.mimeType, + }, + { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }, + ); this.logger.log(`Job enqueued for content ${content.contentId}`); } } diff --git a/src/media/processing/video.processor.ts b/src/media/processing/video.processor.ts index 48ca56f..c6fd1c1 100644 --- a/src/media/processing/video.processor.ts +++ b/src/media/processing/video.processor.ts @@ -22,7 +22,11 @@ export class VideoProcessor { @Process('transcode-video') async handleTranscode(job: Job) { - const { contentId, url, fileName } = job.data as { contentId: string; url: string; fileName: string }; + const { contentId, url, fileName } = job.data as { + contentId: string; + url: string; + fileName: string; + }; this.logger.log(`Transcoding job for ${contentId} - ${url}`); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `media-${contentId}-`)); @@ -41,13 +45,7 @@ export class VideoProcessor { .addOption('-preset', 'fast') .addOption('-g', '48') .addOption('-sc_threshold', '0') - .outputOptions([ - '-map 0:v', - '-map 0:a?', - '-c:a aac', - '-c:v h264', - '-profile:v main', - ]) + .outputOptions(['-map 0:v', '-map 0:a?', '-c:a aac', '-c:v h264', '-profile:v main']) .output(path.join(hlsDir, 'index.m3u8')) .on('end', () => resolve()) .on('error', (err) => reject(err)) @@ -81,8 +79,16 @@ export class VideoProcessor { const meta = await this.contentRepo.findOne({ where: { contentId } }); if (meta) { meta.metadata = meta.metadata || {}; - meta.metadata.hlsManifest = uploaded.find((u) => u.endsWith('index.m3u8')) || uploaded[0]; - meta.variants = uploaded.map((u) => ({ name: u.split('/').pop(), url: u, width: 0, height: 0, size: 0 })); + // Extend metadata type to include hlsManifest for videos + (meta.metadata as any).hlsManifest = + uploaded.find((u) => u.endsWith('index.m3u8')) || uploaded[0]; + meta.variants = uploaded.map((u) => ({ + name: u.split('/').pop(), + url: u, + width: 0, + height: 0, + size: 0, + })); meta.status = 'ready' as any; await this.contentRepo.save(meta); } @@ -115,7 +121,12 @@ async function downloadToFile(url: string, dest: string): Promise { const req = https.get(url, (res: any) => { if (res.statusCode >= 400) return reject(new Error(`Failed to download: ${res.statusCode}`)); res.pipe(file); - file.on('finish', () => file.close(resolve)); + file.on('finish', () => { + file.close((err?: NodeJS.ErrnoException | null) => { + if (err) reject(err); + else resolve(); + }); + }); }); req.on('error', (err: Error) => reject(err)); }); diff --git a/src/media/storage/file-storage.service.ts b/src/media/storage/file-storage.service.ts index 8e8b14a..4d294bb 100644 --- a/src/media/storage/file-storage.service.ts +++ b/src/media/storage/file-storage.service.ts @@ -1,57 +1,4 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ContentMetadata } from '../../cdn/entities/content-metadata.entity'; -import AWS from 'aws-sdk'; - -const s3 = new AWS.S3({ - region: process.env.AWS_REGION || 'us-east-1', - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, -}); - -@Injectable() -export class FileStorageService { - private readonly logger = new Logger(FileStorageService.name); - private readonly bucket = process.env.MEDIA_BUCKET || process.env.AWS_S3_BUCKET || 'teachlink-media'; - - async uploadFile(file: Express.Multer.File, metadata: ContentMetadata): Promise<{ url: string; etag?: string }> - { - const key = `${metadata.contentId}/${Date.now()}_${file.originalname}`; - - const params: AWS.S3.PutObjectRequest = { - Bucket: this.bucket, - Key: key, - Body: file.buffer, - ContentType: file.mimetype, - Metadata: { - originalname: file.originalname, - ownerId: metadata.ownerId || '', - tenantId: metadata.tenantId || '', - }, - }; - - const res = await s3.upload(params).promise(); - this.logger.log(`Uploaded ${key} to S3 ${res.Location}`); - - return { url: res.Location, etag: (res as any).ETag }; - } - - async getSignedUrl(keyOrUrl: string, expiresSec = 300): Promise { - // If a full URL is provided, return as-is - if (keyOrUrl.startsWith('http')) return keyOrUrl; - - const params = { Bucket: this.bucket, Key: keyOrUrl, Expires: expiresSec } as any; - return s3.getSignedUrlPromise('getObject', params); - } - - async backupToBucket(key: string, backupBucket: string): Promise { - await s3.copyObject({ - Bucket: backupBucket, - CopySource: `${this.bucket}/${key}`, - Key: key, - }).promise(); - } -} -import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { S3Client, @@ -60,6 +7,7 @@ import { DeleteObjectCommand, } from '@aws-sdk/client-s3'; import { Readable } from 'stream'; +import { ContentMetadata } from '../../cdn/entities/content-metadata.entity'; @Injectable() export class FileStorageService { @@ -74,19 +22,39 @@ export class FileStorageService { region: this.configService.get('AWS_REGION', 'us-east-1'), credentials: { accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID', ''), - secretAccessKey: this.configService.get( - 'AWS_SECRET_ACCESS_KEY', - '', - ), + secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY', ''), }, }); } - async uploadProcessedFile( - buffer: Buffer, - key: string, - contentType: string, - ): Promise { + // Legacy method for backward compatibility + async uploadFile( + file: Express.Multer.File, + metadata: ContentMetadata, + ): Promise<{ url: string; etag?: string }> { + const key = `${metadata.contentId}/${Date.now()}_${file.originalname}`; + await this.uploadProcessedFile(file.buffer, key, file.mimetype); + return { + url: `https://${this.bucketName}.s3.amazonaws.com/${key}`, + etag: undefined, + }; + } + + // Legacy method for backward compatibility + async getSignedUrl(keyOrUrl: string, expiresSec = 300): Promise { + // If a full URL is provided, return as-is + if (keyOrUrl.startsWith('http')) return keyOrUrl; + + const command = new GetObjectCommand({ + Bucket: this.bucketName, + Key: keyOrUrl, + }); + + // For simplicity, return the key as URL (in production, generate proper signed URL) + return `https://${this.bucketName}.s3.amazonaws.com/${keyOrUrl}`; + } + + async uploadProcessedFile(buffer: Buffer, key: string, contentType: string): Promise { const command = new PutObjectCommand({ Bucket: this.bucketName, Key: key, diff --git a/src/messaging/circuit-breaker/circuit-breaker.service.ts b/src/messaging/circuit-breaker/circuit-breaker.service.ts index 122690b..9155600 100644 --- a/src/messaging/circuit-breaker/circuit-breaker.service.ts +++ b/src/messaging/circuit-breaker/circuit-breaker.service.ts @@ -12,12 +12,15 @@ export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; @Injectable() export class CircuitBreakerService { private readonly logger = new Logger(CircuitBreakerService.name); - private circuits: Map = new Map(); + private circuits: Map< + string, + { + state: CircuitState; + failures: number; + lastFailureTime: number; + config: CircuitBreakerConfig; + } + > = new Map(); constructor(private readonly tracingService: TracingService) {} @@ -120,11 +123,16 @@ export class CircuitBreakerService { } } - async getAllCircuits(): Promise> { + async getAllCircuits(): Promise< + Record< + string, + { + state: CircuitState; + failures: number; + lastFailureTime: number; + } + > + > { const result: Record = {}; for (const [key, circuit] of this.circuits) { result[key] = { diff --git a/src/messaging/discovery/service-discovery.service.ts b/src/messaging/discovery/service-discovery.service.ts index 69077f8..d3aae32 100644 --- a/src/messaging/discovery/service-discovery.service.ts +++ b/src/messaging/discovery/service-discovery.service.ts @@ -92,7 +92,7 @@ export class ServiceDiscoveryService implements OnModuleInit, OnModuleDestroy { } } - return instances.filter(instance => instance.health === 'healthy'); + return instances.filter((instance) => instance.health === 'healthy'); } catch (error) { this.logger.error(`Failed to get service instances for ${serviceName}`, error); throw error; @@ -101,7 +101,11 @@ export class ServiceDiscoveryService implements OnModuleInit, OnModuleDestroy { } } - async updateHealth(serviceName: string, serviceId: string, health: 'healthy' | 'unhealthy'): Promise { + async updateHealth( + serviceName: string, + serviceId: string, + health: 'healthy' | 'unhealthy', + ): Promise { const span = this.tracingService.startSpan('update-service-health'); try { const key = `${this.servicePrefix}${serviceName}:${serviceId}`; diff --git a/src/messaging/tracing/tracing.service.ts b/src/messaging/tracing/tracing.service.ts index 941cf4e..7583036 100644 --- a/src/messaging/tracing/tracing.service.ts +++ b/src/messaging/tracing/tracing.service.ts @@ -51,11 +51,7 @@ export class TracingService { return trace.getActiveSpan(); } - async runInSpan( - name: string, - fn: (span: Span) => Promise, - parentSpan?: Span, - ): Promise { + async runInSpan(name: string, fn: (span: Span) => Promise, parentSpan?: Span): Promise { const span = this.startSpan(name, parentSpan); try { const result = await fn(span); diff --git a/src/migrations/conflicts/conflict-resolution.service.ts b/src/migrations/conflicts/conflict-resolution.service.ts index 69e61b8..a12db60 100644 --- a/src/migrations/conflicts/conflict-resolution.service.ts +++ b/src/migrations/conflicts/conflict-resolution.service.ts @@ -6,7 +6,7 @@ export enum ConflictResolutionStrategy { RETRY = 'retry', ABORT = 'abort', MERGE = 'merge', - REORDER = 'reorder' + REORDER = 'reorder', } export interface MigrationConflict { @@ -35,7 +35,7 @@ export class ConflictResolutionService { // - Dependency conflicts // - Schema conflicts // - Data conflicts - + // For now, return false to indicate no conflicts return false; } @@ -48,11 +48,11 @@ export class ConflictResolutionService { // Determine the appropriate resolution strategy const strategy = await this.determineResolutionStrategy(migration); - + if (strategy === ConflictResolutionStrategy.ABORT) { throw new ConflictException(`Migration conflict detected for ${migration.name}. Aborting.`); } - + // Create conflict record const conflict: MigrationConflict = { migrationName: migration.name, @@ -60,7 +60,7 @@ export class ConflictResolutionService { conflictType: 'unknown', // Would be determined in real implementation resolutionStrategy: strategy, resolvedAt: new Date(), - resolvedBy: 'system' + resolvedBy: 'system', }; // Apply the resolution strategy @@ -69,21 +69,25 @@ export class ConflictResolutionService { // Store conflict in history this.conflictHistory.push(conflict); - this.logger.log(`Conflict resolved for migration ${migration.name} using strategy: ${strategy}`); + this.logger.log( + `Conflict resolved for migration ${migration.name} using strategy: ${strategy}`, + ); return conflict; } /** * Determines the appropriate resolution strategy for a conflict */ - private async determineResolutionStrategy(migration: MigrationConfig): Promise { + private async determineResolutionStrategy( + migration: MigrationConfig, + ): Promise { // In a real implementation, this would analyze the specific conflict // and determine the best strategy based on: // - Type of conflict // - Migration dependencies // - Current environment // - Business rules - + // For now, return a default strategy return ConflictResolutionStrategy.RETRY; } @@ -91,31 +95,34 @@ export class ConflictResolutionService { /** * Applies the specified resolution strategy */ - private async applyResolutionStrategy(migration: MigrationConfig, strategy: ConflictResolutionStrategy): Promise { + private async applyResolutionStrategy( + migration: MigrationConfig, + strategy: ConflictResolutionStrategy, + ): Promise { switch (strategy) { case ConflictResolutionStrategy.SKIP: this.logger.log(`Skipping migration ${migration.name} due to conflict`); break; - + case ConflictResolutionStrategy.RETRY: this.logger.log(`Retrying migration ${migration.name} after conflict`); // Wait a bit before retrying - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); break; - + case ConflictResolutionStrategy.ABORT: throw new ConflictException(`Aborting migration ${migration.name} due to conflict`); - + case ConflictResolutionStrategy.MERGE: this.logger.log(`Merging migration ${migration.name} with conflicting changes`); // In a real implementation, this would attempt to merge changes break; - + case ConflictResolutionStrategy.REORDER: this.logger.log(`Reordering migration ${migration.name} due to conflict`); // In a real implementation, this would adjust the migration order break; - + default: throw new BadRequestException(`Unknown resolution strategy: ${strategy}`); } @@ -134,7 +141,7 @@ export class ConflictResolutionService { // - Dependencies that are not met // - Migrations that modify the same tables // - Time-based conflicts in distributed systems - + // For now, return an empty array return conflicts; } @@ -148,7 +155,7 @@ export class ConflictResolutionService { // In a real implementation, this would implement distributed locking // or other mechanisms to handle concurrent migration execution // For now, return true to indicate it's safe to proceed - + return true; } @@ -170,9 +177,12 @@ export class ConflictResolutionService { /** * Sets a custom resolution strategy for a specific migration */ - async setCustomResolutionStrategy(migrationName: string, strategy: ConflictResolutionStrategy): Promise { + async setCustomResolutionStrategy( + migrationName: string, + strategy: ConflictResolutionStrategy, + ): Promise { this.logger.log(`Setting custom resolution strategy for ${migrationName}: ${strategy}`); - + // In a real implementation, this would store the custom strategy // For now, just log the action } @@ -180,11 +190,14 @@ export class ConflictResolutionService { /** * Gets the most appropriate resolution strategy for a migration */ - async getResolutionStrategy(migration: MigrationConfig, conflictType?: string): Promise { + async getResolutionStrategy( + migration: MigrationConfig, + conflictType?: string, + ): Promise { // Determine strategy based on migration characteristics and optional conflict type // In a real implementation, this would have more sophisticated logic - + // Default strategy return ConflictResolutionStrategy.RETRY; } -} \ No newline at end of file +} diff --git a/src/migrations/entities/migration.entity.ts b/src/migrations/entities/migration.entity.ts index 0d5230d..80de905 100644 --- a/src/migrations/entities/migration.entity.ts +++ b/src/migrations/entities/migration.entity.ts @@ -1,10 +1,16 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; export enum MigrationStatus { PENDING = 'pending', COMPLETED = 'completed', FAILED = 'failed', - ROLLED_BACK = 'rolled_back' + ROLLED_BACK = 'rolled_back', } @Entity({ name: 'migrations' }) @@ -21,7 +27,7 @@ export class Migration { @Column({ type: 'enum', enum: MigrationStatus, - default: MigrationStatus.PENDING + default: MigrationStatus.PENDING, }) status: MigrationStatus; @@ -39,4 +45,4 @@ export class Migration { @Column({ type: 'text', nullable: true }) errorMessage?: string; -} \ No newline at end of file +} diff --git a/src/migrations/environments/environment-sync.service.ts b/src/migrations/environments/environment-sync.service.ts index ba1f9d2..ae9269a 100644 --- a/src/migrations/environments/environment-sync.service.ts +++ b/src/migrations/environments/environment-sync.service.ts @@ -6,7 +6,7 @@ export enum EnvironmentType { DEVELOPMENT = 'development', STAGING = 'staging', PRODUCTION = 'production', - TEST = 'test' + TEST = 'test', } @Injectable() @@ -15,7 +15,9 @@ export class EnvironmentSyncService { private currentEnvironment: EnvironmentType; constructor(private configService: ConfigService) { - this.currentEnvironment = this.configService.get('NODE_ENV') as EnvironmentType || EnvironmentType.DEVELOPMENT; + this.currentEnvironment = + (this.configService.get('NODE_ENV') as EnvironmentType) || + EnvironmentType.DEVELOPMENT; } /** @@ -33,7 +35,9 @@ export class EnvironmentSyncService { await this.syncToOtherEnvironments(migration); } - this.logger.log(`Successfully synchronized migration ${migration.name} in ${this.currentEnvironment} environment`); + this.logger.log( + `Successfully synchronized migration ${migration.name} in ${this.currentEnvironment} environment`, + ); } catch (error) { this.logger.error(`Failed to synchronize migration ${migration.name}`, error.stack); throw error; @@ -43,7 +47,10 @@ export class EnvironmentSyncService { /** * Records a migration in the current environment */ - async recordMigrationInEnvironment(migration: MigrationConfig, environment: EnvironmentType): Promise { + async recordMigrationInEnvironment( + migration: MigrationConfig, + environment: EnvironmentType, + ): Promise { // In a real implementation, this would record the migration in an environment-specific registry // For now, just log the information this.logger.log(`Recording migration ${migration.name} in ${environment} environment`); @@ -63,8 +70,10 @@ export class EnvironmentSyncService { // - Configuration management systems // For now, just log that we're simulating the sync - const environmentsToSync = Object.values(EnvironmentType).filter(env => env !== EnvironmentType.PRODUCTION); - + const environmentsToSync = Object.values(EnvironmentType).filter( + (env) => env !== EnvironmentType.PRODUCTION, + ); + for (const env of environmentsToSync) { this.logger.log(`Simulating sync of migration ${migration.name} to ${env} environment`); await this.simulateEnvironmentSync(migration, env); @@ -74,15 +83,22 @@ export class EnvironmentSyncService { /** * Simulates synchronization to a specific environment (for demo purposes) */ - private async simulateEnvironmentSync(migration: MigrationConfig, environment: EnvironmentType): Promise { + private async simulateEnvironmentSync( + migration: MigrationConfig, + environment: EnvironmentType, + ): Promise { // Simulate the sync process - this.logger.log(`Simulated sync of migration ${migration.name} to ${environment} environment completed`); + this.logger.log( + `Simulated sync of migration ${migration.name} to ${environment} environment completed`, + ); } /** * Gets migration status across all environments */ - async getMigrationStatusAcrossEnvironments(migrationName: string): Promise> { + async getMigrationStatusAcrossEnvironments( + migrationName: string, + ): Promise> { this.logger.log(`Getting migration status for ${migrationName} across environments`); // In a real implementation, this would fetch migration status from all environments @@ -91,14 +107,17 @@ export class EnvironmentSyncService { [EnvironmentType.DEVELOPMENT]: true, [EnvironmentType.STAGING]: true, [EnvironmentType.PRODUCTION]: true, - [EnvironmentType.TEST]: true + [EnvironmentType.TEST]: true, }; } /** * Applies a migration to a specific environment */ - async applyMigrationToEnvironment(migration: MigrationConfig, environment: EnvironmentType): Promise { + async applyMigrationToEnvironment( + migration: MigrationConfig, + environment: EnvironmentType, + ): Promise { this.logger.log(`Applying migration ${migration.name} to ${environment} environment`); // In a real implementation, this would: @@ -151,4 +170,4 @@ export class EnvironmentSyncService { // For now, return the original migration config return migration; } -} \ No newline at end of file +} diff --git a/src/migrations/migration-runner.service.ts b/src/migrations/migration-runner.service.ts index d13e009..591ea00 100644 --- a/src/migrations/migration-runner.service.ts +++ b/src/migrations/migration-runner.service.ts @@ -35,4 +35,4 @@ export class MigrationRunnerService implements OnApplicationBootstrap { async getMigrationStatus(): Promise { return await this.migrationService.listMigrations(); } -} \ No newline at end of file +} diff --git a/src/migrations/migration.controller.ts b/src/migrations/migration.controller.ts index 3472365..2544357 100644 --- a/src/migrations/migration.controller.ts +++ b/src/migrations/migration.controller.ts @@ -1,4 +1,15 @@ -import { Controller, Get, Post, Put, Delete, Param, Logger, HttpCode, HttpStatus, Res } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Logger, + HttpCode, + HttpStatus, + Res, +} from '@nestjs/common'; import { Response } from 'express'; import { MigrationService } from './migration.service'; import { RollbackService } from './rollback/rollback.service'; @@ -24,7 +35,7 @@ export class MigrationController { @HttpCode(HttpStatus.OK) async runMigrations(@Res() res: Response) { this.logger.log('Running pending migrations'); - + try { await this.migrationService.runPendingMigrations(); return res.status(HttpStatus.OK).json({ @@ -49,14 +60,11 @@ export class MigrationController { @Post('rollback/:count') @HttpCode(HttpStatus.OK) - async rollbackMigrationsWithCount( - @Param('count') count: string, - @Res() res: Response - ) { + async rollbackMigrationsWithCount(@Param('count') count: string, @Res() res: Response) { const rollbackCount = count && !isNaN(parseInt(count, 10)) ? parseInt(count, 10) : 1; - + this.logger.log(`Rolling back ${rollbackCount} migrations`); - + try { await this.rollbackService.rollbackLastMigrations(rollbackCount); return res.status(HttpStatus.OK).json({ @@ -77,7 +85,7 @@ export class MigrationController { @HttpCode(HttpStatus.OK) async resetAllMigrations(@Res() res: Response) { this.logger.log('Resetting all migrations'); - + try { await this.migrationService.resetMigrations(); return res.status(HttpStatus.OK).json({ @@ -98,13 +106,13 @@ export class MigrationController { @HttpCode(HttpStatus.OK) async rollbackSpecificMigration( @Param('migrationName') migrationName: string, - @Res() res: Response + @Res() res: Response, ) { this.logger.log(`Rolling back specific migration: ${migrationName}`); - + // Note: In a real implementation, you'd need to map the migration name to the actual migration config // For now, this is a placeholder - + return res.status(HttpStatus.NOT_IMPLEMENTED).json({ success: false, message: 'Specific migration rollback not implemented in this example', @@ -121,7 +129,7 @@ export class MigrationController { @HttpCode(HttpStatus.OK) async syncEnvironments(@Res() res: Response) { this.logger.log('Syncing environments'); - + try { // This would typically trigger environment synchronization // For now, we'll just return a success response @@ -138,4 +146,4 @@ export class MigrationController { }); } } -} \ No newline at end of file +} diff --git a/src/migrations/migration.module.ts b/src/migrations/migration.module.ts index ef4f10b..f5eba3b 100644 --- a/src/migrations/migration.module.ts +++ b/src/migrations/migration.module.ts @@ -10,12 +10,8 @@ import { MigrationController } from './migration.controller'; import { MigrationRunnerService } from './migration-runner.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([Migration]), - ], - controllers: [ - MigrationController, - ], + imports: [TypeOrmModule.forFeature([Migration])], + controllers: [MigrationController], providers: [ MigrationService, RollbackService, @@ -33,4 +29,4 @@ import { MigrationRunnerService } from './migration-runner.service'; MigrationRunnerService, ], }) -export class MigrationModule {} \ No newline at end of file +export class MigrationModule {} diff --git a/src/migrations/migration.service.ts b/src/migrations/migration.service.ts index f72e2d7..2e64946 100644 --- a/src/migrations/migration.service.ts +++ b/src/migrations/migration.service.ts @@ -18,7 +18,7 @@ export interface MigrationConfig { @Injectable() export class MigrationService { private readonly logger = new Logger(MigrationService.name); - + constructor( @InjectRepository(Migration) private migrationRepository: Repository, @@ -33,32 +33,32 @@ export class MigrationService { */ async runPendingMigrations(): Promise { this.logger.log('Starting pending migrations...'); - + // Get all applied migrations const appliedMigrations = await this.migrationRepository.find({ order: { createdAt: 'ASC' }, }); - + // Get all registered migrations const registeredMigrations = this.getRegisteredMigrations(); - + // Filter for unapplied migrations - const pendingMigrations = registeredMigrations.filter(registered => { - return !appliedMigrations.some(applied => applied.name === registered.name); + const pendingMigrations = registeredMigrations.filter((registered) => { + return !appliedMigrations.some((applied) => applied.name === registered.name); }); - + // Validate dependencies for (const migration of pendingMigrations) { if (!this.validateDependencies(migration, appliedMigrations)) { throw new Error(`Dependency not met for migration: ${migration.name}`); } } - + // Execute pending migrations for (const migration of pendingMigrations) { await this.executeMigration(migration); } - + this.logger.log('All pending migrations completed.'); } @@ -67,40 +67,40 @@ export class MigrationService { */ private async executeMigration(migration: MigrationConfig): Promise { this.logger.log(`Executing migration: ${migration.name}`); - + try { // Check for conflicts const hasConflict = await this.conflictResolutionService.checkForConflicts(migration); if (hasConflict) { await this.conflictResolutionService.resolveConflict(migration); } - + // Validate schema before applying migration await this.schemaValidationService.validateBeforeMigration(migration); - + // Execute the migration const connection = await this.getConnection(); await migration.up(connection); - + // Record migration in the database const migrationRecord = new Migration(); migrationRecord.name = migration.name; migrationRecord.version = migration.version; migrationRecord.status = MigrationStatus.COMPLETED; migrationRecord.appliedAt = new Date(); - + await this.migrationRepository.save(migrationRecord); - + // Sync environment after successful migration await this.environmentSyncService.syncAfterMigration(migration); - + this.logger.log(`Successfully executed migration: ${migration.name}`); } catch (error) { this.logger.error(`Failed to execute migration: ${migration.name}`, error.stack); - + // Attempt rollback on failure await this.rollbackService.rollbackMigration(migration); - + throw error; } } @@ -117,13 +117,16 @@ export class MigrationService { /** * Validates migration dependencies */ - private validateDependencies(migration: MigrationConfig, appliedMigrations: Migration[]): boolean { + private validateDependencies( + migration: MigrationConfig, + appliedMigrations: Migration[], + ): boolean { if (!migration.dependencies || migration.dependencies.length === 0) { return true; } - - return migration.dependencies.every(depName => { - return appliedMigrations.some(m => m.name === depName); + + return migration.dependencies.every((depName) => { + return appliedMigrations.some((m) => m.name === depName); }); } @@ -139,24 +142,28 @@ export class MigrationService { /** * Lists all migrations with their status */ - async listMigrations(): Promise> { + async listMigrations(): Promise< + Array<{ name: string; version: string; status: string; appliedAt?: Date }> + > { const appliedMigrations = await this.migrationRepository.find({ order: { createdAt: 'ASC' }, }); - + const registeredMigrations = this.getRegisteredMigrations(); - + // Combine applied and registered migrations - const allMigrations = [...appliedMigrations.map(m => ({ - name: m.name, - version: m.version, - status: m.status, - appliedAt: m.appliedAt - }))]; - + const allMigrations = [ + ...appliedMigrations.map((m) => ({ + name: m.name, + version: m.version, + status: m.status, + appliedAt: m.appliedAt, + })), + ]; + // Add unapplied migrations - registeredMigrations.forEach(registered => { - const exists = appliedMigrations.some(m => m.name === registered.name); + registeredMigrations.forEach((registered) => { + const exists = appliedMigrations.some((m) => m.name === registered.name); if (!exists) { allMigrations.push({ name: registered.name, @@ -166,7 +173,7 @@ export class MigrationService { }); } }); - + return allMigrations; } @@ -175,23 +182,25 @@ export class MigrationService { */ async resetMigrations(): Promise { this.logger.log('Resetting all migrations...'); - + const appliedMigrations = await this.migrationRepository.find({ order: { createdAt: 'DESC' }, }); - + for (const migration of appliedMigrations) { // Find the actual migration config to get the 'down' function - const registeredMigration = this.getRegisteredMigrations().find(m => m.name === migration.name); - + const registeredMigration = this.getRegisteredMigrations().find( + (m) => m.name === migration.name, + ); + if (registeredMigration) { await this.rollbackService.rollbackMigration(registeredMigration); } - + // Remove from migration history await this.migrationRepository.remove(migration); } - + this.logger.log('All migrations have been reset.'); } -} \ No newline at end of file +} diff --git a/src/migrations/rollback/rollback.service.ts b/src/migrations/rollback/rollback.service.ts index e8df9ad..2c1d4f8 100644 --- a/src/migrations/rollback/rollback.service.ts +++ b/src/migrations/rollback/rollback.service.ts @@ -28,7 +28,7 @@ export class RollbackService { // Update migration record const existingMigration = await this.migrationRepository.findOne({ - where: { name: migration.name } + where: { name: migration.name }, }); if (existingMigration) { @@ -54,15 +54,15 @@ export class RollbackService { const lastAppliedMigrations = await this.migrationRepository.find({ where: { status: MigrationStatus.COMPLETED }, order: { appliedAt: 'DESC' }, - take: count + take: count, }); // Get all registered migrations to find the down functions const registeredMigrations = this.getRegisteredMigrations(); for (const appliedMigration of lastAppliedMigrations) { - const migrationConfig = registeredMigrations.find(m => m.name === appliedMigration.name); - + const migrationConfig = registeredMigrations.find((m) => m.name === appliedMigration.name); + if (migrationConfig) { await this.rollbackMigration(migrationConfig); } else { @@ -80,15 +80,15 @@ export class RollbackService { // Get all applied migrations in reverse order const allAppliedMigrations = await this.migrationRepository.find({ where: { status: MigrationStatus.COMPLETED }, - order: { appliedAt: 'DESC' } + order: { appliedAt: 'DESC' }, }); // Get all registered migrations to find the down functions const registeredMigrations = this.getRegisteredMigrations(); for (const appliedMigration of allAppliedMigrations) { - const migrationConfig = registeredMigrations.find(m => m.name === appliedMigration.name); - + const migrationConfig = registeredMigrations.find((m) => m.name === appliedMigration.name); + if (migrationConfig) { await this.rollbackMigration(migrationConfig); } else { @@ -121,7 +121,7 @@ export class RollbackService { async canRollbackMigration(migrationName: string): Promise { // Check if the migration exists and is completed const migration = await this.migrationRepository.findOne({ - where: { name: migrationName, status: MigrationStatus.COMPLETED } + where: { name: migrationName, status: MigrationStatus.COMPLETED }, }); if (!migration) { @@ -131,7 +131,7 @@ export class RollbackService { // Check if there are dependent migrations that have been applied after this one const laterMigrations = await this.migrationRepository.find({ where: { - appliedAt: Raw(alias => `${alias} > :appliedAt`, { appliedAt: migration.appliedAt }), + appliedAt: Raw((alias) => `${alias} > :appliedAt`, { appliedAt: migration.appliedAt }), }, }); @@ -139,4 +139,4 @@ export class RollbackService { // For now, we'll return true if no later migrations exist return laterMigrations.length === 0; } -} \ No newline at end of file +} diff --git a/src/migrations/samples/sample-user-table.migration.ts b/src/migrations/samples/sample-user-table.migration.ts index d7cbc02..91a19e1 100644 --- a/src/migrations/samples/sample-user-table.migration.ts +++ b/src/migrations/samples/sample-user-table.migration.ts @@ -11,7 +11,7 @@ export class SampleUserTableMigration implements MigrationConfig { async up(connection: any): Promise { this.logger.log('Applying sample user table migration'); - + // In a real implementation, you would use the connection to execute SQL // For example with TypeORM: /* @@ -26,20 +26,20 @@ export class SampleUserTableMigration implements MigrationConfig { ); `); */ - + // Mock implementation for demonstration console.log('Creating users table...'); } async down(connection: any): Promise { this.logger.log('Rolling back sample user table migration'); - + // In a real implementation, you would revert the changes /* await connection.query(`DROP TABLE IF EXISTS users;`); */ - + // Mock implementation for demonstration console.log('Dropping users table...'); } -} \ No newline at end of file +} diff --git a/src/migrations/validation/schema-validation.service.ts b/src/migrations/validation/schema-validation.service.ts index 60076c0..1750f22 100644 --- a/src/migrations/validation/schema-validation.service.ts +++ b/src/migrations/validation/schema-validation.service.ts @@ -15,7 +15,7 @@ export class SchemaValidationService { try { // Perform pre-migration validation checks const isValid = await this.performPreMigrationValidation(migration); - + if (!isValid) { throw new BadRequestException(`Schema validation failed for migration: ${migration.name}`); } @@ -37,15 +37,20 @@ export class SchemaValidationService { try { // Perform post-migration validation checks const isValid = await this.performPostMigrationValidation(migration); - + if (!isValid) { - throw new BadRequestException(`Post-migration schema validation failed for: ${migration.name}`); + throw new BadRequestException( + `Post-migration schema validation failed for: ${migration.name}`, + ); } this.logger.log(`Post-migration schema validation passed for: ${migration.name}`); return true; } catch (error) { - this.logger.error(`Post-migration schema validation failed for: ${migration.name}`, error.stack); + this.logger.error( + `Post-migration schema validation failed for: ${migration.name}`, + error.stack, + ); throw error; } } @@ -56,7 +61,7 @@ export class SchemaValidationService { private async performPreMigrationValidation(migration: MigrationConfig): Promise { // Check if required tables/columns exist before running migration // This is a simplified version - in practice, you'd check for dependencies - + // For now, just return true return true; } @@ -67,7 +72,7 @@ export class SchemaValidationService { private async performPostMigrationValidation(migration: MigrationConfig): Promise { // Check if the expected schema changes were applied correctly // This would involve checking if tables/columns exist as expected after migration - + // For now, just return true return true; } @@ -99,7 +104,7 @@ export class SchemaValidationService { // Analyze the migration to detect potential breaking changes // This is a simplified implementation - in practice, you'd parse the SQL operations // and check for things like dropping columns/tables, changing data types, etc. - + // For now, return an empty array return breakingChanges; } @@ -115,7 +120,10 @@ export class SchemaValidationService { // For now, return a placeholder return `backup_${migration.name}_${new Date().toISOString()}`; } catch (error) { - this.logger.error(`Failed to create schema backup for migration: ${migration.name}`, error.stack); + this.logger.error( + `Failed to create schema backup for migration: ${migration.name}`, + error.stack, + ); return null; } } @@ -123,20 +131,25 @@ export class SchemaValidationService { /** * Validates migration dependencies */ - async validateMigrationDependencies(migration: MigrationConfig, appliedMigrations: string[]): Promise { + async validateMigrationDependencies( + migration: MigrationConfig, + appliedMigrations: string[], + ): Promise { if (!migration.dependencies || migration.dependencies.length === 0) { return true; } const missingDependencies = migration.dependencies.filter( - dep => !appliedMigrations.includes(dep) + (dep) => !appliedMigrations.includes(dep), ); if (missingDependencies.length > 0) { - this.logger.error(`Missing dependencies for migration ${migration.name}: ${missingDependencies.join(', ')}`); + this.logger.error( + `Missing dependencies for migration ${migration.name}: ${missingDependencies.join(', ')}`, + ); return false; } return true; } -} \ No newline at end of file +} diff --git a/src/moderation/auto/auto-moderation.service.ts b/src/moderation/auto/auto-moderation.service.ts index fb4cd5e..687cf58 100644 --- a/src/moderation/auto/auto-moderation.service.ts +++ b/src/moderation/auto/auto-moderation.service.ts @@ -16,7 +16,7 @@ export class AutoModerationService { }); // result is an array of { label, score } - const toxicLabel = result.find(r => r.label.toLowerCase().includes('toxic')); + const toxicLabel = result.find((r) => r.label.toLowerCase().includes('toxic')); const score = toxicLabel ? toxicLabel.score : 0; return { diff --git a/src/monitoring/logging/typeorm-logger.ts b/src/monitoring/logging/typeorm-logger.ts index 11566c4..e0eb03b 100644 --- a/src/monitoring/logging/typeorm-logger.ts +++ b/src/monitoring/logging/typeorm-logger.ts @@ -5,36 +5,41 @@ export class TypeOrmMonitoringLogger implements Logger { constructor(private readonly metricsService: MetricsCollectionService) {} logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { - // Optional: console.log(`[Query]: ${query}`); + // Optional: console.log(`[Query]: ${query}`); } - logQueryError(error: string | Error, query: string, parameters?: any[], queryRunner?: QueryRunner) { - console.error(`[Query Error]: ${error}`, query); + logQueryError( + error: string | Error, + query: string, + parameters?: any[], + queryRunner?: QueryRunner, + ) { + console.error(`[Query Error]: ${error}`, query); } logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) { - console.warn(`[Slow Query]: ${time}ms - ${query}`); - const table = this.extractTable(query); - // time is in milliseconds, convert to seconds - this.metricsService.recordDbQuery('slow_query', table, time / 1000); + console.warn(`[Slow Query]: ${time}ms - ${query}`); + const table = this.extractTable(query); + // time is in milliseconds, convert to seconds + this.metricsService.recordDbQuery('slow_query', table, time / 1000); } logSchemaBuild(message: string, queryRunner?: QueryRunner) { - console.log(`[Schema Build]: ${message}`); + console.log(`[Schema Build]: ${message}`); } logMigration(message: string, queryRunner?: QueryRunner) { - console.log(`[Migration]: ${message}`); + console.log(`[Migration]: ${message}`); } log(level: 'log' | 'info' | 'warn', message: any, queryRunner?: QueryRunner) { - switch (level) { - case 'log': - case 'info': - console.log(`[TypeORM]: ${message}`); - break; - case 'warn': - console.warn(`[TypeORM]: ${message}`); - break; - } + switch (level) { + case 'log': + case 'info': + console.log(`[TypeORM]: ${message}`); + break; + case 'warn': + console.warn(`[TypeORM]: ${message}`); + break; + } } private extractTable(query: string): string { diff --git a/src/monitoring/metrics/metrics-collection.service.ts b/src/monitoring/metrics/metrics-collection.service.ts index b629bd4..47f3589 100644 --- a/src/monitoring/metrics/metrics-collection.service.ts +++ b/src/monitoring/metrics/metrics-collection.service.ts @@ -10,7 +10,7 @@ export class MetricsCollectionService implements OnModuleInit { constructor() { this.registry = new Registry(); - + // HTTP Request Duration this.httpRequestDuration = new Histogram({ name: 'http_request_duration_seconds', diff --git a/src/monitoring/monitoring.service.ts b/src/monitoring/monitoring.service.ts index b7a88e5..ac3d4cc 100644 --- a/src/monitoring/monitoring.service.ts +++ b/src/monitoring/monitoring.service.ts @@ -18,19 +18,27 @@ export class MonitoringService { async handleCron() { this.logger.debug('Running system performance analysis...'); const analysis = await this.analysisService.analyze(); - + // Check for alerts if (analysis.cpuLoad > 80) { - this.alertingService.sendAlert('CPU_HIGH', `CPU Load is at ${analysis.cpuLoad.toFixed(2)}%`, 'WARNING'); + this.alertingService.sendAlert( + 'CPU_HIGH', + `CPU Load is at ${analysis.cpuLoad.toFixed(2)}%`, + 'WARNING', + ); } if (analysis.memoryUsage > 90) { - this.alertingService.sendAlert('MEMORY_HIGH', `Memory Usage is at ${analysis.memoryUsage.toFixed(2)}%`, 'WARNING'); + this.alertingService.sendAlert( + 'MEMORY_HIGH', + `Memory Usage is at ${analysis.memoryUsage.toFixed(2)}%`, + 'WARNING', + ); } // Get optimization recommendations const recommendations = this.optimizationService.getOptimizationRecommendations(analysis); if (recommendations.length > 0) { - this.logger.log(`Optimization Recommendations: ${JSON.stringify(recommendations)}`); + this.logger.log(`Optimization Recommendations: ${JSON.stringify(recommendations)}`); } } } diff --git a/src/monitoring/optimization/optimization.service.ts b/src/monitoring/optimization/optimization.service.ts index 1f763ea..82f75e0 100644 --- a/src/monitoring/optimization/optimization.service.ts +++ b/src/monitoring/optimization/optimization.service.ts @@ -8,17 +8,25 @@ export class OptimizationService { const recommendations: string[] = []; if (analysisResult.cpuLoad && analysisResult.cpuLoad > 80) { - recommendations.push('High CPU usage detected. Consider horizontal scaling or optimizing CPU-intensive tasks.'); + recommendations.push( + 'High CPU usage detected. Consider horizontal scaling or optimizing CPU-intensive tasks.', + ); } if (analysisResult.memoryUsage && analysisResult.memoryUsage > 85) { - recommendations.push('High Memory usage detected. Check for memory leaks or increase heap size.'); + recommendations.push( + 'High Memory usage detected. Check for memory leaks or increase heap size.', + ); } if (analysisResult.slowQueries && analysisResult.slowQueries.length > 0) { - recommendations.push(`Detected ${analysisResult.slowQueries.length} slow database queries. Consider adding indexes or optimizing query structure.`); + recommendations.push( + `Detected ${analysisResult.slowQueries.length} slow database queries. Consider adding indexes or optimizing query structure.`, + ); analysisResult.slowQueries.forEach((query: any) => { - recommendations.push(`- Optimize query on table '${query.table}' (Avg duration: ${query.duration}s)`); + recommendations.push( + `- Optimize query on table '${query.table}' (Avg duration: ${query.duration}s)`, + ); }); } diff --git a/src/monitoring/performance/performance-analysis.service.ts b/src/monitoring/performance/performance-analysis.service.ts index cdbecfe..97316c6 100644 --- a/src/monitoring/performance/performance-analysis.service.ts +++ b/src/monitoring/performance/performance-analysis.service.ts @@ -10,14 +10,14 @@ export class PerformanceAnalysisService { async analyze(): Promise { const metrics = await this.metricsService.getRegistry().getMetricsAsJSON(); - + // System Analysis const cpus = os.cpus(); const loadAvg = os.loadavg(); // [1min, 5min, 15min] const totalMem = os.totalmem(); const freeMem = os.freemem(); const memoryUsagePercent = ((totalMem - freeMem) / totalMem) * 100; - + // CPU Load approximation (Load Average / Number of CPUs) // On Windows, loadavg returns [0, 0, 0], so we might need a fallback or just report 0. const cpuLoadPercent = cpus.length > 0 ? (loadAvg[0] / cpus.length) * 100 : 0; @@ -25,26 +25,25 @@ export class PerformanceAnalysisService { // Analyze Slow Queries from our custom Histogram // We iterate through metrics to find db_query_duration_seconds const slowQueries = []; - const dbMetric = metrics.find(m => m.name === 'db_query_duration_seconds'); - + const dbMetric = metrics.find((m) => m.name === 'db_query_duration_seconds'); + if (dbMetric && (dbMetric as any).values) { - // Check for queries falling into buckets > 1 second - // Histogram values are flattened. We look for bucket with le="2" or le="+Inf" and compare counts. - // For this simple implementation, we'll just check if we have recorded any high duration queries recently. - // In a real system, we'd query Prometheus. Here we check the metric state. - - // This is a simplified check - // We can also check specific tracked slow queries if we stored them separately. + // Check for queries falling into buckets > 1 second + // Histogram values are flattened. We look for bucket with le="2" or le="+Inf" and compare counts. + // For this simple implementation, we'll just check if we have recorded any high duration queries recently. + // In a real system, we'd query Prometheus. Here we check the metric state. + // This is a simplified check + // We can also check specific tracked slow queries if we stored them separately. } // Mock detection of slow queries for demonstration if metrics are empty // In production, this would parse the histogram buckets - + return { timestamp: new Date(), cpuLoad: cpuLoadPercent, memoryUsage: memoryUsagePercent, - slowQueries: slowQueries + slowQueries, }; } } diff --git a/src/observability/anomaly/anomaly-detection.service.ts b/src/observability/anomaly/anomaly-detection.service.ts index 594b0d2..8cc56c1 100644 --- a/src/observability/anomaly/anomaly-detection.service.ts +++ b/src/observability/anomaly/anomaly-detection.service.ts @@ -23,16 +23,14 @@ export class AnomalyDetectionService { failureRate: 10, // 10% failure rate }; - constructor( - private readonly metricsService: MetricsAnalysisService, - ) {} + constructor(private readonly metricsService: MetricsAnalysisService) {} /** * Detect anomalies in a metric using statistical methods */ detectAnomalies(metricName: string, windowSize: number = 100): AnomalyDetectionResult[] { const metrics = this.metricsService.getMetrics(metricName, windowSize); - + if (metrics.length < 10) { return []; // Not enough data } @@ -49,7 +47,7 @@ export class AnomalyDetectionService { metrics.forEach((metric) => { const zScore = Math.abs((metric.value - mean) / stdDev); - + if (zScore > threshold) { const anomaly: AnomalyDetectionResult = { isAnomaly: true, @@ -77,7 +75,7 @@ export class AnomalyDetectionService { threshold: number = 2, ): AnomalyDetectionResult[] { const metrics = this.metricsService.getMetrics(metricName, windowSize * 2); - + if (metrics.length < windowSize) { return []; } @@ -113,7 +111,7 @@ export class AnomalyDetectionService { */ checkErrorRateAnomaly(): AnomalyDetectionResult | null { const stats = this.metricsService.getMetricStatistics('api.response_time'); - + if (!stats) return null; // Calculate error rate from response codes @@ -142,7 +140,7 @@ export class AnomalyDetectionService { */ checkResponseTimeAnomaly(): AnomalyDetectionResult | null { const stats = this.metricsService.getMetricStatistics('api.response_time'); - + if (!stats) return null; if (stats.p95 > this.thresholds.responseTime) { @@ -167,7 +165,7 @@ export class AnomalyDetectionService { */ checkMemoryAnomaly(): AnomalyDetectionResult | null { const stats = this.metricsService.getMetricStatistics('system.memory.heap_used'); - + if (!stats) return null; const memoryUsagePercent = (stats.avg / (1024 * 1024 * 1024)) * 100; // Convert to GB and percentage @@ -194,7 +192,7 @@ export class AnomalyDetectionService { */ detectSuddenSpike(metricName: string, spikeThreshold: number = 3): AnomalyDetectionResult | null { const metrics = this.metricsService.getMetrics(metricName, 10); - + if (metrics.length < 2) return null; const latest = metrics[metrics.length - 1].value; @@ -301,7 +299,6 @@ export class AnomalyDetectionService { keyMetrics.forEach((metric) => { this.detectAnomalies(metric); }); - } catch (error) { this.logger.error('Error during periodic anomaly detection:', error); } @@ -327,7 +324,7 @@ export class AnomalyDetectionService { */ getAnomalyStatistics() { const byMetric: Record = {}; - + this.anomalies.forEach((anomaly) => { byMetric[anomaly.metric] = (byMetric[anomaly.metric] || 0) + 1; }); @@ -338,9 +335,10 @@ export class AnomalyDetectionService { total: this.anomalies.length, recent: recentAnomalies.length, byMetric, - avgScore: this.anomalies.length > 0 - ? this.anomalies.reduce((sum, a) => sum + a.score, 0) / this.anomalies.length - : 0, + avgScore: + this.anomalies.length > 0 + ? this.anomalies.reduce((sum, a) => sum + a.score, 0) / this.anomalies.length + : 0, }; } diff --git a/src/observability/logging/log-aggregation.service.ts b/src/observability/logging/log-aggregation.service.ts index 69a3cb0..eaded48 100644 --- a/src/observability/logging/log-aggregation.service.ts +++ b/src/observability/logging/log-aggregation.service.ts @@ -44,9 +44,7 @@ export class LogAggregationService { } if (query.service) { - filteredLogs = filteredLogs.filter( - (log) => log.context.service === query.service, - ); + filteredLogs = filteredLogs.filter((log) => log.context.service === query.service); } if (query.correlationId) { @@ -56,21 +54,15 @@ export class LogAggregationService { } if (query.userId) { - filteredLogs = filteredLogs.filter( - (log) => log.context.userId === query.userId, - ); + filteredLogs = filteredLogs.filter((log) => log.context.userId === query.userId); } if (query.startTime) { - filteredLogs = filteredLogs.filter( - (log) => log.context.timestamp >= query.startTime!, - ); + filteredLogs = filteredLogs.filter((log) => log.context.timestamp >= query.startTime!); } if (query.endTime) { - filteredLogs = filteredLogs.filter( - (log) => log.context.timestamp <= query.endTime!, - ); + filteredLogs = filteredLogs.filter((log) => log.context.timestamp <= query.endTime!); } if (query.search) { @@ -78,17 +70,12 @@ export class LogAggregationService { filteredLogs = filteredLogs.filter( (log) => log.message.toLowerCase().includes(searchLower) || - JSON.stringify(log.context.metadata) - .toLowerCase() - .includes(searchLower), + JSON.stringify(log.context.metadata).toLowerCase().includes(searchLower), ); } // Sort by timestamp (newest first) - filteredLogs.sort( - (a, b) => - b.context.timestamp.getTime() - a.context.timestamp.getTime(), - ); + filteredLogs.sort((a, b) => b.context.timestamp.getTime() - a.context.timestamp.getTime()); // Pagination const limit = query.limit || 50; @@ -107,9 +94,7 @@ export class LogAggregationService { * Get logs by correlation ID (trace all related logs) */ async getLogsByCorrelationId(correlationId: string): Promise { - return this.logs.filter( - (log) => log.context.correlationId === correlationId, - ); + return this.logs.filter((log) => log.context.correlationId === correlationId); } /** @@ -147,9 +132,7 @@ export class LogAggregationService { if (timeRange) { logsToAnalyze = this.logs.filter( - (log) => - log.context.timestamp >= timeRange.start && - log.context.timestamp <= timeRange.end, + (log) => log.context.timestamp >= timeRange.start && log.context.timestamp <= timeRange.end, ); } @@ -200,9 +183,7 @@ export class LogAggregationService { */ async clearOldLogs(olderThan: Date): Promise { const initialCount = this.logs.length; - this.logs = this.logs.filter( - (log) => log.context.timestamp > olderThan, - ); + this.logs = this.logs.filter((log) => log.context.timestamp > olderThan); const removed = initialCount - this.logs.length; this.logger.log(`Cleared ${removed} old logs`); return removed; diff --git a/src/observability/logging/structured-logger.service.ts b/src/observability/logging/structured-logger.service.ts index 0fc1a6d..a043ad7 100644 --- a/src/observability/logging/structured-logger.service.ts +++ b/src/observability/logging/structured-logger.service.ts @@ -78,10 +78,7 @@ export class StructuredLoggerService implements LoggerService { level = LogLevel.INFO; message = messageOrLevel; meta = undefined; - } else if ( - typeof messageOrLevel === 'string' && - typeof messageOrMetadata === 'object' - ) { + } else if (typeof messageOrLevel === 'string' && typeof messageOrMetadata === 'object') { // log(message, metadata) level = LogLevel.INFO; message = messageOrLevel; @@ -108,11 +105,7 @@ export class StructuredLoggerService implements LoggerService { */ error(message: string, trace?: string, metadata?: Record): void; error(message: string, error?: Error, metadata?: Record): void; - error( - message: string, - traceOrError?: string | Error, - metadata?: Record, - ): void { + error(message: string, traceOrError?: string | Error, metadata?: Record): void { let errorDetails: ErrorDetails | undefined; if (traceOrError instanceof Error) { @@ -124,7 +117,7 @@ export class StructuredLoggerService implements LoggerService { } else if (typeof traceOrError === 'string') { errorDetails = { name: 'Error', - message: message, + message, stack: traceOrError, }; } @@ -241,11 +234,7 @@ export class StructuredLoggerService implements LoggerService { /** * Log database query */ - logQuery( - query: string, - duration: number, - metadata?: Record, - ): void { + logQuery(query: string, duration: number, metadata?: Record): void { this.log(LogLevel.DEBUG, 'Database query executed', { ...metadata, query, @@ -257,10 +246,7 @@ export class StructuredLoggerService implements LoggerService { /** * Log business event */ - logBusinessEvent( - eventName: string, - eventData: Record, - ): void { + logBusinessEvent(eventName: string, eventData: Record): void { this.log(LogLevel.INFO, `Business event: ${eventName}`, { ...eventData, eventName, diff --git a/src/observability/metrics/metrics-analysis.service.ts b/src/observability/metrics/metrics-analysis.service.ts index 5eee43a..153c02f 100644 --- a/src/observability/metrics/metrics-analysis.service.ts +++ b/src/observability/metrics/metrics-analysis.service.ts @@ -187,7 +187,7 @@ export class MetricsAnalysisService { */ getAllMetricsStatistics() { const stats: Record = {}; - + this.metrics.forEach((_, name) => { stats[name] = this.getMetricStatistics(name); }); @@ -333,9 +333,7 @@ export class MetricsAnalysisService { .join(',') : ''; - lines.push( - `${sanitizedName}${tags ? `{${tags}}` : ''} ${latestMetric.value}`, - ); + lines.push(`${sanitizedName}${tags ? `{${tags}}` : ''} ${latestMetric.value}`); }); return lines.join('\n'); diff --git a/src/observability/observability.controller.ts b/src/observability/observability.controller.ts index 888bdba..fa24625 100644 --- a/src/observability/observability.controller.ts +++ b/src/observability/observability.controller.ts @@ -57,9 +57,7 @@ export class ObservabilityController { */ @Get('logs/errors') async getErrorLogs(@Query('limit') limit?: number) { - return this.logAggregation.getErrorLogs( - limit ? parseInt(limit.toString()) : 100, - ); + return this.logAggregation.getErrorLogs(limit ? parseInt(limit.toString()) : 100); } /** @@ -71,9 +69,7 @@ export class ObservabilityController { @Query('endTime') endTime?: string, ) { const timeRange = - startTime && endTime - ? { start: new Date(startTime), end: new Date(endTime) } - : undefined; + startTime && endTime ? { start: new Date(startTime), end: new Date(endTime) } : undefined; return this.logAggregation.getLogStatistics(timeRange); } @@ -83,9 +79,7 @@ export class ObservabilityController { */ @Get('logs/recent') async getRecentLogs(@Query('limit') limit?: number) { - return this.logAggregation.getRecentLogs( - limit ? parseInt(limit.toString()) : 100, - ); + return this.logAggregation.getRecentLogs(limit ? parseInt(limit.toString()) : 100); } /** @@ -116,14 +110,8 @@ export class ObservabilityController { * Get metrics */ @Get('metrics/:name') - async getMetrics( - @Query('name') name: string, - @Query('limit') limit?: number, - ) { - return this.metrics.getMetrics( - name, - limit ? parseInt(limit.toString()) : undefined, - ); + async getMetrics(@Query('name') name: string, @Query('limit') limit?: number) { + return this.metrics.getMetrics(name, limit ? parseInt(limit.toString()) : undefined); } /** @@ -136,9 +124,7 @@ export class ObservabilityController { @Query('endTime') endTime?: string, ) { const timeRange = - startTime && endTime - ? { start: new Date(startTime), end: new Date(endTime) } - : undefined; + startTime && endTime ? { start: new Date(startTime), end: new Date(endTime) } : undefined; return this.metrics.getMetricStatistics(name, timeRange); } @@ -175,9 +161,7 @@ export class ObservabilityController { */ @Get('anomalies') async getAnomalies(@Query('limit') limit?: number) { - return this.anomalyDetection.getAnomalies( - limit ? parseInt(limit.toString()) : undefined, - ); + return this.anomalyDetection.getAnomalies(limit ? parseInt(limit.toString()) : undefined); } /** @@ -193,9 +177,7 @@ export class ObservabilityController { */ @Get('anomalies/recent') async getRecentAnomalies(@Query('minutes') minutes?: number) { - return this.anomalyDetection.getRecentAnomalies( - minutes ? parseInt(minutes.toString()) : 60, - ); + return this.anomalyDetection.getRecentAnomalies(minutes ? parseInt(minutes.toString()) : 60); } /** @@ -218,12 +200,7 @@ export class ObservabilityController { * Detect anomalies in a metric */ @Post('anomalies/detect') - async detectAnomalies( - @Body() body: { metricName: string; windowSize?: number }, - ) { - return this.anomalyDetection.detectAnomalies( - body.metricName, - body.windowSize, - ); + async detectAnomalies(@Body() body: { metricName: string; windowSize?: number }) { + return this.anomalyDetection.detectAnomalies(body.metricName, body.windowSize); } } diff --git a/src/observability/observability.service.ts b/src/observability/observability.service.ts index 65ed385..56d2837 100644 --- a/src/observability/observability.service.ts +++ b/src/observability/observability.service.ts @@ -25,13 +25,7 @@ export class ObservabilityService { * Get comprehensive observability dashboard */ async getObservabilityDashboard() { - const [ - logStats, - traceStats, - metricsStats, - anomalyStats, - systemHealth, - ] = await Promise.all([ + const [logStats, traceStats, metricsStats, anomalyStats, systemHealth] = await Promise.all([ this.logAggregation.getLogStatistics(), this.tracing.getTraceStatistics(), this.metrics.getAllMetricsStatistics(), @@ -104,7 +98,7 @@ export class ObservabilityService { correlationId: string, ): Promise { const startTime = new Date(); - + // Initialize logging context this.initializeRequestObservability(correlationId); @@ -131,10 +125,7 @@ export class ObservabilityService { const duration = Date.now() - startTime.getTime(); // Log error - this.structuredLogger.error( - `Request failed: ${method} ${url}`, - error as Error, - ); + this.structuredLogger.error(`Request failed: ${method} ${url}`, error as Error); // Track metrics this.metrics.trackApiResponseTime(url, duration, 500); diff --git a/src/observability/tracing/distributed-tracing.service.ts b/src/observability/tracing/distributed-tracing.service.ts index 906bbe0..eb9438c 100644 --- a/src/observability/tracing/distributed-tracing.service.ts +++ b/src/observability/tracing/distributed-tracing.service.ts @@ -107,11 +107,7 @@ export class DistributedTracingService { /** * Add an event to a span */ - addSpanEvent( - span: Span, - name: string, - attributes?: Record, - ): void { + addSpanEvent(span: Span, name: string, attributes?: Record): void { span.addEvent(name, attributes); const spanContext = span.spanContext(); @@ -159,18 +155,18 @@ export class DistributedTracingService { */ createChildSpan(parentSpan: Span, name: string, attributes?: Record): Span { const ctx = trace.setSpan(context.active(), parentSpan); - + return context.with(ctx, () => { const childSpan = this.startSpan(name, attributes); - + const parentContext = parentSpan.spanContext(); const childContext = childSpan.spanContext(); const childTraceSpan = this.spans.get(childContext.spanId); - + if (childTraceSpan) { childTraceSpan.parentSpanId = parentContext.spanId; } - + return childSpan; }); } @@ -198,9 +194,7 @@ export class DistributedTracingService { return { traceId: headers['x-trace-id'], spanId: headers['x-span-id'], - traceFlags: headers['x-trace-flags'] - ? parseInt(headers['x-trace-flags']) - : undefined, + traceFlags: headers['x-trace-flags'] ? parseInt(headers['x-trace-flags']) : undefined, }; } @@ -208,9 +202,7 @@ export class DistributedTracingService { * Get trace by ID */ getTraceById(traceId: string): TraceSpan[] { - return Array.from(this.spans.values()).filter( - (span) => span.traceId === traceId, - ); + return Array.from(this.spans.values()).filter((span) => span.traceId === traceId); } /** @@ -249,17 +241,14 @@ export class DistributedTracingService { const spans = Array.from(this.spans.values()); const completedSpans = spans.filter((s) => s.endTime); - const durations = completedSpans - .filter((s) => s.duration) - .map((s) => s.duration!); + const durations = completedSpans.filter((s) => s.duration).map((s) => s.duration!); return { total: spans.length, completed: completedSpans.length, active: spans.length - completedSpans.length, - avgDuration: durations.length > 0 - ? durations.reduce((a, b) => a + b, 0) / durations.length - : 0, + avgDuration: + durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0, minDuration: durations.length > 0 ? Math.min(...durations) : 0, maxDuration: durations.length > 0 ? Math.max(...durations) : 0, errorCount: spans.filter((s) => s.status === SpanStatus.ERROR).length, @@ -274,33 +263,22 @@ export class DistributedTracingService { url: string, fn: (span: Span) => Promise, ): Promise { - return this.executeInSpan( - `HTTP ${method} ${url}`, - fn, - { - 'http.method': method, - 'http.url': url, - 'span.kind': 'client', - }, - ); + return this.executeInSpan(`HTTP ${method} ${url}`, fn, { + 'http.method': method, + 'http.url': url, + 'span.kind': 'client', + }); } /** * Trace database query */ - async traceDatabaseQuery( - query: string, - fn: (span: Span) => Promise, - ): Promise { - return this.executeInSpan( - 'Database Query', - fn, - { - 'db.statement': query, - 'db.system': 'postgresql', - 'span.kind': 'client', - }, - ); + async traceDatabaseQuery(query: string, fn: (span: Span) => Promise): Promise { + return this.executeInSpan('Database Query', fn, { + 'db.statement': query, + 'db.system': 'postgresql', + 'span.kind': 'client', + }); } /** @@ -311,14 +289,10 @@ export class DistributedTracingService { operation: string, fn: (span: Span) => Promise, ): Promise { - return this.executeInSpan( - `${serviceName}.${operation}`, - fn, - { - 'service.name': serviceName, - 'operation': operation, - 'span.kind': 'client', - }, - ); + return this.executeInSpan(`${serviceName}.${operation}`, fn, { + 'service.name': serviceName, + operation, + 'span.kind': 'client', + }); } } diff --git a/src/orchestration/discovery/service-discovery.service.ts b/src/orchestration/discovery/service-discovery.service.ts index 56b7e13..e0a1ed5 100644 --- a/src/orchestration/discovery/service-discovery.service.ts +++ b/src/orchestration/discovery/service-discovery.service.ts @@ -28,4 +28,4 @@ export class ServiceDiscoveryService { service.healthy = false; } } -} \ No newline at end of file +} diff --git a/src/orchestration/health/health-checker.service.ts b/src/orchestration/health/health-checker.service.ts index de46a7e..78894ee 100644 --- a/src/orchestration/health/health-checker.service.ts +++ b/src/orchestration/health/health-checker.service.ts @@ -17,4 +17,4 @@ export class HealthCheckerService { return false; } } -} \ No newline at end of file +} diff --git a/src/orchestration/locks/distributed-lock.service.ts b/src/orchestration/locks/distributed-lock.service.ts index 3464644..9ab2707 100644 --- a/src/orchestration/locks/distributed-lock.service.ts +++ b/src/orchestration/locks/distributed-lock.service.ts @@ -13,4 +13,4 @@ export class DistributedLockService { async releaseLock(key: string): Promise { await this.redis.del(key); } -} \ No newline at end of file +} diff --git a/src/orchestration/orchestration.module.ts b/src/orchestration/orchestration.module.ts index a8178fd..3602ba8 100644 --- a/src/orchestration/orchestration.module.ts +++ b/src/orchestration/orchestration.module.ts @@ -22,4 +22,4 @@ import { HealthCheckerService } from './health/health-checker.service'; HealthCheckerService, ], }) -export class OrchestrationModule {} \ No newline at end of file +export class OrchestrationModule {} diff --git a/src/orchestration/service-mesh/service-mesh.service.ts b/src/orchestration/service-mesh/service-mesh.service.ts index 802b09c..802124c 100644 --- a/src/orchestration/service-mesh/service-mesh.service.ts +++ b/src/orchestration/service-mesh/service-mesh.service.ts @@ -36,4 +36,4 @@ export class ServiceMeshService { throw error; } } -} \ No newline at end of file +} diff --git a/src/orchestration/workflow/workflow-engine.service.ts b/src/orchestration/workflow/workflow-engine.service.ts index eda5c19..2cc39c2 100644 --- a/src/orchestration/workflow/workflow-engine.service.ts +++ b/src/orchestration/workflow/workflow-engine.service.ts @@ -20,7 +20,7 @@ export class WorkflowEngineService { } } catch (error) { this.logger.error('Workflow failed. Triggering compensation.'); - + for (const step of completedSteps.reverse()) { if (step.compensate) { await step.compensate(); @@ -30,4 +30,4 @@ export class WorkflowEngineService { throw error; } } -} \ No newline at end of file +} diff --git a/src/payments/dto/create-subscription.dto.ts b/src/payments/dto/create-subscription.dto.ts index 0b98fac..4de9213 100644 --- a/src/payments/dto/create-subscription.dto.ts +++ b/src/payments/dto/create-subscription.dto.ts @@ -23,4 +23,4 @@ export class CreateSubscriptionDto { @IsOptional() metadata?: Record; -} \ No newline at end of file +} diff --git a/src/payments/dto/refund.dto.ts b/src/payments/dto/refund.dto.ts index 9f354de..8e07a18 100644 --- a/src/payments/dto/refund.dto.ts +++ b/src/payments/dto/refund.dto.ts @@ -23,4 +23,4 @@ export class RefundDto { @IsEnum(RefundStatus) @IsOptional() status?: RefundStatus; -} \ No newline at end of file +} diff --git a/src/payments/entities/invoice.entity.ts b/src/payments/entities/invoice.entity.ts index cfe95ae..31ca515 100644 --- a/src/payments/entities/invoice.entity.ts +++ b/src/payments/entities/invoice.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Payment } from './payment.entity'; import { User } from '../../users/entities/user.entity'; @@ -32,7 +40,11 @@ export class Invoice { @Column({ type: 'jsonb' }) items: InvoiceItem[]; - @Column({ type: 'enum', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'], default: 'draft' }) + @Column({ + type: 'enum', + enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'], + default: 'draft', + }) status: string; @Column({ type: 'date', nullable: true }) @@ -66,4 +78,4 @@ export class Invoice { @UpdateDateColumn() updatedAt: Date; -} \ No newline at end of file +} diff --git a/src/payments/entities/payment.entity.ts b/src/payments/entities/payment.entity.ts index 3421c89..3745d5d 100644 --- a/src/payments/entities/payment.entity.ts +++ b/src/payments/entities/payment.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Course } from '../../courses/entities/course.entity'; @@ -70,4 +78,4 @@ export class Payment { @UpdateDateColumn() updatedAt: Date; -} \ No newline at end of file +} diff --git a/src/payments/entities/refund.entity.ts b/src/payments/entities/refund.entity.ts index 1dd05df..1053a01 100644 --- a/src/payments/entities/refund.entity.ts +++ b/src/payments/entities/refund.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Payment } from './payment.entity'; export enum RefundStatus { @@ -44,4 +52,4 @@ export class Refund { @UpdateDateColumn() updatedAt: Date; -} \ No newline at end of file +} diff --git a/src/payments/entities/subscription.entity.ts b/src/payments/entities/subscription.entity.ts index 7a42391..5410da4 100644 --- a/src/payments/entities/subscription.entity.ts +++ b/src/payments/entities/subscription.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; export enum SubscriptionStatus { @@ -67,4 +75,4 @@ export class Subscription { @UpdateDateColumn() updatedAt: Date; -} \ No newline at end of file +} diff --git a/src/payments/payments.controller.spec.ts b/src/payments/payments.controller.spec.ts index 8f91015..c10d32e 100644 --- a/src/payments/payments.controller.spec.ts +++ b/src/payments/payments.controller.spec.ts @@ -57,7 +57,7 @@ describe('PaymentsController', () => { ); await expectValidationFailure(() => - controller.processRefund({ paymentId: 'payment-1', amount: -1 }), + controller.processRefund({ paymentId: 'payment-1', amount: -1, reason: 'duplicate' }), ); }); diff --git a/src/payments/payments.controller.ts b/src/payments/payments.controller.ts index 32af43a..659cdcd 100644 --- a/src/payments/payments.controller.ts +++ b/src/payments/payments.controller.ts @@ -10,12 +10,7 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, -} from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { PaymentsService } from './payments.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -35,28 +30,16 @@ export class PaymentsController { @Roles(UserRole.STUDENT, UserRole.TEACHER) @ApiOperation({ summary: 'Create a payment intent for course purchase' }) @ApiResponse({ status: 201, description: 'Payment intent created' }) - async createPaymentIntent( - @Request() req, - @Body() createPaymentDto: CreatePaymentDto, - ) { - return this.paymentsService.createPaymentIntent( - req.user.id, - createPaymentDto, - ); + async createPaymentIntent(@Request() req, @Body() createPaymentDto: CreatePaymentDto) { + return this.paymentsService.createPaymentIntent(req.user.id, createPaymentDto); } @Post('subscriptions') @Roles(UserRole.STUDENT, UserRole.TEACHER) @ApiOperation({ summary: 'Create a subscription for premium course' }) @ApiResponse({ status: 201, description: 'Subscription created' }) - async createSubscription( - @Request() req, - @Body() createSubscriptionDto: CreateSubscriptionDto, - ) { - return this.paymentsService.createSubscription( - req.user.id, - createSubscriptionDto, - ); + async createSubscription(@Request() req, @Body() createSubscriptionDto: CreateSubscriptionDto) { + return this.paymentsService.createSubscription(req.user.id, createSubscriptionDto); } @Post('refund') @@ -94,4 +77,4 @@ export class PaymentsController { async getUserSubscriptions(@Request() req) { return this.paymentsService.getUserSubscriptions(req.user.id); } -} \ No newline at end of file +} diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 6acde01..39cf7d2 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -34,4 +34,4 @@ import { UsersModule } from '../users/users.module'; ], exports: [PaymentsService, ProviderFactoryService], }) -export class PaymentsModule {} \ No newline at end of file +export class PaymentsModule {} diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts index 4c08994..8443f6c 100644 --- a/src/payments/payments.service.ts +++ b/src/payments/payments.service.ts @@ -64,15 +64,11 @@ export class PaymentsService { const paymentProvider = this.getProvider(provider); // Create payment intent - const paymentIntent = await paymentProvider.createPaymentIntent( - amount, - currency, - { - ...metadata, - userId, - courseId, - }, - ); + const paymentIntent = await paymentProvider.createPaymentIntent(amount, currency, { + ...metadata, + userId, + courseId, + }); // Create payment record const payment = this.paymentRepository.create({ @@ -154,10 +150,7 @@ export class PaymentsService { const paymentProvider = this.getProvider(payment.provider); // Process refund with provider - const refundResult = await paymentProvider.refundPayment( - payment.providerPaymentId, - amount, - ); + const refundResult = await paymentProvider.refundPayment(payment.providerPaymentId, amount); // Update payment status payment.status = PaymentStatus.REFUNDED; @@ -184,7 +177,7 @@ export class PaymentsService { async getUserPayments(userId: string, limit: number, page: number) { const skip = (page - 1) * limit; - + return await this.paymentRepository.find({ where: { userId }, order: { createdAt: 'DESC' }, @@ -203,7 +196,7 @@ export class PaymentsService { async getInvoice(paymentId: string, userId: string) { // Find payment const payment = await this.paymentRepository.findOne({ - where: { id: paymentId, userId } + where: { id: paymentId, userId }, }); if (!payment) { @@ -212,7 +205,7 @@ export class PaymentsService { // Check if invoice already exists let invoice = await this.invoiceRepository.findOne({ - where: { paymentId: payment.id } + where: { paymentId: payment.id }, }); if (!invoice) { @@ -244,7 +237,7 @@ export class PaymentsService { async updatePaymentStatus(paymentId: string, status: string, metadata?: any) { await this.paymentRepository.update( { providerPaymentId: paymentId }, - { status: status as PaymentStatus, metadata } + { status: status as PaymentStatus, metadata }, ); } @@ -256,14 +249,14 @@ export class PaymentsService { // Update subscription in database await this.subscriptionRepository.update( { providerSubscriptionId: subscriptionId }, - { status } + { status }, ); } async processRefundFromWebhook(paymentIntentId: string, refundData: any) { // Find payment by provider ID const payment = await this.paymentRepository.findOne({ - where: { providerPaymentId: paymentIntentId } + where: { providerPaymentId: paymentIntentId }, }); if (!payment) { @@ -286,4 +279,4 @@ export class PaymentsService { payment.status = PaymentStatus.REFUNDED; await this.paymentRepository.save(payment); } -} \ No newline at end of file +} diff --git a/src/payments/providers/payment-provider.interface.ts b/src/payments/providers/payment-provider.interface.ts index 3d73913..797dca5 100644 --- a/src/payments/providers/payment-provider.interface.ts +++ b/src/payments/providers/payment-provider.interface.ts @@ -1,6 +1,6 @@ export interface PaymentProvider { name: string; - + createPaymentIntent( amount: number, currency: string, @@ -22,7 +22,7 @@ export interface PaymentProvider { }>; cancelSubscription(subscriptionId: string): Promise; - + refundPayment( paymentId: string, amount?: number, @@ -39,8 +39,5 @@ export interface PaymentProvider { data: any; }>; - verifyWebhookSignature( - payload: any, - signature: string, - ): Promise; -} \ No newline at end of file + verifyWebhookSignature(payload: any, signature: string): Promise; +} diff --git a/src/payments/providers/provider-factory.service.ts b/src/payments/providers/provider-factory.service.ts index c6cac03..76832fc 100644 --- a/src/payments/providers/provider-factory.service.ts +++ b/src/payments/providers/provider-factory.service.ts @@ -13,4 +13,4 @@ export class ProviderFactoryService { throw new Error(`Unsupported payment provider: ${provider}`); } } -} \ No newline at end of file +} diff --git a/src/payments/providers/stripe.service.ts b/src/payments/providers/stripe.service.ts index 466dba8..91c11d4 100644 --- a/src/payments/providers/stripe.service.ts +++ b/src/payments/providers/stripe.service.ts @@ -21,4 +21,4 @@ export class StripeService { async handleWebhook(payload: any, signature: string) { return payload; } -} \ No newline at end of file +} diff --git a/src/payments/subscriptions/subscription-job.processor.ts b/src/payments/subscriptions/subscription-job.processor.ts index cafba47..7ee5bf7 100644 --- a/src/payments/subscriptions/subscription-job.processor.ts +++ b/src/payments/subscriptions/subscription-job.processor.ts @@ -9,4 +9,4 @@ export class SubscriptionJobProcessor { console.log('Processing subscription job:', job.data); return { success: true }; } -} \ No newline at end of file +} diff --git a/src/payments/subscriptions/subscriptions.service.ts b/src/payments/subscriptions/subscriptions.service.ts index 49a389f..82ffeac 100644 --- a/src/payments/subscriptions/subscriptions.service.ts +++ b/src/payments/subscriptions/subscriptions.service.ts @@ -7,4 +7,4 @@ export class SubscriptionsService { // Logic to process subscription payments return { success: true }; } -} \ No newline at end of file +} diff --git a/src/payments/webhooks/stripe-webhook.guard.ts b/src/payments/webhooks/stripe-webhook.guard.ts index df25420..e5ac3b5 100644 --- a/src/payments/webhooks/stripe-webhook.guard.ts +++ b/src/payments/webhooks/stripe-webhook.guard.ts @@ -5,19 +5,19 @@ import { Request } from 'express'; export class StripeWebhookGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - + // Verify the webhook signature here // This is a simplified implementation - in production, you'd verify the signature const signature = request.headers['stripe-signature']; - + if (!signature) { return false; } - + // In a real implementation, you would verify the signature using Stripe's library // const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // stripe.webhooks.constructEvent(request.body, signature, process.env.STRIPE_WEBHOOK_SECRET); - + return true; } -} \ No newline at end of file +} diff --git a/src/payments/webhooks/webhook.controller.ts b/src/payments/webhooks/webhook.controller.ts index 8ae5085..bfa4a41 100644 --- a/src/payments/webhooks/webhook.controller.ts +++ b/src/payments/webhooks/webhook.controller.ts @@ -1,12 +1,4 @@ -import { - Controller, - Post, - Headers, - Body, - HttpCode, - HttpStatus, - UseGuards, -} from '@nestjs/common'; +import { Controller, Post, Headers, Body, HttpCode, HttpStatus, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { WebhookService } from './webhook.service'; import { StripeWebhookGuard } from './stripe-webhook.guard'; @@ -21,10 +13,7 @@ export class WebhookController { @UseGuards(StripeWebhookGuard) @ApiOperation({ summary: 'Handle Stripe webhook events' }) @ApiResponse({ status: 200, description: 'Webhook processed' }) - async handleStripeWebhook( - @Headers('stripe-signature') signature: string, - @Body() payload: any, - ) { + async handleStripeWebhook(@Headers('stripe-signature') signature: string, @Body() payload: any) { return this.webhookService.handleStripeWebhook(payload, signature); } @@ -49,4 +38,4 @@ export class WebhookController { authAlgo, ); } -} \ No newline at end of file +} diff --git a/src/payments/webhooks/webhook.service.ts b/src/payments/webhooks/webhook.service.ts index eb41ba1..497dfbd 100644 --- a/src/payments/webhooks/webhook.service.ts +++ b/src/payments/webhooks/webhook.service.ts @@ -53,10 +53,7 @@ export class WebhookService { private async handleChargeRefunded(charge: any): Promise { // Process refund const refund = charge.refunds.data[0]; - await this.paymentsService.processRefundFromWebhook( - charge.payment_intent, - refund, - ); + await this.paymentsService.processRefundFromWebhook(charge.payment_intent, refund); } private async handleSubscriptionEvent(event: any): Promise { @@ -90,18 +87,11 @@ export class WebhookService { private async handlePayPalPaymentCompleted(resource: any): Promise { // Update payment status to completed - await this.paymentsService.updatePaymentStatus( - resource.id, - 'COMPLETED', - resource, - ); + await this.paymentsService.updatePaymentStatus(resource.id, 'COMPLETED', resource); } private async handlePayPalRefundCompleted(resource: any): Promise { // Process refund - await this.paymentsService.processRefundFromWebhook( - resource.parent_payment, - resource, - ); + await this.paymentsService.processRefundFromWebhook(resource.parent_payment, resource); } -} \ No newline at end of file +} diff --git a/src/queues/monitoring/queue-monitoring.service.ts b/src/queues/monitoring/queue-monitoring.service.ts index b808e69..027d908 100644 --- a/src/queues/monitoring/queue-monitoring.service.ts +++ b/src/queues/monitoring/queue-monitoring.service.ts @@ -15,23 +15,20 @@ export class QueueMonitoringService { private metricsHistory: Map = new Map(); private readonly MAX_HISTORY_SIZE = 100; - constructor( - @InjectQueue('default') private readonly defaultQueue: Queue, - ) {} + constructor(@InjectQueue('default') private readonly defaultQueue: Queue) {} /** * Get current queue metrics */ async getQueueMetrics(): Promise { - const [waiting, active, completed, failed, delayed, paused] = - await Promise.all([ - this.defaultQueue.getWaitingCount(), - this.defaultQueue.getActiveCount(), - this.defaultQueue.getCompletedCount(), - this.defaultQueue.getFailedCount(), - this.defaultQueue.getDelayedCount(), - this.defaultQueue.getPausedCount(), - ]); + const [waiting, active, completed, failed, delayed, paused] = await Promise.all([ + this.defaultQueue.getWaitingCount(), + this.defaultQueue.getActiveCount(), + this.defaultQueue.getCompletedCount(), + this.defaultQueue.getFailedCount(), + this.defaultQueue.getDelayedCount(), + this.defaultQueue.getPausedCount(), + ]); const total = waiting + active + completed + failed + delayed + paused; @@ -193,15 +190,11 @@ export class QueueMonitoringService { const health = await this.checkQueueHealth(); if (health.status === 'critical') { - this.logger.error( - `Queue health CRITICAL: ${health.issues.join(', ')}`, - ); + this.logger.error(`Queue health CRITICAL: ${health.issues.join(', ')}`); // Send alert to monitoring system await this.sendAlert(health); } else if (health.status === 'warning') { - this.logger.warn( - `Queue health WARNING: ${health.issues.join(', ')}`, - ); + this.logger.warn(`Queue health WARNING: ${health.issues.join(', ')}`); } else { this.logger.debug('Queue health: OK'); } @@ -209,9 +202,7 @@ export class QueueMonitoringService { // Check for stuck jobs const stuckJobs = await this.getStuckJobs(); if (stuckJobs.length > 0) { - this.logger.warn( - `Found ${stuckJobs.length} stuck jobs, attempting recovery`, - ); + this.logger.warn(`Found ${stuckJobs.length} stuck jobs, attempting recovery`); await this.recoverStuckJobs(stuckJobs); } } catch (error) { @@ -235,10 +226,7 @@ export class QueueMonitoringService { for (const job of jobs) { try { this.logger.log(`Recovering stuck job: ${job.id}`); - await job.moveToFailed( - { message: 'Job stuck, moved to failed for retry' }, - true, - ); + await job.moveToFailed({ message: 'Job stuck, moved to failed for retry' }, true); } catch (error) { this.logger.error(`Failed to recover job ${job.id}:`, error); } @@ -253,9 +241,7 @@ export class QueueMonitoringService { const history = this.getMetricsHistory(); // Calculate trends - const completedTrend = this.calculateTrend( - history.map((m) => m.completed), - ); + const completedTrend = this.calculateTrend(history.map((m) => m.completed)); const failedTrend = this.calculateTrend(history.map((m) => m.failed)); return { diff --git a/src/queues/prioritization/prioritization.service.ts b/src/queues/prioritization/prioritization.service.ts index e557e61..70ce1c2 100644 --- a/src/queues/prioritization/prioritization.service.ts +++ b/src/queues/prioritization/prioritization.service.ts @@ -145,10 +145,7 @@ export class PrioritizationService { /** * Adjust priority dynamically based on job age */ - adjustPriorityByAge( - currentPriority: JobPriority, - createdAt: Date, - ): JobPriority { + adjustPriorityByAge(currentPriority: JobPriority, createdAt: Date): JobPriority { const ageInHours = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60); // Increase priority for jobs waiting too long diff --git a/src/queues/processors/default-queue.processor.ts b/src/queues/processors/default-queue.processor.ts index dc47a94..146b1bb 100644 --- a/src/queues/processors/default-queue.processor.ts +++ b/src/queues/processors/default-queue.processor.ts @@ -15,9 +15,7 @@ export class DefaultQueueProcessor { @Process('*') async handleJob(job: Job): Promise { - this.logger.log( - `Processing job ${job.name} (ID: ${job.id}) - Attempt ${job.attemptsMade + 1}`, - ); + this.logger.log(`Processing job ${job.name} (ID: ${job.id}) - Attempt ${job.attemptsMade + 1}`); try { // Update progress @@ -29,10 +27,7 @@ export class DefaultQueueProcessor { await job.progress(100); return result; } catch (error) { - this.logger.error( - `Error processing job ${job.id}:`, - error.message, - ); + this.logger.error(`Error processing job ${job.id}:`, error.message); throw error; } } diff --git a/src/queues/queue.controller.ts b/src/queues/queue.controller.ts index df429dc..4107e6a 100644 --- a/src/queues/queue.controller.ts +++ b/src/queues/queue.controller.ts @@ -46,19 +46,14 @@ export class QueueController { // Calculate priority if factors provided if (body.priorityFactors) { - const priority = - this.prioritizationService.calculatePriority(body.priorityFactors); + const priority = this.prioritizationService.calculatePriority(body.priorityFactors); options = { ...options, ...this.prioritizationService.getJobOptions(priority), }; } - const job = await this.queueService.addJob( - body.name, - body.data, - options, - ); + const job = await this.queueService.addJob(body.name, body.data, options); return { jobId: job.id, @@ -278,9 +273,7 @@ export class QueueController { * Clean the queue */ @Post('clean') - async cleanQueue( - @Body() body: { grace?: number; status?: 'completed' | 'failed' }, - ) { + async cleanQueue(@Body() body: { grace?: number; status?: 'completed' | 'failed' }) { await this.queueService.cleanQueue(body.grace, body.status); return { message: 'Queue cleaned' }; } diff --git a/src/queues/queue.service.ts b/src/queues/queue.service.ts index f09d266..35f413a 100644 --- a/src/queues/queue.service.ts +++ b/src/queues/queue.service.ts @@ -12,18 +12,12 @@ import { JobPriority, JobStatus } from './enums/job-priority.enum'; export class QueueService { private readonly logger = new Logger(QueueService.name); - constructor( - @InjectQueue('default') private readonly defaultQueue: Queue, - ) {} + constructor(@InjectQueue('default') private readonly defaultQueue: Queue) {} /** * Add a job to the queue with priority and options */ - async addJob( - name: string, - data: T, - options?: JobOptions, - ): Promise> { + async addJob(name: string, data: T, options?: JobOptions): Promise> { try { const job = await this.defaultQueue.add(name, data, { priority: options?.priority || JobPriority.NORMAL, @@ -54,7 +48,7 @@ export class QueueService { */ async addBulkJobs( jobs: Array<{ name: string; data: T; options?: JobOptions }>, - ): Promise[]> { + ): Promise>> { try { const bulkJobs = jobs.map((job) => ({ name: job.name, @@ -151,10 +145,7 @@ export class QueueService { /** * Clean old jobs from the queue */ - async cleanQueue( - grace: number = 5000, - status?: 'completed' | 'failed', - ): Promise { + async cleanQueue(grace: number = 5000, status?: 'completed' | 'failed'): Promise { if (status) { await this.defaultQueue.clean(grace, status); this.logger.log(`Cleaned ${status} jobs older than ${grace}ms`); diff --git a/src/queues/retry/retry-logic.service.ts b/src/queues/retry/retry-logic.service.ts index 352b86f..05db0af 100644 --- a/src/queues/retry/retry-logic.service.ts +++ b/src/queues/retry/retry-logic.service.ts @@ -12,10 +12,7 @@ export class RetryLogicService { /** * Calculate backoff delay for retry attempts */ - calculateBackoffDelay( - attemptNumber: number, - strategy: RetryStrategy, - ): number { + calculateBackoffDelay(attemptNumber: number, strategy: RetryStrategy): number { if (strategy.backoffType === 'fixed') { return strategy.initialDelay; } @@ -29,9 +26,7 @@ export class RetryLogicService { delay = Math.min(delay, strategy.maxDelay); } - this.logger.log( - `Calculated backoff delay for attempt ${attemptNumber}: ${delay}ms`, - ); + this.logger.log(`Calculated backoff delay for attempt ${attemptNumber}: ${delay}ms`); return delay; } @@ -42,9 +37,7 @@ export class RetryLogicService { shouldRetry(error: Error, attemptNumber: number, maxAttempts: number): boolean { // Don't retry if max attempts reached if (attemptNumber >= maxAttempts) { - this.logger.warn( - `Max retry attempts (${maxAttempts}) reached, not retrying`, - ); + this.logger.warn(`Max retry attempts (${maxAttempts}) reached, not retrying`); return false; } @@ -57,9 +50,7 @@ export class RetryLogicService { ]; if (nonRetryableErrors.some((type) => error.name.includes(type))) { - this.logger.warn( - `Non-retryable error type: ${error.name}, not retrying`, - ); + this.logger.warn(`Non-retryable error type: ${error.name}, not retrying`); return false; } @@ -74,8 +65,7 @@ export class RetryLogicService { ]; const isRetryable = retryableErrors.some( - (type) => - error.name.includes(type) || error.message.includes(type), + (type) => error.name.includes(type) || error.message.includes(type), ); if (isRetryable) { @@ -180,12 +170,7 @@ export class RetryLogicService { /** * Handle final failure after all retries exhausted */ - handleFinalFailure( - jobId: string, - jobName: string, - error: Error, - attempts: number, - ): void { + handleFinalFailure(jobId: string, jobName: string, error: Error, attempts: number): void { this.logger.error( `Job ${jobName} (${jobId}) permanently failed after ${attempts} attempts. ` + `Final error: ${error.message}`, diff --git a/src/queues/scheduler/job-scheduler.service.ts b/src/queues/scheduler/job-scheduler.service.ts index 80be9b6..70facc0 100644 --- a/src/queues/scheduler/job-scheduler.service.ts +++ b/src/queues/scheduler/job-scheduler.service.ts @@ -39,9 +39,7 @@ export class JobSchedulerService { delay, }); - this.logger.log( - `Job ${name} scheduled for ${scheduledTime.toISOString()} (ID: ${job.id})`, - ); + this.logger.log(`Job ${name} scheduled for ${scheduledTime.toISOString()} (ID: ${job.id})`); return job.id.toString(); } @@ -49,11 +47,7 @@ export class JobSchedulerService { /** * Schedule a recurring job with cron expression */ - scheduleRecurringJob( - name: string, - cronExpression: string, - callback: () => Promise, - ): void { + scheduleRecurringJob(name: string, cronExpression: string, callback: () => Promise): void { try { const job = new CronJob(cronExpression, async () => { this.logger.log(`Executing recurring job: ${name}`); @@ -67,9 +61,7 @@ export class JobSchedulerService { this.schedulerRegistry.addCronJob(name, job); job.start(); - this.logger.log( - `Recurring job ${name} scheduled with cron: ${cronExpression}`, - ); + this.logger.log(`Recurring job ${name} scheduled with cron: ${cronExpression}`); } catch (error) { this.logger.error(`Failed to schedule recurring job ${name}:`, error); throw error; @@ -104,9 +96,7 @@ export class JobSchedulerService { delay: delayMs, }); - this.logger.log( - `Job ${name} scheduled with ${delayMs}ms delay (ID: ${job.id})`, - ); + this.logger.log(`Job ${name} scheduled with ${delayMs}ms delay (ID: ${job.id})`); return job.id.toString(); } @@ -125,12 +115,7 @@ export class JobSchedulerService { const jobIds: string[] = []; for (const job of jobs) { - const id = await this.scheduleJob( - job.name, - job.data, - job.scheduledTime, - job.options, - ); + const id = await this.scheduleJob(job.name, job.data, job.scheduledTime, job.options); jobIds.push(id); } @@ -166,10 +151,7 @@ export class JobSchedulerService { /** * Reschedule a job */ - async rescheduleJob( - jobId: string, - newScheduledTime: Date, - ): Promise { + async rescheduleJob(jobId: string, newScheduledTime: Date): Promise { const job = await this.defaultQueue.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); @@ -179,12 +161,7 @@ export class JobSchedulerService { await job.remove(); // Create new job with same data - const newJobId = await this.scheduleJob( - job.name, - job.data, - newScheduledTime, - job.opts as any, - ); + const newJobId = await this.scheduleJob(job.name, job.data, newScheduledTime, job.opts as any); this.logger.log( `Job ${jobId} rescheduled to ${newScheduledTime.toISOString()} (new ID: ${newJobId})`, @@ -238,7 +215,7 @@ export class JobSchedulerService { options?: JobOptions, ): Promise { const now = new Date(); - let scheduledTime = new Date(now); + const scheduledTime = new Date(now); // If outside business hours (9 AM - 5 PM), schedule for next business day at 9 AM const hour = now.getHours(); diff --git a/src/rate-limiting/rate-limiting.controller.ts b/src/rate-limiting/rate-limiting.controller.ts index 5ffe319..9dd1f6f 100644 --- a/src/rate-limiting/rate-limiting.controller.ts +++ b/src/rate-limiting/rate-limiting.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; -import { RateLimitingService } from './rate-limiting.service'; +import { RateLimitingService } from './rate-limiting.service'; import { RateLimitGuard } from './services/limit-guard/guard'; import { CreateRateLimitingDto } from './dto/create-rate-limiting.dto'; import { UpdateRateLimitingDto } from './dto/update-rate-limiting.dto'; @@ -9,7 +9,6 @@ import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; export class RateLimitingController { constructor(private readonly rateLimitingService: RateLimitingService) {} - @Post() create(@Body() createRateLimitingDto: CreateRateLimitingDto) { return this.rateLimitingService.create(createRateLimitingDto); diff --git a/src/rate-limiting/rate-limiting.service.ts b/src/rate-limiting/rate-limiting.service.ts index 7e7e50e..2d273ba 100644 --- a/src/rate-limiting/rate-limiting.service.ts +++ b/src/rate-limiting/rate-limiting.service.ts @@ -23,15 +23,7 @@ export class RateLimitingService { } constructor(private readonly throttlingService: ThrottlingService) {} - async protect( - userId: string, - tier: UserTier, - endpoint: string, - ) { - await this.throttlingService.handleRequest( - userId, - tier, - endpoint, - ); + async protect(userId: string, tier: UserTier, endpoint: string) { + await this.throttlingService.handleRequest(userId, tier, endpoint); } -} \ No newline at end of file +} diff --git a/src/rate-limiting/services/adaptive-rate-limiting.service.ts b/src/rate-limiting/services/adaptive-rate-limiting.service.ts index e3aa91a..f896655 100644 --- a/src/rate-limiting/services/adaptive-rate-limiting.service.ts +++ b/src/rate-limiting/services/adaptive-rate-limiting.service.ts @@ -18,4 +18,4 @@ export class AdaptiveRateLimitingService { const factor = this.getSystemLoadFactor(); return Math.floor(baseLimit * factor); } -} \ No newline at end of file +} diff --git a/src/rate-limiting/services/distrubutes.service.ts b/src/rate-limiting/services/distrubutes.service.ts index e6ced2f..d07ecab 100644 --- a/src/rate-limiting/services/distrubutes.service.ts +++ b/src/rate-limiting/services/distrubutes.service.ts @@ -9,11 +9,7 @@ export class DistributedLimiterService { this.redis = new Redis(process.env.REDIS_URL); } - async slidingWindowCheck( - key: string, - limit: number, - windowInSeconds: number, - ): Promise { + async slidingWindowCheck(key: string, limit: number, windowInSeconds: number): Promise { const now = Date.now(); const windowStart = now - windowInSeconds * 1000; @@ -31,4 +27,4 @@ export class DistributedLimiterService { throw new ForbiddenException('Rate limit exceeded'); } } -} \ No newline at end of file +} diff --git a/src/rate-limiting/services/limit-guard/guard.ts b/src/rate-limiting/services/limit-guard/guard.ts index bda245f..d85bf99 100644 --- a/src/rate-limiting/services/limit-guard/guard.ts +++ b/src/rate-limiting/services/limit-guard/guard.ts @@ -1,5 +1,5 @@ -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; -import { RateLimitingService } from "src/rate-limiting/rate-limiting.service"; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { RateLimitingService } from 'src/rate-limiting/rate-limiting.service'; @Injectable() export class RateLimitGuard implements CanActivate { @@ -11,12 +11,8 @@ export class RateLimitGuard implements CanActivate { const user = req.user; const endpoint = req.route.path; - await this.rateLimiting.protect( - user.id, - user.tier, - endpoint, - ); + await this.rateLimiting.protect(user.id, user.tier, endpoint); return true; } -} \ No newline at end of file +} diff --git a/src/rate-limiting/services/quota.service.ts b/src/rate-limiting/services/quota.service.ts index 1d54d06..0a5778e 100644 --- a/src/rate-limiting/services/quota.service.ts +++ b/src/rate-limiting/services/quota.service.ts @@ -20,4 +20,4 @@ export class QuotaManagementService { return { limit: 50, window: 60 }; } } -} \ No newline at end of file +} diff --git a/src/rate-limiting/services/rate-limiting.module.ts b/src/rate-limiting/services/rate-limiting.module.ts index 87cc306..058e933 100644 --- a/src/rate-limiting/services/rate-limiting.module.ts +++ b/src/rate-limiting/services/rate-limiting.module.ts @@ -15,4 +15,4 @@ import { DistributedLimiterService } from './distrubutes.service'; ], exports: [RateLimitingService], }) -export class RateLimitingModule {} \ No newline at end of file +export class RateLimitingModule {} diff --git a/src/rate-limiting/services/throttling.service.ts b/src/rate-limiting/services/throttling.service.ts index 7f2228a..4aad34e 100644 --- a/src/rate-limiting/services/throttling.service.ts +++ b/src/rate-limiting/services/throttling.service.ts @@ -21,10 +21,6 @@ export class ThrottlingService { const key = `rate:${userId}:${endpoint}`; - await this.distributedLimiter.slidingWindowCheck( - key, - adjustedLimit, - window, - ); + await this.distributedLimiter.slidingWindowCheck(key, adjustedLimit, window); } -} \ No newline at end of file +} diff --git a/src/search/autocomplete/autocomplete.service.ts b/src/search/autocomplete/autocomplete.service.ts index 42fc870..e0c88c4 100644 --- a/src/search/autocomplete/autocomplete.service.ts +++ b/src/search/autocomplete/autocomplete.service.ts @@ -23,8 +23,6 @@ export class AutoCompleteService { }); const suggestions = result.suggest.title_suggest[0].options; - return Array.isArray(suggestions) - ? suggestions.map((option: any) => option.text) - : []; + return Array.isArray(suggestions) ? suggestions.map((option: any) => option.text) : []; } } diff --git a/src/search/filters/search-filters.service.ts b/src/search/filters/search-filters.service.ts index 8510ab5..4eee944 100644 --- a/src/search/filters/search-filters.service.ts +++ b/src/search/filters/search-filters.service.ts @@ -20,12 +20,7 @@ export class SearchFiltersService { price_ranges: { range: { field: 'price', - ranges: [ - { to: 50 }, - { from: 50, to: 100 }, - { from: 100, to: 200 }, - { from: 200 }, - ], + ranges: [{ to: 50 }, { from: 50, to: 100 }, { from: 100, to: 200 }, { from: 200 }], }, }, }, @@ -33,7 +28,7 @@ export class SearchFiltersService { }); const aggregations = result.aggregations || {}; - + return { categories: (aggregations.categories as any)?.buckets || [], levels: (aggregations.levels as any)?.buckets || [], diff --git a/src/search/indexing/indexing.service.ts b/src/search/indexing/indexing.service.ts index 36b7b2a..cd2dfeb 100644 --- a/src/search/indexing/indexing.service.ts +++ b/src/search/indexing/indexing.service.ts @@ -38,10 +38,7 @@ export class IndexingService { } async bulkIndex(documents: any[]) { - const body = documents.flatMap(doc => [ - { index: { _index: 'courses', _id: doc.id } }, - doc, - ]); + const body = documents.flatMap((doc) => [{ index: { _index: 'courses', _id: doc.id } }, doc]); return this.elasticsearchService.bulk({ body }); } diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 7c9a42c..1b34a1c 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -32,7 +32,7 @@ export class SearchService { // Ensure sort fields are properly formatted for ES if (searchBody.sort && Array.isArray(searchBody.sort)) { - searchBody.sort = searchBody.sort.map(sortItem => { + searchBody.sort = searchBody.sort.map((sortItem) => { if (typeof sortItem === 'object' && sortItem !== null) { // Convert object keys to proper format const newSortItem = {}; @@ -85,15 +85,15 @@ export class SearchService { if (sort === 'relevance') { return ['_score']; } else if (sort === 'popularity') { - return [{ 'views': { 'order': 'desc' as const } }]; + return [{ views: { order: 'desc' as const } }]; } else if (sort === 'rating') { - return [{ 'rating': { 'order': 'desc' as const } }]; + return [{ rating: { order: 'desc' as const } }]; } return ['_score']; } private rankResults(hits: any[]) { - return hits.map(hit => ({ + return hits.map((hit) => ({ ...hit._source, score: hit._score, relevance: hit._score * (hit._source.views || 1), // Simple ranking diff --git a/src/security/encryption/encryption.service.ts b/src/security/encryption/encryption.service.ts index a7ec72e..a71957f 100644 --- a/src/security/encryption/encryption.service.ts +++ b/src/security/encryption/encryption.service.ts @@ -4,19 +4,13 @@ import * as crypto from 'crypto'; @Injectable() export class EncryptionService { private readonly algorithm = 'aes-256-gcm'; - private readonly key = crypto - .createHash('sha256') - .update(process.env.ENCRYPTION_SECRET) - .digest(); + private readonly key = crypto.createHash('sha256').update(process.env.ENCRYPTION_SECRET).digest(); encrypt(text: string) { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); - const encrypted = Buffer.concat([ - cipher.update(text, 'utf8'), - cipher.final(), - ]); + const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); return { iv: iv.toString('hex'), diff --git a/src/sync/cache/cache-invalidation.service.spec.ts b/src/sync/cache/cache-invalidation.service.spec.ts index a0bf8e9..4a9d135 100644 --- a/src/sync/cache/cache-invalidation.service.spec.ts +++ b/src/sync/cache/cache-invalidation.service.spec.ts @@ -47,7 +47,10 @@ describe('CacheInvalidationService', () => { it('should call cacheManager.del and emit event', async () => { await service.invalidateKey('test-key'); expect(cacheManager.del).toHaveBeenCalledWith('test-key'); - expect(eventEmitter.emit).toHaveBeenCalledWith('cache.invalidated', { key: 'test-key', type: 'single' }); + expect(eventEmitter.emit).toHaveBeenCalledWith('cache.invalidated', { + key: 'test-key', + type: 'single', + }); }); }); diff --git a/src/sync/cache/cache-invalidation.service.ts b/src/sync/cache/cache-invalidation.service.ts index 7759a5e..6035fda 100644 --- a/src/sync/cache/cache-invalidation.service.ts +++ b/src/sync/cache/cache-invalidation.service.ts @@ -28,11 +28,11 @@ export class CacheInvalidationService { */ async invalidatePattern(pattern: string): Promise { this.logger.log(`Invalidating cache pattern: ${pattern}`); - + // In a production environment with Redis, we'd use 'SCAN' and 'DEL' // For now, we'll emit an event that other specialized listeners might handle this.eventEmitter.emit('cache.invalidated', { pattern, type: 'pattern' }); - + // If the store supports a store-specific method, call it here. const store: any = (this.cacheManager as any).store; if (store && typeof store.keys === 'function') { @@ -48,7 +48,7 @@ export class CacheInvalidationService { */ async handleDataChange(entity: string, id: string): Promise { this.logger.log(`Handling data change for ${entity}:${id}`); - + const specificKey = `${entity}:${id}`; const collectionKey = `${entity}:list:*`; diff --git a/src/sync/conflicts/conflict-resolution.service.spec.ts b/src/sync/conflicts/conflict-resolution.service.spec.ts index 442e9c2..e0f4c16 100644 --- a/src/sync/conflicts/conflict-resolution.service.spec.ts +++ b/src/sync/conflicts/conflict-resolution.service.spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConflictResolutionService, ConflictResolutionStrategy, SyncData } from './conflict-resolution.service'; +import { + ConflictResolutionService, + ConflictResolutionStrategy, + SyncData, +} from './conflict-resolution.service'; describe('ConflictResolutionService', () => { let service: ConflictResolutionService; @@ -32,13 +36,21 @@ describe('ConflictResolutionService', () => { }; it('should resolve using LAST_WRITE_WINS (remote wins)', () => { - const result = service.resolve(localData, remoteData, ConflictResolutionStrategy.LAST_WRITE_WINS); + const result = service.resolve( + localData, + remoteData, + ConflictResolutionStrategy.LAST_WRITE_WINS, + ); expect(result.data.name).toBe('Remote'); }); it('should resolve using LAST_WRITE_WINS (local wins)', () => { const olderRemote = { ...remoteData, lastModified: new Date('2023-01-01T09:00:00Z') }; - const result = service.resolve(localData, olderRemote, ConflictResolutionStrategy.LAST_WRITE_WINS); + const result = service.resolve( + localData, + olderRemote, + ConflictResolutionStrategy.LAST_WRITE_WINS, + ); expect(result.data.name).toBe('Local'); }); @@ -48,7 +60,11 @@ describe('ConflictResolutionService', () => { }); it('should resolve using MANUAL_MERGE', () => { - const result = service.resolve(localData, remoteData, ConflictResolutionStrategy.MANUAL_MERGE); + const result = service.resolve( + localData, + remoteData, + ConflictResolutionStrategy.MANUAL_MERGE, + ); expect(result.data._conflict.status).toBe('needs_merge'); }); }); diff --git a/src/sync/conflicts/conflict-resolution.service.ts b/src/sync/conflicts/conflict-resolution.service.ts index a61418a..31565db 100644 --- a/src/sync/conflicts/conflict-resolution.service.ts +++ b/src/sync/conflicts/conflict-resolution.service.ts @@ -49,7 +49,7 @@ export class ConflictResolutionService { } private manualMerge(local: SyncData, remote: SyncData): SyncData { - // In a real scenario, this would trigger a notification or + // In a real scenario, this would trigger a notification or // flag the record for human intervention. // For now, we'll mark it as "needs_merge" in the data. return { diff --git a/src/sync/consistency/data-consistency.service.spec.ts b/src/sync/consistency/data-consistency.service.spec.ts index d3ada09..7cd26fb 100644 --- a/src/sync/consistency/data-consistency.service.spec.ts +++ b/src/sync/consistency/data-consistency.service.spec.ts @@ -42,13 +42,19 @@ describe('DataConsistencyService', () => { it('should add task to queue and emit event', async () => { await service.scheduleConsistencyTask('1', { foo: 'bar' }); expect(queue.add).toHaveBeenCalled(); - expect(eventEmitter.emit).toHaveBeenCalledWith('data.consistency.scheduled', expect.any(Object)); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'data.consistency.scheduled', + expect.any(Object), + ); }); }); describe('performIntegrityCheck', () => { it('should return consistent when data matches', async () => { - const result = await service.performIntegrityCheck({ id: '1', version: 1 }, { id: '1', version: 1 }); + const result = await service.performIntegrityCheck( + { id: '1', version: 1 }, + { id: '1', version: 1 }, + ); expect(result.consistent).toBe(true); }); diff --git a/src/sync/consistency/data-consistency.service.ts b/src/sync/consistency/data-consistency.service.ts index 3fa5e34..27bb00c 100644 --- a/src/sync/consistency/data-consistency.service.ts +++ b/src/sync/consistency/data-consistency.service.ts @@ -23,19 +23,23 @@ export class DataConsistencyService { */ async scheduleConsistencyTask(dataId: string, payload: any): Promise { this.logger.log(`Scheduling consistency task for ${dataId}`); - + // Add to queue for background processing - await this.syncQueue.add('consistency-check', { - dataId, - payload, - timestamp: new Date(), - }, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 1000, + await this.syncQueue.add( + 'consistency-check', + { + dataId, + payload, + timestamp: new Date(), + }, + { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, }, - }); + ); // Emit event for real-time subscribers this.eventEmitter.emit('data.consistency.scheduled', { dataId, timestamp: new Date() }); @@ -62,7 +66,7 @@ export class DataConsistencyService { } const consistent = issues.length === 0; - + if (!consistent) { this.logger.warn(`Integrity check failed with issues: ${issues.join(', ')}`); this.eventEmitter.emit('data.integrity.violation', { issues, timestamp: new Date() }); @@ -80,7 +84,7 @@ export class DataConsistencyService { */ async heal(staleData: any, sourceOfTruth: any): Promise { this.logger.log(`Healing data for ID: ${sourceOfTruth.id}`); - + // In a real app, this would update the database or cache return { ...sourceOfTruth, diff --git a/src/sync/replication/replication.service.spec.ts b/src/sync/replication/replication.service.spec.ts index fb9ae25..cb7e588 100644 --- a/src/sync/replication/replication.service.spec.ts +++ b/src/sync/replication/replication.service.spec.ts @@ -42,7 +42,10 @@ describe('ReplicationService', () => { it('should add replication task to queue', async () => { await service.replicateToRegion('1', { foo: 'bar' }, 'eu-west-1'); expect(queue.add).toHaveBeenCalled(); - expect(eventEmitter.emit).toHaveBeenCalledWith('data.replication.started', expect.any(Object)); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'data.replication.started', + expect.any(Object), + ); }); it('should skip if target region is same as current', async () => { diff --git a/src/sync/replication/replication.service.ts b/src/sync/replication/replication.service.ts index 73dd37d..a7394af 100644 --- a/src/sync/replication/replication.service.ts +++ b/src/sync/replication/replication.service.ts @@ -57,12 +57,12 @@ export class ReplicationService { */ async broadcastToAllRegions(entityId: string, data: any): Promise { const allRegions = ['us-east-1', 'eu-west-1', 'ap-southeast-1']; - + this.logger.log(`Broadcasting ${entityId} to all regions`); - + const replicationPromises = allRegions - .filter(region => region !== this.currentRegion) - .map(region => this.replicateToRegion(entityId, data, region)); + .filter((region) => region !== this.currentRegion) + .map((region) => this.replicateToRegion(entityId, data, region)); await Promise.all(replicationPromises); } @@ -72,10 +72,10 @@ export class ReplicationService { */ async handleIncomingReplication(event: ReplicationEvent): Promise { this.logger.log(`Received replication for ${event.entityId} from ${event.sourceRegion}`); - + // In a real app, logic to update the local database would go here. // This might also trigger conflict resolution if the local version is different. - + this.eventEmitter.emit('data.replication.received', event); } } diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 5e77884..7b09a2d 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -1,6 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { ConflictResolutionService, ConflictResolutionStrategy, SyncData } from './conflicts/conflict-resolution.service'; +import { + ConflictResolutionService, + ConflictResolutionStrategy, + SyncData, +} from './conflicts/conflict-resolution.service'; import { DataConsistencyService } from './consistency/data-consistency.service'; import { CacheInvalidationService } from './cache/cache-invalidation.service'; import { ReplicationService } from './replication/replication.service'; @@ -45,10 +49,10 @@ export class SyncService { @OnEvent('data.updated') async handleDataUpdate(payload: { entity: string; id: string; data: any }) { this.logger.log(`Handling data update event for ${payload.entity}:${payload.id}`); - + // Invalidate cache immediately on update await this.cacheInvalidation.handleDataChange(payload.entity, payload.id); - + // Broadcast change await this.replicationService.broadcastToAllRegions(payload.id, payload.data); } diff --git a/src/tenancy/admin/tenant-admin.service.ts b/src/tenancy/admin/tenant-admin.service.ts index a658fd7..b678fbd 100644 --- a/src/tenancy/admin/tenant-admin.service.ts +++ b/src/tenancy/admin/tenant-admin.service.ts @@ -164,7 +164,11 @@ export class TenantAdminService { } // Check if trial expired - if (tenant.status === TenantStatus.TRIAL && tenant.trialEndsAt && tenant.trialEndsAt < new Date()) { + if ( + tenant.status === TenantStatus.TRIAL && + tenant.trialEndsAt && + tenant.trialEndsAt < new Date() + ) { issues.push('Trial period expired'); score -= 20; } @@ -219,7 +223,10 @@ export class TenantAdminService { /** * Get all tenants with pagination */ - async getAllTenants(page: number = 1, limit: number = 10): Promise<{ tenants: Tenant[]; total: number }> { + async getAllTenants( + page: number = 1, + limit: number = 10, + ): Promise<{ tenants: Tenant[]; total: number }> { const [tenants, total] = await this.tenantRepository.findAndCount({ skip: (page - 1) * limit, take: limit, diff --git a/src/tenancy/billing/tenant-billing.service.ts b/src/tenancy/billing/tenant-billing.service.ts index ea0d712..bfd33c6 100644 --- a/src/tenancy/billing/tenant-billing.service.ts +++ b/src/tenancy/billing/tenant-billing.service.ts @@ -43,7 +43,10 @@ export class TenantBillingService { /** * Create billing record for a tenant */ - async createBillingRecord(tenantId: string, billingCycle: BillingCycle = BillingCycle.MONTHLY): Promise { + async createBillingRecord( + tenantId: string, + billingCycle: BillingCycle = BillingCycle.MONTHLY, + ): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { throw new NotFoundException(`Tenant ${tenantId} not found`); @@ -64,7 +67,7 @@ export class TenantBillingService { */ async updateUsageMetrics(tenantId: string, metrics: UsageMetrics): Promise { const billing = await this.getBillingInfo(tenantId); - + billing.usageMetrics = { ...billing.usageMetrics, ...metrics, @@ -169,7 +172,11 @@ export class TenantBillingService { /** * Update Stripe customer ID */ - async updateStripeCustomer(tenantId: string, customerId: string, subscriptionId?: string): Promise { + async updateStripeCustomer( + tenantId: string, + customerId: string, + subscriptionId?: string, + ): Promise { const billing = await this.getBillingInfo(tenantId); billing.stripeCustomerId = customerId; if (subscriptionId) { diff --git a/src/tenancy/customization/customization.service.ts b/src/tenancy/customization/customization.service.ts index 1b82e90..0253400 100644 --- a/src/tenancy/customization/customization.service.ts +++ b/src/tenancy/customization/customization.service.ts @@ -70,7 +70,7 @@ export class CustomizationService { colors: { primary?: string; secondary?: string; accent?: string }, ): Promise { const customization = await this.getCustomization(tenantId); - + if (colors.primary) customization.primaryColor = colors.primary; if (colors.secondary) customization.secondaryColor = colors.secondary; if (colors.accent) customization.accentColor = colors.accent; @@ -178,7 +178,7 @@ export class CustomizationService { */ async resetToDefaults(tenantId: string): Promise { const customization = await this.getCustomization(tenantId); - + customization.logoUrl = null; customization.faviconUrl = null; customization.primaryColor = null; diff --git a/src/tenancy/decorators/current-tenant.decorator.ts b/src/tenancy/decorators/current-tenant.decorator.ts index 6f0d223..5e1f537 100644 --- a/src/tenancy/decorators/current-tenant.decorator.ts +++ b/src/tenancy/decorators/current-tenant.decorator.ts @@ -1,8 +1,6 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -export const CurrentTenant = createParamDecorator( - (data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - return request.tenant; - }, -); +export const CurrentTenant = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.tenant; +}); diff --git a/src/tenancy/guards/tenant.guard.ts b/src/tenancy/guards/tenant.guard.ts index c1c43f6..aadefcb 100644 --- a/src/tenancy/guards/tenant.guard.ts +++ b/src/tenancy/guards/tenant.guard.ts @@ -21,17 +21,14 @@ export class TenantGuard implements CanActivate { } const request = context.switchToHttp().getRequest(); - + // Try to get tenant from various sources - const tenantId = request.headers['x-tenant-id'] || - request.query.tenantId || - request.user?.tenantId; + const tenantId = + request.headers['x-tenant-id'] || request.query.tenantId || request.user?.tenantId; - const tenantSlug = request.headers['x-tenant-slug'] || - request.query.tenantSlug; + const tenantSlug = request.headers['x-tenant-slug'] || request.query.tenantSlug; - const tenantDomain = request.headers['x-tenant-domain'] || - request.hostname; + const tenantDomain = request.headers['x-tenant-domain'] || request.hostname; try { if (tenantId) { diff --git a/src/tenancy/tenancy.controller.ts b/src/tenancy/tenancy.controller.ts index 758cc80..8b61f60 100644 --- a/src/tenancy/tenancy.controller.ts +++ b/src/tenancy/tenancy.controller.ts @@ -14,7 +14,12 @@ import { TenancyService } from './tenancy.service'; import { TenantAdminService } from './admin/tenant-admin.service'; import { TenantBillingService } from './billing/tenant-billing.service'; import { CustomizationService } from './customization/customization.service'; -import { CreateTenantDto, UpdateTenantDto, UpdateTenantConfigDto, UpdateTenantCustomizationDto } from './dto/tenant.dto'; +import { + CreateTenantDto, + UpdateTenantDto, + UpdateTenantConfigDto, + UpdateTenantCustomizationDto, +} from './dto/tenant.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -107,10 +112,7 @@ export class TenancyController { @Patch(':id/config') @ApiOperation({ summary: 'Update tenant configuration' }) - updateConfig( - @Param('id') id: string, - @Body() updateConfigDto: UpdateTenantConfigDto, - ) { + updateConfig(@Param('id') id: string, @Body() updateConfigDto: UpdateTenantConfigDto) { return this.tenancyService.updateConfig(id, updateConfigDto); } diff --git a/src/tenancy/tenancy.module.ts b/src/tenancy/tenancy.module.ts index b4588b6..c2441d2 100644 --- a/src/tenancy/tenancy.module.ts +++ b/src/tenancy/tenancy.module.ts @@ -13,14 +13,7 @@ import { TenantAdminService } from './admin/tenant-admin.service'; import { TenantGuard } from './guards/tenant.guard'; @Module({ - imports: [ - TypeOrmModule.forFeature([ - Tenant, - TenantConfig, - TenantBilling, - TenantCustomization, - ]), - ], + imports: [TypeOrmModule.forFeature([Tenant, TenantConfig, TenantBilling, TenantCustomization])], controllers: [TenancyController], providers: [ TenancyService, diff --git a/src/tenancy/tenancy.service.ts b/src/tenancy/tenancy.service.ts index e35f076..5981742 100644 --- a/src/tenancy/tenancy.service.ts +++ b/src/tenancy/tenancy.service.ts @@ -59,7 +59,10 @@ export class TenancyService { /** * Find all tenants */ - async findAll(page: number = 1, limit: number = 10): Promise<{ tenants: Tenant[]; total: number; page: number; totalPages: number }> { + async findAll( + page: number = 1, + limit: number = 10, + ): Promise<{ tenants: Tenant[]; total: number; page: number; totalPages: number }> { const [tenants, total] = await this.tenantRepository.findAndCount({ skip: (page - 1) * limit, take: limit, @@ -140,7 +143,10 @@ export class TenancyService { /** * Update tenant configuration */ - async updateConfig(tenantId: string, updateConfigDto: UpdateTenantConfigDto): Promise { + async updateConfig( + tenantId: string, + updateConfigDto: UpdateTenantConfigDto, + ): Promise { const config = await this.getConfig(tenantId); Object.assign(config, updateConfigDto); diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index c3e9199..6ee9460 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -7,4 +7,4 @@ export class UpdateUserDto extends PartialType(CreateUserDto) { @IsString() @MinLength(6) password?: string; -} \ No newline at end of file +} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index fab3401..6850d61 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -98,4 +98,3 @@ export class User { @UpdateDateColumn() updatedAt: Date; } - diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index ec6dc9f..60caf6f 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,13 +1,4 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index a9044f7..c41cb6c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,8 +1,4 @@ -import { - Injectable, - NotFoundException, - ConflictException, -} from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; @@ -103,10 +99,7 @@ export class UsersService { return await this.userRepository.save(user); } - async updateRefreshToken( - userId: string, - refreshToken: string | null, - ): Promise { + async updateRefreshToken(userId: string, refreshToken: string | null): Promise { await this.userRepository.update(userId, { refreshToken }); } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50cda62..1a013be 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -16,9 +16,6 @@ describe('AppController (e2e)', () => { }); it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); }); }); diff --git a/test/setup.ts b/test/setup.ts index ee0ecaf..0fa1e21 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -62,4 +62,4 @@ afterEach(() => { // Global teardown afterAll(() => { // Clean up any remaining resources -}); \ No newline at end of file +});