diff --git a/package-lock.json b/package-lock.json index a80c20da..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", @@ -4550,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", @@ -4955,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", @@ -6333,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", @@ -7842,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", @@ -9067,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": { @@ -9379,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", @@ -11502,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", @@ -11871,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", @@ -13430,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", @@ -13698,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", @@ -13925,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", @@ -15317,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", @@ -16675,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", @@ -17164,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", 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/prisma/schema.prisma b/prisma/schema.prisma index 2753886a..df163678 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,10 +225,11 @@ model User { emailEngagements EmailEngagement[] emailBounces EmailBounce[] digestPreference DigestPreference? - createdTaxStrategies TransactionTaxStrategy[] @relation("CreatedTransactionTaxStrategies") - transactionHistory TransactionHistory[] - favorites PropertyFavorite[] - propertyViews PropertyView[] + createdTaxStrategies TransactionTaxStrategy[] @relation("CreatedTransactionTaxStrategies") + 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]) @@ -598,12 +600,12 @@ model Transaction { commissions Commission[] 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[] transactionMilestones TransactionMilestone[] transactionHistory TransactionHistory[] - cancelledBy User? @relation("CancelledTransactions", fields: [cancelledById], references: [id]) @@index([propertyId]) @@index([buyerId]) @@ -1136,6 +1138,8 @@ model Commission { @@unique([transactionId, agentId]) @@map("commissions") +} + // Property duplicate detection results model PropertyDuplicate { id String @id @default(uuid()) @@ -1159,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.controller.ts b/src/properties/properties.controller.ts index 7353479c..4da7ae13 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -9,6 +9,8 @@ import { Patch, Query, UseGuards, + Res, + HttpStatus, } from '@nestjs/common'; import { PropertiesService } from './properties.service'; import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; @@ -26,10 +28,15 @@ import { BulkPropertyDeleteDto, BulkPropertyExportDto, } from './dto/bulk-operations.dto'; +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() @@ -75,6 +82,30 @@ export class PropertiesController { return this.propertiesService.remove(id); } + @UseGuards(JwtAuthGuard) + @Get(':id/report') + async generateReport(@Param('id') id: string, @Res() res: Response): Promise { + try { + const pdfBuffer = await this.propertyReportService.generatePropertyReport(id); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="property-report-${id}.pdf"`, + 'Content-Length': pdfBuffer.length, + }); + + res.send(pdfBuffer); + } catch (error) { + if (error.message?.includes('not found')) { + res.status(HttpStatus.NOT_FOUND).send({ message: error.message }); + return; + } + res + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .send({ message: 'Failed to generate property report' }); + } + } + /** * Transition a property's lifecycle status. * Workflow: DRAFT → PENDING → ACTIVE → UNDER_CONTRACT → SOLD diff --git a/src/properties/properties.module.ts b/src/properties/properties.module.ts index 9a9505bb..a739f0d9 100644 --- a/src/properties/properties.module.ts +++ b/src/properties/properties.module.ts @@ -10,6 +10,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, ConfigModule], @@ -19,11 +20,12 @@ import { FraudModule } from '../fraud/fraud.module'; PropertyImagesService, GeocodingService, PropertiesResolver, + PropertyReportService, { provide: 'PUB_SUB', useValue: new PubSub(), }, ], - exports: [PropertiesService, PropertyImagesService, GeocodingService], + exports: [PropertiesService, PropertyReportService, PropertyImagesService, GeocodingService], }) export class PropertiesModule {} 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 new file mode 100644 index 00000000..39d0e718 --- /dev/null +++ b/src/properties/report/property-report.service.ts @@ -0,0 +1,305 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +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'; + +@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.aggregate({ + 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: Decimal, p: Decimal) => sum.plus(p), new Decimal(0)) + .divide(firstHalf.length); + const avgSecondHalf = secondHalf + .reduce((sum: Decimal, p: Decimal) => 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', + }); + } +} 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) {}