From 79e61887ffb87571d6d682748fbab940a46dc09a Mon Sep 17 00:00:00 2001 From: victor-olamide Date: Wed, 27 May 2026 10:44:46 +0100 Subject: [PATCH 1/5] feat: add property report generation feature - Create PropertyReportService for generating PDF reports - Add report generation endpoint to PropertiesController - Update PropertiesModule to include the new service - Add pdfkit dependency for PDF generation - Reports include property details, comparable properties, and market analysis --- package-lock.json | 297 ++++++++++++++++- package.json | 2 + src/properties/properties.controller.ts | 47 ++- src/properties/properties.module.ts | 4 +- .../report/property-report.service.ts | 304 ++++++++++++++++++ 5 files changed, 646 insertions(+), 8 deletions(-) create mode 100644 src/properties/report/property-report.service.ts diff --git a/package-lock.json b/package-lock.json index 21c9e440..50dd1753 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pdf-poppler": "^0.2.3", + "pdfkit": "^0.18.0", "pg": "^8.11.3", "redis": "^5.12.1", "reflect-metadata": "^0.1.13", @@ -67,6 +68,7 @@ "@types/node": "^20.19.39", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^3.0.13", + "@types/pdfkit": "^0.17.6", "@typescript-eslint/eslint-plugin": "^6.13.1", "@typescript-eslint/parser": "^6.13.1", "eslint": "^8.54.0", @@ -4103,6 +4105,18 @@ } } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -4496,6 +4510,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -4536,6 +4564,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -4941,6 +4978,16 @@ "@types/passport": "*" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pug": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", @@ -5439,6 +5486,51 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "optional": true, + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, + "node_modules/@zone-eu/mailsplit/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@zone-eu/mailsplit/node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT", + "optional": true + }, "node_modules/abitype": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.7.1.tgz", @@ -6274,6 +6366,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -7320,6 +7430,18 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -7771,6 +7893,12 @@ "license": "MIT", "optional": true }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -7981,6 +8109,7 @@ "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", "license": "Apache-2.0", + "optional": true, "bin": { "ejs": "bin/cli.js" }, @@ -8995,7 +9124,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -9307,6 +9435,32 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fontkit/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9486,6 +9640,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -11429,6 +11584,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-stringify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", @@ -11798,6 +11959,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -13357,6 +13537,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13625,6 +13811,32 @@ "integrity": "sha512-nUczP3M/W4c8/3F6il0LmkxkF33qTKQyxeBmUnPbQLxxhtBX42zfpZqnLysomvMdb756qVR7n5kvNr+LzisXQw==", "license": "ISC" }, + "node_modules/pdfkit": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.18.0.tgz", + "integrity": "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "node_modules/pdfkit/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -13852,6 +14064,14 @@ "node": ">=4" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13948,7 +14168,7 @@ "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.8.tgz", "integrity": "sha512-CvvS5S9WrXblFXCEJ9nVo+4z+eA7zSC7Z88V1HEJuwlQhlFnYTIjg1xJY+BCUiG2bvICap2tXii4mP22BD108Q==", "license": "MIT", - "peer": true, + "optional": true, "dependencies": { "postcss-selector-parser": "^7.1.1" }, @@ -14123,7 +14343,7 @@ "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.3.tgz", "integrity": "sha512-ldsCX0QIt05pKIOobZtVQ48wXJecr+czw4+e1/YjVhLMqslShgpVxgPtI2CefURR8oyVoYaU/l829MMwExDMLw==", "license": "MIT", - "peer": true, + "optional": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -15244,6 +15464,12 @@ "dev": true, "license": "ISC" }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -16602,6 +16828,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", @@ -17035,6 +17267,7 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { @@ -17090,6 +17323,32 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -17294,6 +17553,36 @@ "defaults": "^1.0.3" } }, + "node_modules/web-resource-inliner": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-8.0.0.tgz", + "integrity": "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^9.1.0", + "mime": "^2.4.6", + "valid-data-url": "^3.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/web-resource-inliner/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/web3": { "version": "4.16.0", "resolved": "https://registry.npmjs.org/web3/-/web3-4.16.0.tgz", @@ -17965,7 +18254,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -18101,7 +18389,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" } diff --git a/package.json b/package.json index a8c15773..de0a3421 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pdf-poppler": "^0.2.3", + "pdfkit": "^0.18.0", "pg": "^8.11.3", "redis": "^5.12.1", "reflect-metadata": "^0.1.13", @@ -84,6 +85,7 @@ "@types/node": "^20.19.39", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^3.0.13", + "@types/pdfkit": "^0.17.6", "@typescript-eslint/eslint-plugin": "^6.13.1", "@typescript-eslint/parser": "^6.13.1", "eslint": "^8.54.0", diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index b40e85dc..64db1633 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -1,4 +1,16 @@ -import { Controller, Get, Post, Body, Param, Put, Delete, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Param, + Put, + Delete, + UseGuards, + Res, + StreamableFile, + HttpStatus, +} from '@nestjs/common'; import { PropertiesService } from './properties.service'; import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -7,10 +19,15 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUserPayload } from '../auth/types/auth-user.type'; import { UserRole } from '../types/prisma.types'; +import { PropertyReportService } from './report/property-report.service'; +import { Response } from 'express'; @Controller('properties') export class PropertiesController { - constructor(private readonly propertiesService: PropertiesService) {} + constructor( + private readonly propertiesService: PropertiesService, + private readonly propertyReportService: PropertyReportService, + ) {} @UseGuards(JwtAuthGuard) @Post() @@ -41,4 +58,30 @@ export class PropertiesController { remove(@Param('id') id: string) { return this.propertiesService.remove(id); } + + @UseGuards(JwtAuthGuard) + @Get(':id/report') + async generateReport(@Param('id') id: string, @Res() res: Response) { + try { + const pdfBuffer = await this.propertyReportService.generatePropertyReport(id); + + // Set response headers for PDF download + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="property-report-${id}.pdf"`, + 'Content-Length': pdfBuffer.length, + }); + + // Send the PDF buffer + res.send(pdfBuffer); + } catch (error) { + // Handle errors appropriately + if (error.message.includes('not found')) { + return res.status(HttpStatus.NOT_FOUND).send({ message: error.message }); + } + return res + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .send({ message: 'Failed to generate property report' }); + } + } } diff --git a/src/properties/properties.module.ts b/src/properties/properties.module.ts index be88593b..1c408dd2 100644 --- a/src/properties/properties.module.ts +++ b/src/properties/properties.module.ts @@ -6,6 +6,7 @@ import { AuthModule } from '../auth/auth.module'; import { PropertiesResolver } from './properties.resolver'; import { PubSub } from 'graphql-subscriptions'; import { FraudModule } from '../fraud/fraud.module'; +import { PropertyReportService } from './report/property-report.service'; @Module({ imports: [PrismaModule, AuthModule, FraudModule], @@ -13,11 +14,12 @@ import { FraudModule } from '../fraud/fraud.module'; providers: [ PropertiesService, PropertiesResolver, + PropertyReportService, { provide: 'PUB_SUB', useValue: new PubSub(), }, ], - exports: [PropertiesService], + exports: [PropertiesService, PropertyReportService], }) export class PropertiesModule {} diff --git a/src/properties/report/property-report.service.ts b/src/properties/report/property-report.service.ts new file mode 100644 index 00000000..127c79f7 --- /dev/null +++ b/src/properties/report/property-report.service.ts @@ -0,0 +1,304 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { PropertiesService } from '../properties.service'; +import * as PDFDocument from 'pdfkit'; +import { Buffer } from 'buffer'; + +@Injectable() +export class PropertyReportService { + constructor( + private readonly prisma: PrismaService, + private readonly propertiesService: PropertiesService, + ) {} + + async generatePropertyReport(propertyId: string): Promise { + // Get the property + const property = await this.propertiesService.findOne(propertyId); + if (!property) { + throw new NotFoundException(`Property with ID ${propertyId} not found`); + } + + // Get comparable properties (similar properties in the same area) + const comparableProperties = await this.getComparableProperties(property); + + // Get market analysis data + const marketAnalysis = await this.getMarketAnalysis(property); + + // Generate PDF + const doc = new PDFDocument({ margin: 50 }); + const chunks: Buffer[] = []; + + doc.on('data', (chunk) => { + chunks.push(chunk); + }); + + doc.on('end', () => { + // PDF generation complete + }); + + // Add content to PDF + this.addPropertyHeader(doc, property); + this.addPropertyDetails(doc, property); + this.addComparableProperties(doc, comparableProperties); + this.addMarketAnalysis(doc, marketAnalysis); + this.addFooter(doc); + + doc.end(); + + // Wait for PDF to be generated + return new Promise((resolve, reject) => { + doc.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + doc.on('error', (err) => { + reject(err); + }); + }); + } + + private async getComparableProperties(property: any): Promise { + // Find similar properties in the same city and state + // With similar price range (+/- 20%) and same property type + const priceLow = property.price.times(0.8); + const priceHigh = property.price.times(1.2); + + return this.prisma.property.findMany({ + where: { + id: { not: property.id }, // Exclude the property itself + city: property.city, + state: property.state, + propertyType: property.propertyType, + price: { + gte: priceLow, + lte: priceHigh, + }, + status: 'ACTIVE', + }, + include: { + owner: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + take: 5, // Limit to 5 comparable properties + orderBy: { + updatedAt: 'desc', + }, + }); + } + + private async getMarketAnalysis(property: any): Promise { + // Get average price for similar properties in the area + const priceLow = property.price.times(0.8); + const priceHigh = property.price.times(1.2); + + const [avgPrice, count, recentSales] = await Promise.all([ + this.prisma.property.average({ + where: { + city: property.city, + state: property.state, + propertyType: property.propertyType, + price: { + gte: priceLow, + lte: priceHigh, + }, + status: 'ACTIVE', + }, + _avg: { + price: true, + }, + }), + this.prisma.property.count({ + where: { + city: property.city, + state: property.state, + propertyType: property.propertyType, + price: { + gte: priceLow, + lte: priceHigh, + }, + status: 'ACTIVE', + }, + }), + this.prisma.transaction.findMany({ + where: { + property: { + city: property.city, + state: property.state, + }, + status: 'COMPLETED', + }, + orderBy: { + createdAt: 'desc', + }, + take: 10, + include: { + property: true, + }, + }), + ]); + + // Calculate price trend from recent sales + let priceTrend = 'stable'; + if (recentSales.length >= 3) { + const prices = recentSales.map((tx: any) => tx.property.price); + const firstHalf = prices.slice(0, Math.ceil(prices.length / 2)); + const secondHalf = prices.slice(Math.ceil(prices.length / 2)); + + const avgFirstHalf = firstHalf + .reduce((sum, p) => sum.plus(p), new Decimal(0)) + .divide(firstHalf.length); + const avgSecondHalf = secondHalf + .reduce((sum, p) => sum.plus(p), new Decimal(0)) + .divide(secondHalf.length); + + const diffPercent = avgSecondHalf.minus(avgFirstHalf).divide(avgFirstHalf).times(100); + + if (diffPercent.greaterThan(5)) { + priceTrend = 'increasing'; + } else if (diffPercent.lessThan(-5)) { + priceTrend = 'decreasing'; + } + } + + return { + averagePrice: avgPrice._avg.price || new Decimal(0), + comparableCount: count, + recentSalesCount: recentSales.length, + priceTrend, + recentSales: recentSales.slice(0, 3), // Show top 3 recent sales + }; + } + + private addPropertyHeader(doc: PDFKit.PDFDocument, property: any) { + doc.fontSize(24).text('Property Report', { align: 'center' }).moveDown(0.5); + + doc.fontSize(16).text(property.title, { align: 'center' }).moveDown(1); + + // Add a separator line + doc + .moveTo(50, doc.y) + .lineTo(doc.page.width - 50, doc.y) + .stroke() + .moveDown(1); + } + + private addPropertyDetails(doc: PDFKit.PDFDocument, property: any) { + doc.fontSize(12); + + // Property basic info + doc.text(`Property ID: ${property.id}`, { continued: true }); + doc.text(`Status: ${property.status}`); + doc.moveDown(0.5); + + doc.text(`Address: ${property.address}`); + doc.text(`${property.city}, ${property.state} ${property.zipCode}`); + doc.text(`Country: ${property.country}`); + doc.moveDown(0.5); + + doc.text(`Price: $${property.price.toFormat(2)}`); + doc.text(`Property Type: ${property.propertyType}`); + doc.text(`Bedrooms: ${property.bedrooms || 'N/A'}`); + doc.text(`Bathrooms: ${property.bathrooms || 'N/A'}`); + doc.text(`Square Feet: ${property.squareFeet ? property.squareFeet.toFormat(0) : 'N/A'} sq ft`); + doc.text(`Lot Size: ${property.lotSize ? property.lotSize.toFormat(2) : 'N/A'} acres`); + doc.text(`Year Built: ${property.yearBuilt || 'N/A'}`); + doc.moveDown(0.5); + + if (property.description) { + doc.text('Description:'); + doc.text(property.description, { indent: 10 }); + doc.moveDown(0.5); + } + + if (property.features && property.features.length > 0) { + doc.text('Features:'); + const featuresText = property.features.join(', '); + doc.text(featuresText, { indent: 10 }); + doc.moveDown(0.5); + } + + // Owner information + if (property.owner) { + doc.text(`Listed by: ${property.owner.firstName} ${property.owner.lastName}`); + doc.moveDown(0.5); + } + + doc.moveDown(1); + } + + private addComparableProperties(doc: PDFKit.PDFDocument, properties: any[]) { + doc.fontSize(16).text('Comparable Properties', { underline: true }); + doc.moveDown(0.5); + + if (properties.length === 0) { + doc.text('No comparable properties found in the area.'); + doc.moveDown(1); + return; + } + + // Create a table-like structure for comparable properties + properties.forEach((prop, index) => { + if (index > 0) { + doc.moveDown(0.5); + } + + doc.fontSize(12).text(`${index + 1}. ${prop.title}`, { continued: true }); + doc.text(` - $${prop.price.toFormat(2)}`); + + doc.text(` ${prop.address}`); + doc.text(` ${prop.city}, ${prop.state} ${prop.zipCode}`); + doc.text( + ` ${prop.bedrooms} bd | ${prop.bathrooms} ba | ${prop.squareFeet ? prop.squareFeet.toFormat(0) : 'N/A'} sq ft`, + ); + + if (prop.owner) { + doc.text(` Listed by: ${prop.owner.firstName} ${prop.owner.lastName}`); + } + + doc.moveDown(0.2); + }); + + doc.moveDown(1); + } + + private addMarketAnalysis(doc: PDFKit.PDFDocument, analysis: any) { + doc.fontSize(16).text('Market Analysis', { underline: true }); + doc.moveDown(0.5); + + doc.fontSize(12); + doc.text(`Average Price in Area: $${analysis.averagePrice.toFormat(2)}`); + doc.text(`Number of Comparable Properties: ${analysis.comparableCount}`); + doc.text(`Recent Sales (Last 3 Months): ${analysis.recentSalesCount}`); + doc.text(`Price Trend: ${analysis.priceTrend}`); + + if (analysis.recentSales && analysis.recentSales.length > 0) { + doc.moveDown(0.5); + doc.text('Recent Sales:', { underline: true }); + doc.moveDown(0.2); + + analysis.recentSales.forEach((sale: any, index: number) => { + if (index > 0) { + doc.moveDown(0.2); + } + doc.text(`${index + 1}. ${sale.property.title}`); + doc.text(` Sale Price: $${sale.amount.toFormat(2)}`); + doc.text(` Sale Date: ${sale.createdAt.toLocaleDateString()}`); + doc.text( + ` Property: ${sale.property.bedrooms} bd | ${sale.property.bathrooms} ba | ${sale.property.squareFeet ? sale.property.squareFeet.toFormat(0) : 'N/A'} sq ft`, + ); + }); + } + + doc.moveDown(1); + } + + private addFooter(doc: PDFKit.PDFDocument) { + doc.fontSize(10); + doc.text(`Generated on ${new Date().toLocaleString()} | PropChain Real Estate Platform`, { + align: 'center', + }); + } +} From 0c34b6ed897f0ee7010822fcbda8e0e2b0a55350 Mon Sep 17 00:00:00 2001 From: victor-olamide Date: Wed, 27 May 2026 11:36:57 +0100 Subject: [PATCH 2/5] fix: resolve build errors in property report generation - Fix PrismaService import path in property-report.service.ts - Add missing Decimal import from @prisma/client/runtime/library - Add explicit type annotations to reduce callbacks - Add Promise return type to generateReport method --- src/properties/properties.controller.ts | 9 +++++---- src/properties/report/property-report.service.ts | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index 64db1633..d47e6e28 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -61,7 +61,7 @@ export class PropertiesController { @UseGuards(JwtAuthGuard) @Get(':id/report') - async generateReport(@Param('id') id: string, @Res() res: Response) { + async generateReport(@Param('id') id: string, @Res() res: Response): Promise { try { const pdfBuffer = await this.propertyReportService.generatePropertyReport(id); @@ -76,10 +76,11 @@ export class PropertiesController { res.send(pdfBuffer); } catch (error) { // Handle errors appropriately - if (error.message.includes('not found')) { - return res.status(HttpStatus.NOT_FOUND).send({ message: error.message }); + if (error.message?.includes('not found')) { + res.status(HttpStatus.NOT_FOUND).send({ message: error.message }); + return; } - return res + res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send({ message: 'Failed to generate property report' }); } diff --git a/src/properties/report/property-report.service.ts b/src/properties/report/property-report.service.ts index 127c79f7..80b3f83f 100644 --- a/src/properties/report/property-report.service.ts +++ b/src/properties/report/property-report.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../database/prisma.service'; +import { PrismaService } from '../../database/prisma.service'; +import { Decimal } from '@prisma/client/runtime/library'; import { PropertiesService } from '../properties.service'; import * as PDFDocument from 'pdfkit'; import { Buffer } from 'buffer'; @@ -148,10 +149,10 @@ export class PropertyReportService { const secondHalf = prices.slice(Math.ceil(prices.length / 2)); const avgFirstHalf = firstHalf - .reduce((sum, p) => sum.plus(p), new Decimal(0)) + .reduce((sum: Decimal, p: Decimal) => sum.plus(p), new Decimal(0)) .divide(firstHalf.length); const avgSecondHalf = secondHalf - .reduce((sum, p) => sum.plus(p), new Decimal(0)) + .reduce((sum: Decimal, p: Decimal) => sum.plus(p), new Decimal(0)) .divide(secondHalf.length); const diffPercent = avgSecondHalf.minus(avgFirstHalf).divide(avgFirstHalf).times(100); From a6ce39d5a1fceb081f80a7ccad3b792aa66dfb4c Mon Sep 17 00:00:00 2001 From: victor-olamide Date: Wed, 27 May 2026 12:00:32 +0100 Subject: [PATCH 3/5] fix: remove duplicate DisputeStatus enum in Prisma schema --- prisma/schema.prisma | 7 ------- 1 file changed, 7 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eaea0fa8..5b713964 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,13 +152,6 @@ enum MilestoneStatus { DELAYED } -enum DisputeStatus { - OPEN - UNDER_REVIEW - RESOLVED - CANCELLED -} - // User model model User { id String @id @default(uuid()) From da65cf6a107b397bf2866e73d44052def9a265c9 Mon Sep 17 00:00:00 2001 From: victor-olamide Date: Sat, 30 May 2026 08:30:28 +0100 Subject: [PATCH 4/5] fix: add missing Prisma schema relations for CI build Add cancelledBy relation on Transaction for CancelledTransactions, add versions relation on Document for DocumentVersion, and add uploadedDocumentVersions relation on User for DocumentVersionUploader. These missing opposite-side relation fields caused prisma generate to fail with P1012 validation errors, blocking the CI Lint & Build step. --- prisma/schema.prisma | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5b713964..c1898647 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -221,8 +221,9 @@ model User { emailEngagements EmailEngagement[] emailBounces EmailBounce[] digestPreference DigestPreference? - createdTaxStrategies TransactionTaxStrategy[] @relation("CreatedTransactionTaxStrategies") - transactionHistory TransactionHistory[] + createdTaxStrategies TransactionTaxStrategy[] @relation("CreatedTransactionTaxStrategies") + transactionHistory TransactionHistory[] + uploadedDocumentVersions DocumentVersion[] @relation("DocumentVersionUploader") @@index([email]) @@index([role]) @@ -438,6 +439,7 @@ model Transaction { property Property @relation(fields: [propertyId], references: [id]) buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id]) seller User @relation("SellerTransactions", fields: [sellerId], references: [id]) + cancelledBy User? @relation("CancelledTransactions", fields: [cancelledById], references: [id]) fraudAlerts FraudAlert[] taxStrategies TransactionTaxStrategy[] disputes Dispute[] @@ -508,6 +510,7 @@ model Document { property Property? @relation(fields: [propertyId], references: [id], onDelete: SetNull) user User @relation(fields: [userId], references: [id], onDelete: Cascade) dispute Dispute? @relation(fields: [disputeId], references: [id], onDelete: SetNull) + versions DocumentVersion[] @@index([propertyId]) @@index([transactionId]) From 28c85d5370d94f88df85e70e575cff82e1a44fd9 Mon Sep 17 00:00:00 2001 From: victor-olamide Date: Sat, 30 May 2026 08:30:28 +0100 Subject: [PATCH 5/5] fix: resolve build errors and merge conflicts with upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OpenHouse and OpenHouseRsvp Prisma models with RsvpStatus enum - Fix double angle-bracket syntax in users.service.ts (Promise<< → Promise<) - Remove duplicate import block in main.ts; fix useGlobalPipe typo - Remove duplicate import block in properties.service.ts; add null guard for existingProperty - Fix property-images.service.ts out-of-scope filename reference - Add findAuthorizedById, toObjectKey, buildUploadObjectKey to DocumentsService used by documents-download.controller.ts - Fix property-report.service.ts: use aggregate instead of average - Fix transactions.service.spec.ts duplicate TransactionTypeDto import - Add missing BadRequestException import in users.service.ts; replace non-schema bio/address/occupation/company field access with nulls - Use useGlobalPipes (plural) in main.ts bootstrap --- prisma/schema.prisma | 44 ++++ src/analytics/analytics.controller.ts | 20 +- src/analytics/analytics.service.ts | 9 +- src/commissions/commissions.controller.ts | 10 +- src/commissions/commissions.service.spec.ts | 15 +- src/commissions/commissions.service.ts | 53 +++-- src/documents/documents.service.ts | 25 +++ src/main.ts | 21 +- src/open-house/open-house.service.ts | 2 +- src/properties/dto/agent-assignment.dto.ts | 25 ++- .../properties.service.agent.spec.ts | 5 +- src/properties/properties.service.ts | 25 ++- src/properties/property-images.service.ts | 2 +- .../report/property-report.service.ts | 2 +- src/transactions/transactions.service.spec.ts | 1 - src/users/dto/profile-response.dto.ts | 2 +- src/users/dto/update-profile.dto.ts | 2 +- src/users/users.controller.ts | 7 +- src/users/users.service.ts | 212 +++++++++--------- 19 files changed, 286 insertions(+), 196 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c8772a7..df163678 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -229,6 +229,7 @@ model User { transactionHistory TransactionHistory[] favorites PropertyFavorite[] propertyViews PropertyView[] + openHouseRsvps OpenHouseRsvp[] @@index([email]) @@index([role]) @@ -438,6 +439,7 @@ model Property { mergedInto PropertyDuplicate[] @relation("MergedProperty") favorites PropertyFavorite[] views PropertyView[] + openHouses OpenHouse[] neighborhood Neighborhood? @relation(fields: [neighborhoodId], references: [id], onDelete: SetNull) @@index([ownerId]) @@ -1161,3 +1163,45 @@ model PropertyDuplicate { @@index([isMerged]) @@map("property_duplicates") } + +enum RsvpStatus { + ATTENDING + DECLINED + MAYBE +} + +model OpenHouse { + id String @id @default(uuid()) + propertyId String @map("property_id") + title String + description String? + startAt DateTime @map("start_at") + endAt DateTime @map("end_at") + isCancelled Boolean @default(false) @map("is_cancelled") + cancelledAt DateTime? @map("cancelled_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) + rsvps OpenHouseRsvp[] + + @@index([propertyId]) + @@map("open_houses") +} + +model OpenHouseRsvp { + id String @id @default(uuid()) + openHouseId String @map("open_house_id") + userId String @map("user_id") + status RsvpStatus @default(ATTENDING) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + openHouse OpenHouse @relation(fields: [openHouseId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([openHouseId, userId]) + @@index([openHouseId]) + @@index([userId]) + @@map("open_house_rsvps") +} diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts index 93ceafb5..af3b51dc 100644 --- a/src/analytics/analytics.controller.ts +++ b/src/analytics/analytics.controller.ts @@ -49,9 +49,7 @@ export class AnalyticsController { example: 60, }) @ApiResponse({ status: 200, description: 'Monitoring stats returned successfully' }) - getStats( - @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, - ) { + getStats(@Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number) { return this.analytics.getStats(window); } @@ -65,9 +63,7 @@ export class AnalyticsController { }) @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) @ApiResponse({ status: 200, description: 'Endpoint stats returned successfully' }) - getEndpoints( - @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, - ) { + getEndpoints(@Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number) { return this.analytics.getEndpointStats(window); } @@ -81,9 +77,7 @@ export class AnalyticsController { }) @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) @ApiResponse({ status: 200, description: 'Slow endpoints returned successfully' }) - getSlowEndpoints( - @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, - ) { + getSlowEndpoints(@Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number) { return this.analytics.getStats(window).slowEndpoints; } @@ -97,9 +91,7 @@ export class AnalyticsController { }) @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) @ApiResponse({ status: 200, description: 'Error stats returned successfully' }) - getErrors( - @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, - ) { + getErrors(@Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number) { const stats = this.analytics.getStats(window); return { window: stats.window, @@ -120,9 +112,7 @@ export class AnalyticsController { }) @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) @ApiResponse({ status: 200, description: 'User usage stats returned successfully' }) - getUsers( - @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, - ) { + getUsers(@Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number) { return this.analytics.getStats(window).topUsers; } diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index b973a391..18438ac3 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -96,10 +96,7 @@ export class AnalyticsService { } // --- Aggregate by endpoint --- - const endpointMap = new Map< - string, - { count: number; errors: number; times: number[] } - >(); + const endpointMap = new Map(); // --- Aggregate by user --- const userMap = new Map< @@ -222,9 +219,7 @@ export class AnalyticsService { * Usage breakdown for a specific user. */ getUserStats(userId: string, windowMinutes = 60): UserUsageStats | null { - const records = this.getWindowedRecords(windowMinutes).filter( - (r) => r.userId === userId, - ); + const records = this.getWindowedRecords(windowMinutes).filter((r) => r.userId === userId); if (records.length === 0) return null; const errors = records.filter((r) => r.statusCode >= 400).length; diff --git a/src/commissions/commissions.controller.ts b/src/commissions/commissions.controller.ts index 90fe944b..74d3b45d 100644 --- a/src/commissions/commissions.controller.ts +++ b/src/commissions/commissions.controller.ts @@ -11,10 +11,7 @@ export class CommissionsController { constructor(private readonly commissionsService: CommissionsService) {} @Get() - async findAll( - @Query() query: CommissionListQueryDto, - @CurrentUser() user: AuthUserPayload, - ) { + async findAll(@Query() query: CommissionListQueryDto, @CurrentUser() user: AuthUserPayload) { return this.commissionsService.findAll(query, user); } @@ -24,10 +21,7 @@ export class CommissionsController { } @Get(':id') - async findOne( - @Param('id') id: string, - @CurrentUser() user: AuthUserPayload, - ) { + async findOne(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { return this.commissionsService.findOne(id, user); } } diff --git a/src/commissions/commissions.service.spec.ts b/src/commissions/commissions.service.spec.ts index dd5b04c4..76c89bb4 100644 --- a/src/commissions/commissions.service.spec.ts +++ b/src/commissions/commissions.service.spec.ts @@ -24,10 +24,7 @@ describe('CommissionsService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ - CommissionsService, - { provide: PrismaService, useValue: mockPrismaService }, - ], + providers: [CommissionsService, { provide: PrismaService, useValue: mockPrismaService }], }).compile(); service = module.get(CommissionsService); @@ -129,10 +126,12 @@ describe('CommissionsService', () => { }); await expect( - service.findOne( - 'c-1', - { sub: 'agent-2', email: 'agent2@test.com', role: 'AGENT', type: 'access' }, - ), + service.findOne('c-1', { + sub: 'agent-2', + email: 'agent2@test.com', + role: 'AGENT', + type: 'access', + }), ).rejects.toBeInstanceOf(ForbiddenException); }); }); diff --git a/src/commissions/commissions.service.ts b/src/commissions/commissions.service.ts index f1e1c182..0cb679c4 100644 --- a/src/commissions/commissions.service.ts +++ b/src/commissions/commissions.service.ts @@ -1,4 +1,10 @@ -import { Injectable, NotFoundException, ForbiddenException, BadRequestException, Logger } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ForbiddenException, + BadRequestException, + Logger, +} from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; import { Decimal } from '@prisma/client/runtime/library'; import { CommissionListQueryDto } from './dto/commission.dto'; @@ -32,7 +38,9 @@ export class CommissionsService { } const agents = (transaction as any).property?.agents || []; - this.logger.log(`Found ${agents.length} agents assigned to property for transaction ${transactionId}`); + this.logger.log( + `Found ${agents.length} agents assigned to property for transaction ${transactionId}`, + ); for (const agentAssignment of agents) { // Calculate commission amount @@ -49,7 +57,9 @@ export class CommissionsService { }); if (existing) { - this.logger.warn(`Commission for transaction ${transactionId} and agent ${agentAssignment.agentId} already exists`); + this.logger.warn( + `Commission for transaction ${transactionId} and agent ${agentAssignment.agentId} already exists`, + ); continue; } @@ -64,10 +74,15 @@ export class CommissionsService { }, }); - this.logger.log(`Created commission of ${amount.toString()} for agent ${agentAssignment.agentId} on transaction ${transactionId}`); + this.logger.log( + `Created commission of ${amount.toString()} for agent ${agentAssignment.agentId} on transaction ${transactionId}`, + ); } } catch (error) { - this.logger.error(`Failed to create commissions for transaction ${transactionId}: ${error.message}`, error.stack); + this.logger.error( + `Failed to create commissions for transaction ${transactionId}: ${error.message}`, + error.stack, + ); } } @@ -77,20 +92,21 @@ export class CommissionsService { async updateCommissionsStatus(transactionId: string, status: string): Promise { try { const dbStatus = - status === 'COMPLETED' - ? 'COMPLETED' - : status === 'CANCELLED' - ? 'CANCELLED' - : 'PENDING'; + status === 'COMPLETED' ? 'COMPLETED' : status === 'CANCELLED' ? 'CANCELLED' : 'PENDING'; const result = await (this.prisma as any).commission.updateMany({ where: { transactionId }, data: { status: dbStatus as any }, }); - this.logger.log(`Updated ${result.count} commission statuses to ${dbStatus} for transaction ${transactionId}`); + this.logger.log( + `Updated ${result.count} commission statuses to ${dbStatus} for transaction ${transactionId}`, + ); } catch (error) { - this.logger.error(`Failed to update commission statuses for transaction ${transactionId}: ${error.message}`, error.stack); + this.logger.error( + `Failed to update commission statuses for transaction ${transactionId}: ${error.message}`, + error.stack, + ); } } @@ -254,8 +270,12 @@ export class CommissionsService { }); // Get count of completed vs pending - const completedCount = await (this.prisma as any).commission.count({ where: { status: 'COMPLETED' } }); - const pendingCount = await (this.prisma as any).commission.count({ where: { status: 'PENDING' } }); + const completedCount = await (this.prisma as any).commission.count({ + where: { status: 'COMPLETED' }, + }); + const pendingCount = await (this.prisma as any).commission.count({ + where: { status: 'PENDING' }, + }); // Breakdown per agent const commissions = await (this.prisma as any).commission.findMany({ @@ -271,7 +291,10 @@ export class CommissionsService { }, }); - const agentMap = new Map(); + const agentMap = new Map< + string, + { name: string; email: string; earned: number; pending: number } + >(); commissions.forEach((c: any) => { const agentId = c.agentId; const current = agentMap.get(agentId) || { diff --git a/src/documents/documents.service.ts b/src/documents/documents.service.ts index 2b41ba59..fd5abadf 100644 --- a/src/documents/documents.service.ts +++ b/src/documents/documents.service.ts @@ -66,6 +66,31 @@ export class DocumentsService { return doc; } + async findAuthorizedById(id: string, userId: string) { + const doc = await this.prisma.document.findUnique({ where: { id } }); + if (!doc) throw new NotFoundException('Document not found'); + if (doc.userId !== userId) throw new NotFoundException('Document not found'); + return doc; + } + + toObjectKey(fileUrl: string): string { + try { + return new URL(fileUrl).pathname.replace(/^\//, ''); + } catch { + return fileUrl; + } + } + + async buildUploadObjectKey(opts: { + mimeType: string; + userId: string; + documentId?: string; + }): Promise { + const ext = opts.mimeType.split('/')[1] ?? 'bin'; + const name = opts.documentId ?? crypto.randomUUID(); + return `documents/${opts.userId}/${name}.${ext}`; + } + async update(id: string, dto: UpdateDocumentDto) { await this.findOne(id); return this.prisma.document.update({ where: { id }, data: dto as any }); diff --git a/src/main.ts b/src/main.ts index 957358d5..44fa7afb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,6 @@ import { RateLimitGuard } from './auth/guards/rate-limit.guard'; import { RateLimitService } from './auth/rate-limit.service'; import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; import { setupSwagger } from './config/swagger.config'; -import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -51,16 +50,16 @@ async function bootstrap() { const cacheMonitoringService = app.get(CacheMonitoringService); app.useGlobalInterceptors(new CacheMetricsInterceptor(cacheMonitoringService)); - app.useGlobalPipe( - new ValidationPipe({ - whitelist: true, // Strip properties not in DTO - forbidNonWhitelisted: true, // Throw error for extra properties - transform: true, // Auto-transform types - transformOptions: { - enableImplicitConversion: true, - }, - }), -); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); // Setup Swagger documentation setupSwagger(app); diff --git a/src/open-house/open-house.service.ts b/src/open-house/open-house.service.ts index ddb874c0..53c50939 100644 --- a/src/open-house/open-house.service.ts +++ b/src/open-house/open-house.service.ts @@ -11,7 +11,7 @@ export class OpenHouseService { return this.prisma.openHouse.create({ data: { propertyId: dto.propertyId, - title: dto.title, + title: dto.title ?? 'Open House', description: dto.description, startAt: dto.startAt, endAt: dto.endAt, diff --git a/src/properties/dto/agent-assignment.dto.ts b/src/properties/dto/agent-assignment.dto.ts index 9ed25e14..d051122e 100644 --- a/src/properties/dto/agent-assignment.dto.ts +++ b/src/properties/dto/agent-assignment.dto.ts @@ -6,38 +6,51 @@ export class AssignAgentDto { @IsString() agentId: string; - @ApiPropertyOptional({ description: 'The commission rate for this agent on this property (0 to 1, e.g. 0.03 for 3%)', default: 0.03 }) + @ApiPropertyOptional({ + description: 'The commission rate for this agent on this property (0 to 1, e.g. 0.03 for 3%)', + default: 0.03, + }) @IsOptional() @IsNumber() @Min(0) @Max(1) commissionRate?: number; - @ApiPropertyOptional({ description: 'Override contact phone number for this property assignment' }) + @ApiPropertyOptional({ + description: 'Override contact phone number for this property assignment', + }) @IsOptional() @IsString() contactPhone?: string; - @ApiPropertyOptional({ description: 'Override contact email address for this property assignment' }) + @ApiPropertyOptional({ + description: 'Override contact email address for this property assignment', + }) @IsOptional() @IsEmail() contactEmail?: string; } export class UpdateAgentAssignmentDto { - @ApiPropertyOptional({ description: 'The commission rate for this agent on this property (0 to 1, e.g. 0.03 for 3%)' }) + @ApiPropertyOptional({ + description: 'The commission rate for this agent on this property (0 to 1, e.g. 0.03 for 3%)', + }) @IsOptional() @IsNumber() @Min(0) @Max(1) commissionRate?: number; - @ApiPropertyOptional({ description: 'Override contact phone number for this property assignment' }) + @ApiPropertyOptional({ + description: 'Override contact phone number for this property assignment', + }) @IsOptional() @IsString() contactPhone?: string; - @ApiPropertyOptional({ description: 'Override contact email address for this property assignment' }) + @ApiPropertyOptional({ + description: 'Override contact email address for this property assignment', + }) @IsOptional() @IsEmail() contactEmail?: string; diff --git a/src/properties/properties.service.agent.spec.ts b/src/properties/properties.service.agent.spec.ts index 680833a3..455bb390 100644 --- a/src/properties/properties.service.agent.spec.ts +++ b/src/properties/properties.service.agent.spec.ts @@ -102,7 +102,10 @@ describe('PropertiesService - Agent Assignment', () => { it('successfully updates an assignment', async () => { mockPrismaService.property.findUnique.mockResolvedValue({ id: 'prop-1', ownerId: 'owner-1' }); mockPrismaService.propertyAgent.findUnique.mockResolvedValue({ id: 'assign-1' }); - mockPrismaService.propertyAgent.update.mockResolvedValue({ id: 'assign-1', commissionRate: new Decimal('0.04') }); + mockPrismaService.propertyAgent.update.mockResolvedValue({ + id: 'assign-1', + commissionRate: new Decimal('0.04'), + }); const result = await service.updateAgentAssignment( 'prop-1', diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index ed9e34a2..110dbddd 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -1,9 +1,3 @@ -import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; -import { Decimal } from '@prisma/client/runtime/library'; -import { PrismaService } from '../database/prisma.service'; -import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; -import { AssignAgentDto, UpdateAgentAssignmentDto } from './dto/agent-assignment.dto'; -import { AuthUserPayload } from '../auth/types/auth-user.type'; import { BadRequestException, ForbiddenException, @@ -13,6 +7,8 @@ import { import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../database/prisma.service'; import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; +import { AssignAgentDto, UpdateAgentAssignmentDto } from './dto/agent-assignment.dto'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; import { SearchPropertiesDto } from './dto/search-properties.dto'; import { FraudService } from '../fraud/fraud.service'; import { GeocodingService } from './geocoding.service'; @@ -171,6 +167,7 @@ export class PropertiesService { // Duplicate address check (if address fields are being updated) if (rest.address || rest.city || rest.state || rest.zipCode || rest.country) { const existingProperty = await this.prisma.property.findUnique({ where: { id } }); + if (!existingProperty) throw new NotFoundException(`Property ${id} not found`); const newAddress = { address: rest.address ?? existingProperty.address, city: rest.city ?? existingProperty.city, @@ -614,7 +611,10 @@ export class PropertiesService { data: { propertyId, agentId: dto.agentId, - commissionRate: dto.commissionRate !== undefined ? new Decimal(dto.commissionRate.toString()) : new Decimal('0.03'), + commissionRate: + dto.commissionRate !== undefined + ? new Decimal(dto.commissionRate.toString()) + : new Decimal('0.03'), contactPhone: dto.contactPhone ?? null, contactEmail: dto.contactEmail ?? null, }, @@ -646,7 +646,9 @@ export class PropertiesService { } if (user.role !== 'ADMIN' && property.ownerId !== user.sub) { - throw new ForbiddenException('Only the property owner or an admin can update agent assignments'); + throw new ForbiddenException( + 'Only the property owner or an admin can update agent assignments', + ); } const assignment = await (this.prisma as any).propertyAgent.findUnique({ @@ -669,7 +671,8 @@ export class PropertiesService { }, }, data: { - commissionRate: dto.commissionRate !== undefined ? new Decimal(dto.commissionRate.toString()) : undefined, + commissionRate: + dto.commissionRate !== undefined ? new Decimal(dto.commissionRate.toString()) : undefined, contactPhone: dto.contactPhone !== undefined ? dto.contactPhone : undefined, contactEmail: dto.contactEmail !== undefined ? dto.contactEmail : undefined, }, @@ -696,7 +699,9 @@ export class PropertiesService { } if (user.role !== 'ADMIN' && property.ownerId !== user.sub) { - throw new ForbiddenException('Only the property owner or an admin can remove agent assignments'); + throw new ForbiddenException( + 'Only the property owner or an admin can remove agent assignments', + ); } const assignment = await (this.prisma as any).propertyAgent.findUnique({ diff --git a/src/properties/property-images.service.ts b/src/properties/property-images.service.ts index 5a1cacaa..e204e6b3 100644 --- a/src/properties/property-images.service.ts +++ b/src/properties/property-images.service.ts @@ -385,7 +385,7 @@ export class PropertyImagesService { }); this.logger.log( - `Stored image ${filename} for property ${propertyId} (order=${order}, primary=${isPrimary})`, + `Stored image ${baseName}.webp for property ${propertyId} (order=${order}, primary=${isPrimary})`, ); return this.toResponse(created); diff --git a/src/properties/report/property-report.service.ts b/src/properties/report/property-report.service.ts index 80b3f83f..39d0e718 100644 --- a/src/properties/report/property-report.service.ts +++ b/src/properties/report/property-report.service.ts @@ -96,7 +96,7 @@ export class PropertyReportService { const priceHigh = property.price.times(1.2); const [avgPrice, count, recentSales] = await Promise.all([ - this.prisma.property.average({ + this.prisma.property.aggregate({ where: { city: property.city, state: property.state, diff --git a/src/transactions/transactions.service.spec.ts b/src/transactions/transactions.service.spec.ts index b0e99146..cc970ad0 100644 --- a/src/transactions/transactions.service.spec.ts +++ b/src/transactions/transactions.service.spec.ts @@ -6,7 +6,6 @@ import { BlockchainService } from '../blockchain/blockchain.service'; import { NotificationsService } from '../notifications/notifications.service'; import { TransactionAnalyticsGranularity, TransactionTypeDto } from './dto/transaction.dto'; import { CommissionsService } from '../commissions/commissions.service'; -import { TransactionTypeDto } from './dto/transaction.dto'; describe('TransactionsService', () => { let service: TransactionsService; diff --git a/src/users/dto/profile-response.dto.ts b/src/users/dto/profile-response.dto.ts index b6e592ec..4a9e57c9 100644 --- a/src/users/dto/profile-response.dto.ts +++ b/src/users/dto/profile-response.dto.ts @@ -31,4 +31,4 @@ export class ProfileResponseDto { transactionsCount: number; accountAgeDays: number; } | null; -} \ No newline at end of file +} diff --git a/src/users/dto/update-profile.dto.ts b/src/users/dto/update-profile.dto.ts index fbd9ed12..a8503d76 100644 --- a/src/users/dto/update-profile.dto.ts +++ b/src/users/dto/update-profile.dto.ts @@ -118,4 +118,4 @@ export class UpdateProfileDto { @IsString() @MaxLength(100) company?: string; -} \ No newline at end of file +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 47346d54..d29eca1f 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -113,10 +113,7 @@ export class UsersController { @UseGuards(JwtAuthGuard) @Put('me/profile') - updateProfile( - @CurrentUser() user: AuthUserPayload, - @Body() updateProfileDto: UpdateProfileDto, - ) { + updateProfile(@CurrentUser() user: AuthUserPayload, @Body() updateProfileDto: UpdateProfileDto) { return this.usersService.updateProfile(user.sub, updateProfileDto); } @@ -290,4 +287,4 @@ export class UsersController { return match[1]; } -} \ No newline at end of file +} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 4756d108..ac418aab 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,4 +1,10 @@ -import { Injectable, Logger, OnModuleInit, NotFoundException } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleInit, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; import { CreateUserDto, SearchUsersDto, UpdatePreferencesDto, UpdateUserDto } from './dto/user.dto'; import { DeactivateAccountDto, ReactivateAccountDto } from './dto/deactivation.dto'; @@ -10,120 +16,118 @@ import { ProfileResponseDto } from './dto/profile-response.dto'; @Injectable() export class UsersService implements OnModuleInit { - async getProfile(userId: string): Promise< { - const user = await this.prisma.user.findUnique({ - where: { id: userId, isDeactivated: false }, - include: { - properties: { select: { id: true } }, - buyerTransactions: { select: { id: true } }, - sellerTransactions: { select: { id: true } }, - _count: { - select: { - properties: true, - buyerTransactions: true, - sellerTransactions: true, + async getProfile(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId, isDeactivated: false }, + include: { + properties: { select: { id: true } }, + buyerTransactions: { select: { id: true } }, + sellerTransactions: { select: { id: true } }, + _count: { + select: { + properties: true, + buyerTransactions: true, + sellerTransactions: true, + }, }, }, - }, - }); + }); - if (!user) { - throw new NotFoundException('User profile not found'); + if (!user) { + throw new NotFoundException('User profile not found'); + } + + const now = new Date(); + const createdAt = new Date(user.createdAt); + const accountAgeDays = Math.floor( + (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24), + ); + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: `${user.firstName} ${user.lastName}`, + phone: user.phone, + avatar: user.avatar, + bio: null, + role: user.role, + isVerified: user.isVerified, + preferredChannel: user.preferredChannel, + languagePreference: user.languagePreference, + timezone: user.timezone, + contactHours: user.contactHours as { start: string; end: string } | null, + address: null, + occupation: null, + company: null, + referralCode: user.referralCode, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastActivityAt: user.lastActivityAt, + statistics: { + propertiesCount: user._count.properties, + transactionsCount: user._count.buyerTransactions + user._count.sellerTransactions, + accountAgeDays, + }, + }; } - const now = new Date(); - const createdAt = new Date(user.createdAt); - const accountAgeDays = Math.floor( - (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24), - ); - - return { - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - fullName: `${user.firstName} ${user.lastName}`, - phone: user.phone, - avatar: user.avatar, - bio: user.bio || null, // if bio field exists in schema, otherwise omit - role: user.role, - isVerified: user.isVerified, - preferredChannel: user.preferredChannel, - languagePreference: user.languagePreference, - timezone: user.timezone, - contactHours: user.contactHours as { start: string; end: string } | null, - address: user.address as any || null, // if address field exists - occupation: user.occupation || null, - company: user.company || null, - referralCode: user.referralCode, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - lastActivityAt: user.lastActivityAt, - statistics: { - propertiesCount: user._count.properties, - transactionsCount: user._count.buyerTransactions + user._count.sellerTransactions, - accountAgeDays, - }, - }; -} + async updateProfile(userId: string, data: UpdateProfileDto): Promise { + // Check if email is being changed and if it's already taken + if (data.email) { + const existingUser = await this.prisma.user.findFirst({ + where: { + email: data.email, + NOT: { id: userId }, + }, + }); -async updateProfile( - userId: string, - data: UpdateProfileDto, -): Promise< { - // Check if email is being changed and if it's already taken - if (data.email) { - const existingUser = await this.prisma.user.findFirst({ - where: { - email: data.email, - NOT: { id: userId }, + if (existingUser) { + throw new BadRequestException('Email address is already in use'); + } + } + + // Build update data — only include provided fields + const updateData: any = {}; + + if (data.firstName !== undefined) updateData.firstName = data.firstName; + if (data.lastName !== undefined) updateData.lastName = data.lastName; + if (data.email !== undefined) updateData.email = data.email; + if (data.phone !== undefined) updateData.phone = data.phone; + if (data.avatar !== undefined) updateData.avatar = data.avatar; + if (data.bio !== undefined) updateData.bio = data.bio; + if (data.preferredChannel !== undefined) updateData.preferredChannel = data.preferredChannel; + if (data.languagePreference !== undefined) + updateData.languagePreference = data.languagePreference; + if (data.timezone !== undefined) updateData.timezone = data.timezone; + if (data.contactHours !== undefined) updateData.contactHours = data.contactHours; + if (data.address !== undefined) updateData.address = data.address; + if (data.occupation !== undefined) updateData.occupation = data.occupation; + if (data.company !== undefined) updateData.company = data.company; + + // Update user + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + + // Log the profile update activity + await this.prisma.activityLog.create({ + data: { + userId, + action: 'UPDATE_PROFILE', + entityType: 'USER', + entityId: userId, + description: 'User updated their profile', + metadata: { updatedFields: Object.keys(updateData) }, }, }); - if (existingUser) { - throw new BadRequestException('Email address is already in use'); - } + // Return fresh profile + return this.getProfile(userId); } - // Build update data — only include provided fields - const updateData: any = {}; - - if (data.firstName !== undefined) updateData.firstName = data.firstName; - if (data.lastName !== undefined) updateData.lastName = data.lastName; - if (data.email !== undefined) updateData.email = data.email; - if (data.phone !== undefined) updateData.phone = data.phone; - if (data.avatar !== undefined) updateData.avatar = data.avatar; - if (data.bio !== undefined) updateData.bio = data.bio; - if (data.preferredChannel !== undefined) updateData.preferredChannel = data.preferredChannel; - if (data.languagePreference !== undefined) updateData.languagePreference = data.languagePreference; - if (data.timezone !== undefined) updateData.timezone = data.timezone; - if (data.contactHours !== undefined) updateData.contactHours = data.contactHours; - if (data.address !== undefined) updateData.address = data.address; - if (data.occupation !== undefined) updateData.occupation = data.occupation; - if (data.company !== undefined) updateData.company = data.company; - - // Update user - const updatedUser = await this.prisma.user.update({ - where: { id: userId }, - data: updateData, - }); - - // Log the profile update activity - await this.prisma.activityLog.create({ - data: { - userId, - action: 'UPDATE_PROFILE', - entityType: 'USER', - entityId: userId, - description: 'User updated their profile', - metadata: { updatedFields: Object.keys(updateData) }, - }, - }); - - // Return fresh profile - return this.getProfile(userId); -} - private readonly logger = new Logger(UsersService.name); constructor(private prisma: PrismaService) {}