From 234721b4d6a1d25d26c121d010a98aeabb68efb2 Mon Sep 17 00:00:00 2001 From: Godsmiracle001 Date: Sat, 25 Apr 2026 08:57:12 +0100 Subject: [PATCH 1/2] all fixed --- package-lock.json | 326 +++++++++++++++++++-- package.json | 7 +- prisma/schema.prisma | 96 ++++++ src/app.module.ts | 4 + src/auth/decorators/gql-user.decorator.ts | 10 +- src/common/common.types.ts | 18 +- src/content/content.controller.ts | 12 +- src/content/content.module.ts | 2 +- src/content/content.service.ts | 2 +- src/email/email-webhook.controller.ts | 25 ++ src/email/email.module.ts | 5 + src/email/email.service.ts | 101 +++++-- src/notifications/notifications.gateway.ts | 58 ++++ src/notifications/notifications.module.ts | 11 + src/notifications/notifications.service.ts | 73 +++++ src/properties/properties.resolver.ts | 10 +- src/search/search-analytics.service.ts | 75 ++--- src/search/search-autocomplete.service.ts | 53 ++-- src/search/search-facets.service.ts | 5 +- src/search/search-filters.service.ts | 22 +- src/search/search-geographic.service.ts | 44 +-- src/search/search.controller.ts | 2 +- src/search/search.module.ts | 9 +- src/search/search.service.ts | 15 +- src/search/voice-search.service.ts | 5 +- src/tracking/tracking.controller.ts | 71 +++++ src/tracking/tracking.module.ts | 12 + src/tracking/tracking.service.ts | 76 +++++ src/users/users.resolver.ts | 5 +- 29 files changed, 955 insertions(+), 199 deletions(-) create mode 100644 src/email/email-webhook.controller.ts create mode 100644 src/notifications/notifications.gateway.ts create mode 100644 src/notifications/notifications.module.ts create mode 100644 src/notifications/notifications.service.ts create mode 100644 src/tracking/tracking.controller.ts create mode 100644 src/tracking/tracking.module.ts create mode 100644 src/tracking/tracking.service.ts diff --git a/package-lock.json b/package-lock.json index 2b25c8f9..b2e52761 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,12 @@ "@nestjs/core": "^10.3.0", "@nestjs/graphql": "^12.2.2", "@nestjs/platform-express": "^10.3.0", + "@nestjs/platform-socket.io": "^10.4.22", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.1.16", + "@nestjs/websockets": "^10.4.22", "@prisma/client": "^6.19.2", + "@types/uuid": "^10.0.0", "archiver": "^7.0.1", "bcrypt": "^6.0.0", "cache-manager": "^7.2.8", @@ -40,7 +43,9 @@ "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sharp": "^0.34.5", - "swagger-ui-express": "^5.0.1" + "socket.io": "^4.8.3", + "swagger-ui-express": "^5.0.1", + "uuid": "^14.0.0" }, "devDependencies": { "@nestjs/cli": "^10.3.0", @@ -451,13 +456,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@apollo/server/node_modules/negotiator": { - "version": "0.6.3", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@apollo/server/node_modules/path-to-regexp": { "version": "0.1.13", "license": "MIT" @@ -497,6 +495,19 @@ "node": ">= 0.8.0" } }, + "node_modules/@apollo/server/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@apollo/usage-reporting-protobuf": { "version": "4.1.1", "license": "MIT", @@ -2832,13 +2843,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nestjs/platform-express/node_modules/negotiator": { - "version": "0.6.3", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { "version": "0.1.12", "license": "MIT" @@ -2878,6 +2882,73 @@ "node": ">= 0.8.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz", + "integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.1", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/platform-socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-socket.io/node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, "node_modules/@nestjs/schedule": { "version": "6.1.3", "license": "MIT", @@ -2984,6 +3055,29 @@ } } }, + "node_modules/@nestjs/websockets": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", + "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -3254,6 +3348,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "devOptional": true, @@ -3369,6 +3469,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "dev": true, @@ -3579,10 +3688,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "dev": true, @@ -4538,6 +4662,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.16", "dev": true, @@ -5780,6 +5913,70 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "dev": true, @@ -8758,6 +8955,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "dev": true, @@ -8882,6 +9088,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "license": "MIT", @@ -10239,6 +10454,81 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.7.4", "dev": true, @@ -11206,14 +11496,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index 48995117..ee9c4f3c 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,12 @@ "@nestjs/core": "^10.3.0", "@nestjs/graphql": "^12.2.2", "@nestjs/platform-express": "^10.3.0", + "@nestjs/platform-socket.io": "^10.4.22", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.1.16", + "@nestjs/websockets": "^10.4.22", "@prisma/client": "^6.19.2", + "@types/uuid": "^10.0.0", "archiver": "^7.0.1", "bcrypt": "^6.0.0", "cache-manager": "^7.2.8", @@ -57,7 +60,9 @@ "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sharp": "^0.34.5", - "swagger-ui-express": "^5.0.1" + "socket.io": "^4.8.3", + "swagger-ui-express": "^5.0.1", + "uuid": "^14.0.0" }, "devDependencies": { "@nestjs/cli": "^10.3.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 40e27858..57827d14 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -91,6 +91,24 @@ enum FraudPattern { HIGH_VALUE_NEW_ACCOUNT_LISTING } +enum NotificationStatus { + PENDING + DELIVERED + READ +} + +enum EmailStatus { + ACTIVE + BOUNCED + INVALID +} + +enum BounceType { + HARD + SOFT +} + + // User model model User { id String @id @default(uuid()) @@ -147,6 +165,12 @@ model User { savedFilters SavedFilter[] searchAnalytics SearchAnalytics[] searchHistory SearchHistory[] + emailStatus EmailStatus @default(ACTIVE) @map("email_status") + notifications Notification[] + linkClicks LinkClick[] + emailEngagements EmailEngagement[] + emailBounces EmailBounce[] + @@index([email]) @@index([role]) @@ -602,3 +626,75 @@ model SearchSuggestion { @@index([expiresAt]) @@map("search_suggestions") } + +model Notification { + id String @id @default(uuid()) + userId String @map("user_id") + title String + message String + type String + status NotificationStatus @default(PENDING) + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + readAt DateTime? @map("read_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([status]) + @@index([createdAt]) + @@map("notifications") +} + +model LinkClick { + id String @id @default(uuid()) + url String + userId String? @map("user_id") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([url]) + @@index([userId]) + @@index([createdAt]) + @@map("link_clicks") +} + +model EmailEngagement { + id String @id @default(uuid()) + trackingId String @unique @map("tracking_id") + userId String @map("user_id") + emailType String @map("email_type") + openedAt DateTime? @map("opened_at") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([trackingId]) + @@index([createdAt]) + @@map("email_engagements") +} + +model EmailBounce { + id String @id @default(uuid()) + userId String @map("user_id") + email String + bounceType BounceType @map("bounce_type") + reason String? + rawEvent Json? @map("raw_event") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([email]) + @@index([bounceType]) + @@map("email_bounces") +} + diff --git a/src/app.module.ts b/src/app.module.ts index 3f003e4e..308f0983 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,8 @@ import './common/common.types'; // Load registered enums import { AdminModule } from './admin/admin.module'; import { FraudModule } from './fraud/fraud.module'; import { SearchModule } from './search/search.module'; +import { TrackingModule } from './tracking/tracking.module'; +import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -52,6 +54,8 @@ import { SearchModule } from './search/search.module'; DocumentsModule, IntegrationsModule, SearchModule, + TrackingModule, + NotificationsModule, ], controllers: [AppController], }) diff --git a/src/auth/decorators/gql-user.decorator.ts b/src/auth/decorators/gql-user.decorator.ts index a2ef0e46..0b70aad5 100644 --- a/src/auth/decorators/gql-user.decorator.ts +++ b/src/auth/decorators/gql-user.decorator.ts @@ -1,9 +1,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; -export const GqlUser = createParamDecorator( - (data: unknown, context: ExecutionContext) => { - const ctx = GqlExecutionContext.create(context); - return ctx.getContext().req.authUser; - }, -); +export const GqlUser = createParamDecorator((data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.authUser; +}); diff --git a/src/common/common.types.ts b/src/common/common.types.ts index fdf5fbc9..fe8cdef2 100644 --- a/src/common/common.types.ts +++ b/src/common/common.types.ts @@ -1,5 +1,12 @@ import { registerEnumType } from '@nestjs/graphql'; -import { UserRole, PropertyStatus, TransactionType, TransactionStatus, DocumentType, VerificationStatus } from '@prisma/client'; +import { + UserRole, + PropertyStatus, + TransactionType, + TransactionStatus, + DocumentType, + VerificationStatus, +} from '@prisma/client'; registerEnumType(UserRole, { name: 'UserRole' }); registerEnumType(PropertyStatus, { name: 'PropertyStatus' }); @@ -8,4 +15,11 @@ registerEnumType(TransactionStatus, { name: 'TransactionStatus' }); registerEnumType(DocumentType, { name: 'DocumentType' }); registerEnumType(VerificationStatus, { name: 'VerificationStatus' }); -export { UserRole, PropertyStatus, TransactionType, TransactionStatus, DocumentType, VerificationStatus }; +export { + UserRole, + PropertyStatus, + TransactionType, + TransactionStatus, + DocumentType, + VerificationStatus, +}; diff --git a/src/content/content.controller.ts b/src/content/content.controller.ts index f79166fc..fbb45e2e 100644 --- a/src/content/content.controller.ts +++ b/src/content/content.controller.ts @@ -6,10 +6,7 @@ export class ContentController { constructor(private service: ContentService) {} @Post('pages/:slug') - updatePage( - @Param('slug') slug: string, - @Body() body: { title: string; content: string }, - ) { + updatePage(@Param('slug') slug: string, @Body() body: { title: string; content: string }) { return this.service.updatePage(slug, body); } @@ -39,10 +36,7 @@ export class ContentController { } @Post('legal/:type') - updateLegal( - @Param('type') type: string, - @Body() body: { content: string }, - ) { + updateLegal(@Param('type') type: string, @Body() body: { content: string }) { return this.service.updateLegal(type, body.content); } @@ -50,4 +44,4 @@ export class ContentController { getLegal(@Param('type') type: string) { return this.service.getLegal(type); } -} \ No newline at end of file +} diff --git a/src/content/content.module.ts b/src/content/content.module.ts index 7c808cb3..75d5f9ae 100644 --- a/src/content/content.module.ts +++ b/src/content/content.module.ts @@ -6,4 +6,4 @@ import { PrismaService } from 'src/database/prisma.service'; providers: [ContentService, PrismaService], controllers: [ContentController], }) -export class ContentModule {} \ No newline at end of file +export class ContentModule {} diff --git a/src/content/content.service.ts b/src/content/content.service.ts index 995a694a..624986f4 100644 --- a/src/content/content.service.ts +++ b/src/content/content.service.ts @@ -44,4 +44,4 @@ export class ContentService { getLegal(type: string) { return this.legal.get(type) || null; } -} \ No newline at end of file +} diff --git a/src/email/email-webhook.controller.ts b/src/email/email-webhook.controller.ts new file mode 100644 index 00000000..e43ee994 --- /dev/null +++ b/src/email/email-webhook.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { EmailService } from './email.service'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; + +@ApiTags('webhooks') +@Controller('webhooks/email') +export class EmailWebhookController { + constructor(private emailService: EmailService) {} + + @Post('bounce') + @HttpCode(200) + @ApiOperation({ summary: 'Handle email bounce webhooks' }) + async handleBounce(@Body() payload: any) { + // Basic extraction logic - in a real app, this would be provider-specific + const email = payload.email || payload.recipient; + const type = payload.type || (payload.bounceType === 'Hard' ? 'HARD' : 'SOFT'); + const reason = payload.reason || payload.diagnosticCode; + + if (email) { + await this.emailService.handleBounce(email, type as 'HARD' | 'SOFT', reason, payload); + } + + return { received: true }; + } +} diff --git a/src/email/email.module.ts b/src/email/email.module.ts index fff2146d..c19f1e0b 100644 --- a/src/email/email.module.ts +++ b/src/email/email.module.ts @@ -1,7 +1,12 @@ import { Module } from '@nestjs/common'; import { EmailService } from './email.service'; +import { EmailWebhookController } from './email-webhook.controller'; +import { PrismaModule } from '../database/prisma.module'; +import { TrackingModule } from '../tracking/tracking.module'; @Module({ + imports: [PrismaModule, TrackingModule], + controllers: [EmailWebhookController], providers: [EmailService], exports: [EmailService], }) diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 4a8f08ef..90acc67b 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -6,6 +6,8 @@ export interface EmailOptions { subject: string; html: string; text?: string; + userId?: string; + emailType?: string; } export interface FraudAlertEmailPayload { @@ -17,9 +19,17 @@ export interface FraudAlertEmailPayload { userEmail?: string | null; } +import { PrismaService } from '../database/prisma.service'; +import { TrackingService } from '../tracking/tracking.service'; +import { v4 as uuidv4 } from 'uuid'; + @Injectable() export class EmailService { - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + private readonly trackingService: TrackingService, + ) {} async sendPasswordResetEmail(email: string, resetToken: string): Promise { const resetUrl = `${this.configService.get('FRONTEND_URL', 'http://localhost:3000')}/reset-password?token=${resetToken}`; @@ -120,25 +130,82 @@ Summary: ${payload.description} ); } - private async sendEmail(options: EmailOptions): Promise { - // For now, we'll just log the email. In production, you would integrate with - // an email service like SendGrid, Mailgun, AWS SES, etc. + async handleBounce( + email: string, + type: 'HARD' | 'SOFT', + reason?: string, + rawEvent?: any, + ): Promise { + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) return; + + await this.prisma.emailBounce.create({ + data: { + userId: user.id, + email, + bounceType: type, + reason, + rawEvent, + }, + }); + + if (type === 'HARD') { + await this.prisma.user.update({ + where: { id: user.id }, + data: { emailStatus: 'INVALID' }, + }); + } else { + await this.prisma.user.update({ + where: { id: user.id }, + data: { emailStatus: 'BOUNCED' }, + }); + } + } + private async sendEmail(options: EmailOptions): Promise { + const baseUrl = this.configService.get('API_URL', 'http://localhost:3000/api'); + let html = options.html; + + // 1. Check if user is blocked or has invalid email + if (options.userId) { + const user = await this.prisma.user.findUnique({ where: { id: options.userId } }); + if (user && (user.isBlocked || user.emailStatus === 'INVALID')) { + console.log(`🚫 Skipping email to ${options.to} (User blocked or email invalid)`); + return; + } + } + + // 2. Click Tracking: Rewrite links + if (options.userId) { + const linkRegex = /]*?\s+)?href="([^"]*)"([^>]*)>/gi; + html = html.replace(linkRegex, (match, url, rest) => { + // Only track external links or specific ones if needed + if (url.startsWith('http')) { + const trackingUrl = `${baseUrl}/track/click?url=${encodeURIComponent(url)}&userId=${options.userId}`; + return ``; + } + return match; + }); + } + + // 3. Open Tracking: Inject pixel + if (options.userId && options.emailType) { + const trackingId = uuidv4(); + await this.trackingService.createEmailEngagement( + options.userId, + options.emailType, + trackingId, + ); + const pixelUrl = `${baseUrl}/track/open/${trackingId}.png`; + html += ``; + } + + // For now, we'll just log the email. console.log('📧 Sending email:'); console.log(`To: ${options.to}`); console.log(`Subject: ${options.subject}`); - console.log(`HTML: ${options.html.substring(0, 200)}...`); - console.log(`Text: ${options.text?.substring(0, 200)}...`); - - // TODO: Integrate with actual email service - // Example with nodemailer: - // const transporter = nodemailer.createTransporter({...}); - // await transporter.sendMail({ - // from: this.configService.get('EMAIL_FROM'), - // to: options.to, - // subject: options.subject, - // html: options.html, - // text: options.text, - // }); + console.log(`Tracking enabled: ${!!options.userId}`); + + // Actual implementation would go here } } diff --git a/src/notifications/notifications.gateway.ts b/src/notifications/notifications.gateway.ts new file mode 100644 index 00000000..02e654c7 --- /dev/null +++ b/src/notifications/notifications.gateway.ts @@ -0,0 +1,58 @@ +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Logger } from '@nestjs/common'; + +@WebSocketGateway({ + cors: { + origin: '*', + }, + namespace: 'notifications', +}) +export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private logger: Logger = new Logger('NotificationsGateway'); + private userSockets = new Map(); // userId -> socketIds + + handleConnection(client: Socket) { + const userId = client.handshake.query.userId as string; + if (userId) { + const sockets = this.userSockets.get(userId) || []; + sockets.push(client.id); + this.userSockets.set(userId, sockets); + this.logger.log(`User ${userId} connected (${client.id})`); + } + } + + handleDisconnect(client: Socket) { + const userId = client.handshake.query.userId as string; + if (userId) { + const sockets = this.userSockets.get(userId) || []; + const index = sockets.indexOf(client.id); + if (index > -1) { + sockets.splice(index, 1); + } + if (sockets.length === 0) { + this.userSockets.delete(userId); + } + this.logger.log(`User ${userId} disconnected (${client.id})`); + } + } + + sendToUser(userId: string, event: string, data: any): boolean { + const sockets = this.userSockets.get(userId); + if (sockets && sockets.length > 0) { + sockets.forEach((socketId) => { + this.server.to(socketId).emit(event, data); + }); + return true; + } + return false; + } +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 00000000..89266f73 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { NotificationsGateway } from './notifications.gateway'; +import { NotificationsService } from './notifications.service'; +import { PrismaModule } from '../database/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [NotificationsGateway, NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts new file mode 100644 index 00000000..b897852f --- /dev/null +++ b/src/notifications/notifications.service.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { NotificationsGateway } from './notifications.gateway'; + +@Injectable() +export class NotificationsService { + constructor( + private prisma: PrismaService, + private gateway: NotificationsGateway, + ) {} + + async sendNotification( + userId: string, + title: string, + message: string, + type: string, + metadata?: any, + ) { + // 1. Save to database + const notification = await this.prisma.notification.create({ + data: { + userId, + title, + message, + type, + metadata: metadata || {}, + }, + }); + + // 2. Try real-time delivery + const delivered = this.gateway.sendToUser(userId, 'notification', notification); + + if (delivered) { + await this.prisma.notification.update({ + where: { id: notification.id }, + data: { status: 'DELIVERED' }, + }); + } + + return notification; + } + + async getUserNotifications(userId: string) { + return this.prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } + + async markAsRead(id: string) { + return this.prisma.notification.update({ + where: { id }, + data: { status: 'READ', readAt: new Date() }, + }); + } + + async deliverPending(userId: string) { + const pending = await this.prisma.notification.findMany({ + where: { userId, status: 'PENDING' }, + }); + + for (const notification of pending) { + const delivered = this.gateway.sendToUser(userId, 'notification', notification); + if (delivered) { + await this.prisma.notification.update({ + where: { id: notification.id }, + data: { status: 'DELIVERED' }, + }); + } + } + } +} diff --git a/src/properties/properties.resolver.ts b/src/properties/properties.resolver.ts index a4062bc6..f5f9ad48 100644 --- a/src/properties/properties.resolver.ts +++ b/src/properties/properties.resolver.ts @@ -32,10 +32,7 @@ export class PropertiesResolver { @Mutation(() => Property) @UseGuards(GqlAuthGuard) - async createProperty( - @GqlUser() user: any, - @Args('input') input: CreatePropertyDto, - ) { + async createProperty(@GqlUser() user: any, @Args('input') input: CreatePropertyDto) { const property = await this.propertiesService.create(input, user.id); this.pubSub.publish('propertyAdded', { propertyAdded: property }); return property; @@ -43,10 +40,7 @@ export class PropertiesResolver { @Mutation(() => Property) @UseGuards(GqlAuthGuard) - async updateProperty( - @Args('id') id: string, - @Args('input') input: UpdatePropertyDto, - ) { + async updateProperty(@Args('id') id: string, @Args('input') input: UpdatePropertyDto) { return this.propertiesService.update(id, input); } diff --git a/src/search/search-analytics.service.ts b/src/search/search-analytics.service.ts index 8e241ad1..48d84569 100644 --- a/src/search/search-analytics.service.ts +++ b/src/search/search-analytics.service.ts @@ -41,25 +41,19 @@ export interface SearchInsights { @Injectable() export class SearchAnalyticsService { - constructor( - private readonly prisma: PrismaService, - ) {} + constructor(private readonly prisma: PrismaService) {} async recordSearch(userId: string, searchQuery: SearchQuery): Promise { const queryId = `search_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - + // Record search analytics // This would typically save to a search analytics table // For now, we'll simulate the recording - + return queryId; } - async recordSearchResults( - queryId: string, - resultsCount: number, - took: number, - ): Promise { + async recordSearchResults(queryId: string, resultsCount: number, took: number): Promise { // Update search record with results // This would typically update the search analytics record } @@ -106,30 +100,30 @@ export class SearchAnalyticsService { // This would typically query search analytics for searches with no results // For now, return mock data return [ - { - query: 'mansion under 100k', - count: 23, - suggestedAlternatives: ['luxury home', 'estate property', 'high-end house'] + { + query: 'mansion under 100k', + count: 23, + suggestedAlternatives: ['luxury home', 'estate property', 'high-end house'], }, - { - query: 'beachfront in desert', - count: 18, - suggestedAlternatives: ['beachfront property', 'desert home', 'coastal house'] + { + query: 'beachfront in desert', + count: 18, + suggestedAlternatives: ['beachfront property', 'desert home', 'coastal house'], }, - { - query: 'free house', - count: 15, - suggestedAlternatives: ['affordable home', 'low-cost property', 'budget house'] + { + query: 'free house', + count: 15, + suggestedAlternatives: ['affordable home', 'low-cost property', 'budget house'], }, - { - query: 'castle for rent', - count: 12, - suggestedAlternatives: ['luxury rental', 'historic home', 'estate rental'] + { + query: 'castle for rent', + count: 12, + suggestedAlternatives: ['luxury rental', 'historic home', 'estate rental'], }, - { - query: 'underwater house', - count: 8, - suggestedAlternatives: ['waterfront property', 'lake house', 'beach house'] + { + query: 'underwater house', + count: 8, + suggestedAlternatives: ['waterfront property', 'lake house', 'beach house'], }, ].slice(0, limit); } @@ -156,11 +150,11 @@ export class SearchAnalyticsService { // For now, return mock data const trends = []; const today = new Date(); - + for (let i = days - 1; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); - + trends.push({ date: date.toISOString().split('T')[0], searches: Math.floor(Math.random() * 100) + 50, @@ -168,7 +162,7 @@ export class SearchAnalyticsService { avgResults: Math.floor(Math.random() * 20) + 10, }); } - + return trends; } @@ -214,7 +208,10 @@ export class SearchAnalyticsService { }; } - async generateSearchReport(userId?: string, dateRange?: { start: Date; end: Date }): Promise { + async generateSearchReport( + userId?: string, + dateRange?: { start: Date; end: Date }, + ): Promise { const insights = await this.getAnalytics(userId); const performance = await this.getSearchPerformanceMetrics(userId); const topFilters = await this.getTopFilters(userId); @@ -222,7 +219,9 @@ export class SearchAnalyticsService { return { summary: { totalSearches: insights.popularSearches.reduce((sum, s) => sum + s.count, 0), - avgConversionRate: insights.conversionRates.reduce((sum, c) => sum + c.rate, 0) / insights.conversionRates.length, + avgConversionRate: + insights.conversionRates.reduce((sum, c) => sum + c.rate, 0) / + insights.conversionRates.length, zeroResultQueries: insights.noResultSearches.length, }, insights, @@ -238,7 +237,9 @@ export class SearchAnalyticsService { // Analyze popular searches const topSearch = insights.popularSearches[0]; if (topSearch && topSearch.trend === 'up') { - recommendations.push(`Focus on "${topSearch.query}" - it's trending upward with ${topSearch.count} searches`); + recommendations.push( + `Focus on "${topSearch.query}" - it's trending upward with ${topSearch.count} searches`, + ); } // Analyze zero-result searches @@ -247,7 +248,7 @@ export class SearchAnalyticsService { } // Analyze conversion rates - const lowConversionQueries = insights.conversionRates.filter(c => c.rate < 10); + const lowConversionQueries = insights.conversionRates.filter((c) => c.rate < 10); if (lowConversionQueries.length > 3) { recommendations.push('Review search result quality for queries with low conversion rates'); } diff --git a/src/search/search-autocomplete.service.ts b/src/search/search-autocomplete.service.ts index 397c5cc7..9793befc 100644 --- a/src/search/search-autocomplete.service.ts +++ b/src/search/search-autocomplete.service.ts @@ -10,9 +10,7 @@ export interface Suggestion { @Injectable() export class SearchAutocompleteService { - constructor( - private readonly prisma: PrismaService, - ) {} + constructor(private readonly prisma: PrismaService) {} async getSuggestions(query: string, limit: number = 10): Promise { if (!query || query.length < 2) { @@ -44,7 +42,7 @@ export class SearchAutocompleteService { // Sort by relevance and limit const sortedSuggestions = this.rankSuggestions(suggestions, query) .slice(0, limit) - .map(s => s.text); + .map((s) => s.text); return sortedSuggestions; } @@ -62,13 +60,22 @@ export class SearchAutocompleteService { } private async getFeatureSuggestions(query: string, limit: number): Promise { - const features = ['pool', 'garage', 'garden', 'balcony', 'fireplace', 'basement', 'patio', 'deck']; - - const matchingFeatures = features.filter(feature => - feature.toLowerCase().includes(query.toLowerCase()) - ).slice(0, limit); - - return matchingFeatures.map(feature => ({ + const features = [ + 'pool', + 'garage', + 'garden', + 'balcony', + 'fireplace', + 'basement', + 'patio', + 'deck', + ]; + + const matchingFeatures = features + .filter((feature) => feature.toLowerCase().includes(query.toLowerCase())) + .slice(0, limit); + + return matchingFeatures.map((feature) => ({ text: feature, type: 'feature', })); @@ -128,14 +135,14 @@ export class SearchAutocompleteService { } const suggestions = await this.getSuggestions(query); - + if (suggestions.length > 0) { return suggestions; } // Try common typo corrections const corrections = this.getCommonTypoCorrections(query); - + for (const correction of corrections) { const correctedSuggestions = await this.getSuggestions(correction); if (correctedSuggestions.length > 0) { @@ -148,17 +155,17 @@ export class SearchAutocompleteService { private getCommonTypoCorrections(query: string): string[] { const corrections: string[] = []; - + // Common property-related typos const typoMap: Record = { - 'apartment': ['apartmant', 'apartmet', 'apartmen'], - 'house': ['hous', 'hose'], - 'condo': ['condo', 'condo'], - 'garage': ['garage', 'garage'], - 'bedroom': ['bedrom', 'bedrum', 'bedroom'], - 'bathroom': ['bathrom', 'bathrum', 'bathroom'], - 'pool': ['pol', 'pool'], - 'garden': ['garden', 'garden'], + apartment: ['apartmant', 'apartmet', 'apartmen'], + house: ['hous', 'hose'], + condo: ['condo', 'condo'], + garage: ['garage', 'garage'], + bedroom: ['bedrom', 'bedrum', 'bedroom'], + bathroom: ['bathrom', 'bathrum', 'bathroom'], + pool: ['pol', 'pool'], + garden: ['garden', 'garden'], }; for (const [correct, typos] of Object.entries(typoMap)) { @@ -173,7 +180,7 @@ export class SearchAutocompleteService { // Swap adjacent characters const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2); corrections.push(swapped); - + // Delete character const deleted = query.slice(0, i) + query.slice(i + 1); corrections.push(deleted); diff --git a/src/search/search-facets.service.ts b/src/search/search-facets.service.ts index 0d7e5bb9..deccd2bf 100644 --- a/src/search/search-facets.service.ts +++ b/src/search/search-facets.service.ts @@ -35,10 +35,7 @@ export class SearchFacetsService { }); } - applyFacetFilter( - items: SearchableItem[], - filters: Record, - ): SearchableItem[] { + applyFacetFilter(items: SearchableItem[], filters: Record): SearchableItem[] { return items.filter((item) => Object.entries(filters).every(([field, value]) => String(item[field] ?? '') === value), ); diff --git a/src/search/search-filters.service.ts b/src/search/search-filters.service.ts index 09832a97..140bfb9e 100644 --- a/src/search/search-filters.service.ts +++ b/src/search/search-filters.service.ts @@ -28,14 +28,9 @@ export interface FilterCombination { @Injectable() export class SearchFiltersService { - constructor( - private readonly prisma: PrismaService, - ) {} + constructor(private readonly prisma: PrismaService) {} - async applyFilters( - whereClause: any, - filters: Record, - ): Promise { + async applyFilters(whereClause: any, filters: Record): Promise { const filterKeys = Object.keys(filters); for (const key of filterKeys) { @@ -334,19 +329,12 @@ export class SearchFiltersService { // For now, do nothing } - async applyFilterCombination( - whereClause: any, - combination: FilterCombination, - ): Promise { + async applyFilterCombination(whereClause: any, combination: FilterCombination): Promise { if (combination.operator === 'AND') { - const conditions = combination.filters.map(filter => - this.applyFilters({}, filter) - ); + const conditions = combination.filters.map((filter) => this.applyFilters({}, filter)); whereClause.AND = [...(whereClause.AND || []), ...conditions]; } else if (combination.operator === 'OR') { - const conditions = combination.filters.map(filter => - this.applyFilters({}, filter) - ); + const conditions = combination.filters.map((filter) => this.applyFilters({}, filter)); whereClause.OR = [...(whereClause.OR || []), ...conditions]; } diff --git a/src/search/search-geographic.service.ts b/src/search/search-geographic.service.ts index 8c2a9482..e04c9854 100644 --- a/src/search/search-geographic.service.ts +++ b/src/search/search-geographic.service.ts @@ -13,10 +13,7 @@ export interface Point { @Injectable() export class SearchGeographicService { - async applyGeographicFilter( - whereClause: any, - geographic: GeographicFilter, - ): Promise { + async applyGeographicFilter(whereClause: any, geographic: GeographicFilter): Promise { if (geographic.type === 'radius') { return this.applyRadiusFilter(whereClause, geographic); } else if (geographic.type === 'polygon') { @@ -25,10 +22,7 @@ export class SearchGeographicService { return whereClause; } - private async applyRadiusFilter( - whereClause: any, - geographic: GeographicFilter, - ): Promise { + private async applyRadiusFilter(whereClause: any, geographic: GeographicFilter): Promise { const center = geographic.coordinates[0]; const radius = geographic.radius || 10; // default 10 miles @@ -53,13 +47,10 @@ export class SearchGeographicService { return whereClause; } - private async applyPolygonFilter( - whereClause: any, - geographic: GeographicFilter, - ): Promise { + private async applyPolygonFilter(whereClause: any, geographic: GeographicFilter): Promise { // Get bounding box of polygon for initial filtering const bounds = this.getBoundingBox(geographic.coordinates); - + whereClause.AND = [ ...(whereClause.AND || []), { @@ -82,7 +73,7 @@ export class SearchGeographicService { centerPoint: Point, maxDistance?: number, ): Promise { - const propertiesWithDistance = properties.map(property => { + const propertiesWithDistance = properties.map((property) => { if (!property.latitude || !property.longitude) { return { ...property, distance: Infinity }; } @@ -99,19 +90,14 @@ export class SearchGeographicService { // Filter by max distance if specified const filtered = maxDistance - ? propertiesWithDistance.filter(p => p.distance <= maxDistance) + ? propertiesWithDistance.filter((p) => p.distance <= maxDistance) : propertiesWithDistance; // Sort by distance return filtered.sort((a, b) => a.distance - b.distance); } - private calculateDistance( - lat1: number, - lng1: number, - lat2: number, - lng2: number, - ): number { + private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { const R = 3959; // Earth's radius in miles const dLat = this.toRadians(lat2 - lat1); const dLng = this.toRadians(lng2 - lng1); @@ -135,8 +121,8 @@ export class SearchGeographicService { minLng: number; maxLng: number; } { - const lats = coordinates.map(coord => coord[0]); - const lngs = coordinates.map(coord => coord[1]); + const lats = coordinates.map((coord) => coord[0]); + const lngs = coordinates.map((coord) => coord[1]); return { minLat: Math.min(...lats), @@ -157,9 +143,7 @@ export class SearchGeographicService { const xj = polygon[j][1]; const yj = polygon[j][0]; - const intersect = - yi > y !== yj > y && - x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } @@ -183,14 +167,12 @@ export class SearchGeographicService { } | null { if (properties.length === 0) return null; - const validProperties = properties.filter( - p => p.latitude && p.longitude, - ); + const validProperties = properties.filter((p) => p.latitude && p.longitude); if (validProperties.length === 0) return null; - const lats = validProperties.map(p => p.latitude); - const lngs = validProperties.map(p => p.longitude); + const lats = validProperties.map((p) => p.latitude); + const lngs = validProperties.map((p) => p.longitude); return { northeast: { diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index 0ef361c1..993ebdb6 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -33,7 +33,7 @@ export class SearchController { } @Get('filters/saved') - @ApiOperation({ summary: 'Get user\'s saved filters' }) + @ApiOperation({ summary: "Get user's saved filters" }) @ApiResponse({ status: 200, description: 'Saved filters returned successfully' }) async getSavedFilters(@Request() req: AuthenticatedRequest) { return this.searchService.getSavedFilters(req.user.id); diff --git a/src/search/search.module.ts b/src/search/search.module.ts index 86a88478..604c610a 100644 --- a/src/search/search.module.ts +++ b/src/search/search.module.ts @@ -11,13 +11,8 @@ import { PrismaModule } from '../database/prisma.module'; import { CacheModuleConfig } from '../cache/cache.module'; @Module({ - imports: [ - PrismaModule, - CacheModuleConfig, - ], - controllers: [ - SearchController, - ], + imports: [PrismaModule, CacheModuleConfig], + controllers: [SearchController], providers: [ SearchService, SearchAnalyticsService, diff --git a/src/search/search.service.ts b/src/search/search.service.ts index cef7c4eb..40133ed3 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -48,10 +48,7 @@ export class SearchService { private readonly facetsService: SearchFacetsService, ) {} - async searchProperties( - userId: string, - searchQuery: SearchQuery, - ): Promise> { + async searchProperties(userId: string, searchQuery: SearchQuery): Promise> { const startTime = Date.now(); const queryId = await this.analyticsService.recordSearch(userId, searchQuery); @@ -80,10 +77,7 @@ export class SearchService { // Apply advanced filters if (searchQuery.filters) { - whereClause = await this.filtersService.applyFilters( - whereClause, - searchQuery.filters, - ); + whereClause = await this.filtersService.applyFilters(whereClause, searchQuery.filters); } // Execute query with sorting and pagination @@ -105,9 +99,7 @@ export class SearchService { ]); // Get suggestions - const suggestions = await this.autocompleteService.getSuggestions( - searchQuery.query || '', - ); + const suggestions = await this.autocompleteService.getSuggestions(searchQuery.query || ''); // Record search history if (searchQuery.query) { @@ -151,5 +143,4 @@ export class SearchService { async getPopularSearches(): Promise { return this.analyticsService.getPopularSearches(); } - } diff --git a/src/search/voice-search.service.ts b/src/search/voice-search.service.ts index 30d05357..4a0ad05a 100644 --- a/src/search/voice-search.service.ts +++ b/src/search/voice-search.service.ts @@ -11,7 +11,10 @@ export class VoiceSearchService { private readonly FILLER_WORDS = new Set(['the', 'a', 'an', 'in', 'on', 'at', 'for', 'with']); process(rawTranscript: string): VoiceSearchResult { - const normalised = rawTranscript.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim(); + const normalised = rawTranscript + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .trim(); const tokens = normalised .split(/\s+/) .filter((word) => word.length > 0 && !this.FILLER_WORDS.has(word)); diff --git a/src/tracking/tracking.controller.ts b/src/tracking/tracking.controller.ts new file mode 100644 index 00000000..5f91b62f --- /dev/null +++ b/src/tracking/tracking.controller.ts @@ -0,0 +1,71 @@ +import { Controller, Get, Query, Res, Req, Param } from '@nestjs/common'; +import { Response, Request } from 'express'; +import { TrackingService } from './tracking.service'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; + +@ApiTags('tracking') +@Controller('track') +export class TrackingController { + constructor(private trackingService: TrackingService) {} + + @Get('click') + @ApiOperation({ summary: 'Track link click and redirect' }) + async trackClick( + @Query('url') url: string, + @Query('userId') userId: string, + @Req() req: Request, + @Res() res: Response, + ) { + if (!url) { + return res.status(400).send('URL is required'); + } + + const ipAddress = req.ip; + const userAgent = req.headers['user-agent']; + + await this.trackingService.trackClick(url, userId, ipAddress, userAgent); + + return res.redirect(url); + } + + @Get('open/:trackingId.png') + @ApiOperation({ summary: 'Track email open via pixel' }) + async trackOpen( + @Param('trackingId') trackingId: string, + @Req() req: Request, + @Res() res: Response, + ) { + const ipAddress = req.ip; + const userAgent = req.headers['user-agent']; + + await this.trackingService.trackEmailOpen(trackingId, ipAddress, userAgent); + + // 1x1 transparent PNG pixel + const pixel = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + 'base64', + ); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': pixel.length, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }); + + return res.send(pixel); + } + + @Get('stats') + @ApiOperation({ summary: 'Get tracking statistics' }) + async getStats() { + const clickStats = await this.trackingService.getClickStats(); + const emailStats = await this.trackingService.getEmailStats(); + + return { + clicks: clickStats, + emails: emailStats, + }; + } +} diff --git a/src/tracking/tracking.module.ts b/src/tracking/tracking.module.ts new file mode 100644 index 00000000..b27776c4 --- /dev/null +++ b/src/tracking/tracking.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TrackingController } from './tracking.controller'; +import { TrackingService } from './tracking.service'; +import { PrismaModule } from '../database/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [TrackingController], + providers: [TrackingService], + exports: [TrackingService], +}) +export class TrackingModule {} diff --git a/src/tracking/tracking.service.ts b/src/tracking/tracking.service.ts new file mode 100644 index 00000000..a91c62a0 --- /dev/null +++ b/src/tracking/tracking.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; + +@Injectable() +export class TrackingService { + constructor(private prisma: PrismaService) {} + + async trackClick(url: string, userId?: string, ipAddress?: string, userAgent?: string) { + return this.prisma.linkClick.create({ + data: { + url, + userId, + ipAddress, + userAgent, + }, + }); + } + + async trackEmailOpen(trackingId: string, ipAddress?: string, userAgent?: string) { + return this.prisma.emailEngagement + .update({ + where: { trackingId }, + data: { + openedAt: new Date(), + ipAddress, + userAgent, + }, + }) + .catch(() => { + // Ignore if trackingId not found + }); + } + + async createEmailEngagement(userId: string, emailType: string, trackingId: string) { + return this.prisma.emailEngagement.create({ + data: { + userId, + emailType, + trackingId, + }, + }); + } + + async getClickStats() { + const stats = await this.prisma.linkClick.groupBy({ + by: ['url'], + _count: { + _all: true, + }, + orderBy: { + _count: { + url: 'desc', + }, + }, + take: 10, + }); + + return stats.map((s) => ({ + url: s.url, + clicks: s._count._all, + })); + } + + async getEmailStats() { + const totalSent = await this.prisma.emailEngagement.count(); + const totalOpened = await this.prisma.emailEngagement.count({ + where: { openedAt: { not: null } }, + }); + + return { + totalSent, + totalOpened, + openRate: totalSent > 0 ? (totalOpened / totalSent) * 100 : 0, + }; + } +} diff --git a/src/users/users.resolver.ts b/src/users/users.resolver.ts index 3a21d2e4..587f9f32 100644 --- a/src/users/users.resolver.ts +++ b/src/users/users.resolver.ts @@ -30,10 +30,7 @@ export class UsersResolver { @Mutation(() => User) @UseGuards(GqlAuthGuard) - async updateProfile( - @GqlUser() user: any, - @Args('input') input: UpdateUserDto, - ) { + async updateProfile(@GqlUser() user: any, @Args('input') input: UpdateUserDto) { // Note: UpdateUserDto might need @InputType() decoration if not already. // NestJS GraphQL can automatically handle it if mapped correctly. return this.usersService.update(user.id, input); From fb2a1578cf6310707b6a811028840a31f0ef5f1f Mon Sep 17 00:00:00 2001 From: Godsmiracle001 Date: Sat, 25 Apr 2026 09:10:40 +0100 Subject: [PATCH 2/2] ci fixed --- package-lock.json | 31 +++++++++---------------------- package.json | 4 ++-- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2e52761..5a95ac0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@nestjs/swagger": "^7.1.16", "@nestjs/websockets": "^10.4.22", "@prisma/client": "^6.19.2", - "@types/uuid": "^10.0.0", + "@types/uuid": "^9.0.7", "archiver": "^7.0.1", "bcrypt": "^6.0.0", "cache-manager": "^7.2.8", @@ -45,7 +45,7 @@ "sharp": "^0.34.5", "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.1", - "uuid": "^14.0.0" + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.3.0", @@ -495,19 +495,6 @@ "node": ">= 0.8.0" } }, - "node_modules/@apollo/server/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@apollo/usage-reporting-protobuf": { "version": "4.1.1", "license": "MIT", @@ -3689,9 +3676,9 @@ "license": "MIT" }, "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", "license": "MIT" }, "node_modules/@types/validator": { @@ -11496,16 +11483,16 @@ } }, "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist-node/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index ee9c4f3c..52c93e6f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@nestjs/swagger": "^7.1.16", "@nestjs/websockets": "^10.4.22", "@prisma/client": "^6.19.2", - "@types/uuid": "^10.0.0", + "@types/uuid": "^9.0.7", "archiver": "^7.0.1", "bcrypt": "^6.0.0", "cache-manager": "^7.2.8", @@ -62,7 +62,7 @@ "sharp": "^0.34.5", "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.1", - "uuid": "^14.0.0" + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.3.0",