diff --git a/package-lock.json b/package-lock.json index 0be5bfb..1003203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.0", "@nestjs/mapped-types": "^2.0.5", "@nestjs/passport": "^11.0.5", @@ -37,6 +38,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", + "firebase-admin": "^13.6.0", "ioredis": "^5.8.2", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2", @@ -3021,6 +3023,246 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.3.tgz", + "integrity": "sha512-gOnCAbFgAYKRozywLsxagdevTF7Gm+2Ncz5u5CQAuOv/2VCa0rdGJWvJFDOftPx1tc+q8TXiC2pEJfFKu+yeMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^4.4.1", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@google-cloud/storage/node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@google/generative-ai": { "version": "0.24.1", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", @@ -3030,6 +3272,58 @@ "node": ">=18.0.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.1.tgz", + "integrity": "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -4116,6 +4410,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -5007,6 +5312,19 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", + "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==", + "license": "MIT", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/jwt": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.1.tgz", @@ -5375,6 +5693,16 @@ "license": "MIT", "optional": true }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -5502,6 +5830,80 @@ "@prisma/debug": "6.18.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -6690,6 +7092,16 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -6777,18 +7189,23 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", "@types/node": "*" } }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -6902,7 +7319,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -6970,6 +7386,13 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -6981,7 +7404,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/mjml": { @@ -7138,21 +7560,72 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -7162,7 +7635,6 @@ "version": "1.15.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -7174,7 +7646,6 @@ "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -7212,6 +7683,13 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, "node_modules/@types/validator": { "version": "13.15.3", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", @@ -7963,6 +8441,19 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -8292,6 +8783,16 @@ "dev": true, "license": "MIT" }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -8313,6 +8814,16 @@ "license": "MIT", "optional": true }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -8509,7 +9020,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -8554,6 +9064,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bin-version": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz", @@ -10081,6 +10600,19 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -10239,6 +10771,16 @@ "node": ">=8.10.0" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", @@ -10720,6 +11262,22 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -10893,6 +11451,12 @@ "node": ">=4" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extend-object": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/extend-object/-/extend-object-1.0.0.tgz", @@ -10900,6 +11464,15 @@ "license": "MIT", "optional": true }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -10927,7 +11500,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": { @@ -11045,6 +11617,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -11217,6 +11801,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase-admin": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.0.tgz", + "integrity": "sha512-GdPA/t0+Cq8p1JnjFRBmxRxAGvF/kl2yfdhALl38PrRp325YxyQ5aNaHui0XmaKcKiGRFIJ/EgBNWFoDP0onjw==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^9.14.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" + } + }, + "node_modules/firebase-admin/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/fixpack": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fixpack/-/fixpack-4.0.0.tgz", @@ -11479,6 +12102,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -11669,6 +12329,77 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -11721,6 +12452,40 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -11812,6 +12577,23 @@ "he": "bin/he" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -11917,6 +12699,12 @@ "node": ">= 0.8" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -12339,7 +13127,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13195,6 +13982,15 @@ "node": ">= 20" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -13329,6 +14125,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -13464,6 +14269,47 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -13583,6 +14429,11 @@ "license": "MIT", "optional": true }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -13686,6 +14537,19 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -13771,6 +14635,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/lower-case": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", @@ -13801,6 +14672,34 @@ "yallist": "^3.0.2" } }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -14825,7 +15724,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -14848,6 +15746,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-forge": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", + "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -15211,7 +16118,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -15882,6 +16789,44 @@ "license": "ISC", "optional": true }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -16502,6 +17447,31 @@ "dev": true, "license": "ISC" }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -17279,6 +18249,23 @@ "node": ">= 0.8" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -17535,6 +18522,13 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, "node_modules/superagent": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", @@ -17684,6 +18678,65 @@ "streamx": "^2.15.0" } }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/terser": { "version": "5.44.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", @@ -17980,8 +19033,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/tree-kill": { "version": "1.2.2", @@ -18446,7 +19498,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -18684,8 +19735,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true + "license": "BSD-2-Clause" }, "node_modules/webpack": { "version": "5.102.1", @@ -18888,12 +19938,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", - "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -19164,7 +20236,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 83887a2..c322cf2 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.0", "@nestjs/mapped-types": "^2.0.5", "@nestjs/passport": "^11.0.5", @@ -52,6 +53,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", + "firebase-admin": "^13.6.0", "ioredis": "^5.8.2", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2", diff --git a/prisma/migrations/20251129095434_add_notifications_and_device_tokens/migration.sql b/prisma/migrations/20251129095434_add_notifications_and_device_tokens/migration.sql new file mode 100644 index 0000000..a405516 --- /dev/null +++ b/prisma/migrations/20251129095434_add_notifications_and_device_tokens/migration.sql @@ -0,0 +1,66 @@ + +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('LIKE', 'REPOST', 'QUOTE', 'REPLY', 'MENTION', 'FOLLOW', 'DM'); + +-- CreateEnum +CREATE TYPE "Platform" AS ENUM ('WEB', 'IOS', 'ANDROID'); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "recipient_id" INTEGER NOT NULL, + "actor_id" INTEGER NOT NULL, + "actor_username" VARCHAR(50) NOT NULL, + "actor_avatar_url" VARCHAR(255), + "post_id" INTEGER, + "quote_post_id" INTEGER, + "reply_id" INTEGER, + "thread_post_id" INTEGER, + "conversation_id" INTEGER, + "message_preview" VARCHAR(200), + "post_preview_text" VARCHAR(200), + "is_read" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "device_tokens" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "token" VARCHAR(255) NOT NULL, + "platform" "Platform" NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "device_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notifications_recipient_id_created_at_idx" ON "notifications"("recipient_id", "created_at" DESC); + +-- CreateIndex +CREATE INDEX "notifications_recipient_id_is_read_idx" ON "notifications"("recipient_id", "is_read"); + +-- CreateIndex +CREATE UNIQUE INDEX "device_tokens_token_key" ON "device_tokens"("token"); + +-- CreateIndex +CREATE INDEX "device_tokens_user_id_idx" ON "device_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_recipient_id_fkey" FOREIGN KEY ("recipient_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "device_tokens" ADD CONSTRAINT "device_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251129142135_add_actor_display_name_to_notifications/migration.sql b/prisma/migrations/20251129142135_add_actor_display_name_to_notifications/migration.sql new file mode 100644 index 0000000..c9787d6 --- /dev/null +++ b/prisma/migrations/20251129142135_add_actor_display_name_to_notifications/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "notifications" ADD COLUMN "actor_display_name" VARCHAR(100); diff --git a/prisma/migrations/20251129150005_add_notification_deduplication_indexes/migration.sql b/prisma/migrations/20251129150005_add_notification_deduplication_indexes/migration.sql new file mode 100644 index 0000000..69b71f1 --- /dev/null +++ b/prisma/migrations/20251129150005_add_notification_deduplication_indexes/migration.sql @@ -0,0 +1,25 @@ +-- Add unique indexes to prevent duplicate notifications + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_like_unique +ON notifications(recipient_id, actor_id, post_id, type) +WHERE type = 'LIKE' AND post_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_repost_unique +ON notifications(recipient_id, actor_id, post_id, type) +WHERE type = 'REPOST' AND post_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_follow_unique +ON notifications(recipient_id, actor_id, type) +WHERE type = 'FOLLOW'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_mention_unique +ON notifications(recipient_id, actor_id, post_id, type) +WHERE type = 'MENTION' AND post_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_quote_unique +ON notifications(recipient_id, actor_id, quote_post_id, type) +WHERE type = 'QUOTE' AND quote_post_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_reply_unique +ON notifications(recipient_id, actor_id, reply_id, type) +WHERE type = 'REPLY' AND reply_id IS NOT NULL; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b841536..aa0a042 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,9 @@ model User { ConversationsAsUser2 Conversation[] @relation("User2Conversations") MessagesSent Message[] interests UserInterest[] + ReceivedNotifications Notification[] @relation("ReceivedNotifications") + SentNotifications Notification[] @relation("SentNotifications") + DeviceTokens DeviceToken[] } model Profile { @@ -91,7 +94,6 @@ model Interest { @@map("interests") } - model UserInterest { user_id Int interest_id Int @@ -113,7 +115,7 @@ model Post { interest_id Int? created_at DateTime @default(now()) is_deleted Boolean @default(false) - summary String? + summary String? likes Like[] media Media[] mentions Mention[] @@ -123,6 +125,7 @@ model Post { User User @relation(fields: [user_id], references: [id]) Interest Interest? @relation(fields: [interest_id], references: [id]) hashtags Hashtag[] @relation("PostHashtags") + Notifications Notification[] @@map("posts") } @@ -184,11 +187,26 @@ model Repost { } model Hashtag { - id Int @id @default(autoincrement()) - tag String @unique - created_at DateTime @default(now()) - posts Post[] @relation("PostHashtags") - hashtagTrends HashtagTrend[] + id Int @id @default(autoincrement()) + tag String @unique + created_at DateTime @default(now()) + posts Post[] @relation("PostHashtags") + trends HashtagTrend[] +} + +model HashtagTrend { + id Int @id @default(autoincrement()) + hashtag_id Int + post_count_1h Int + post_count_24h Int + post_count_7d Int + trending_score Float + calculated_at DateTime @default(now()) + hashtag Hashtag @relation(fields: [hashtag_id], references: [id], onDelete: Cascade) + + @@index([trending_score]) + @@index([hashtag_id]) + @@map("hashtag_trends") } model Like { @@ -221,6 +239,7 @@ model Conversation { User1 User @relation("User1Conversations", fields: [user1Id], references: [id], onDelete: Cascade) User2 User @relation("User2Conversations", fields: [user2Id], references: [id], onDelete: Cascade) Messages Message[] + Notifications Notification[] @@unique([user1Id, user2Id]) @@map("conversations") @@ -259,16 +278,70 @@ enum MediaType { IMAGE } -model HashtagTrend { - id Int @id @default(autoincrement()) - hashtag_id Int - post_count_1h Int - post_count_24h Int - post_count_7d Int - trending_score Float - calculated_at DateTime @default(now()) - hashtag Hashtag @relation(fields: [hashtag_id], references: [id], onDelete: Cascade) +model Notification { + id String @id @default(cuid()) + type NotificationType + recipientId Int @map("recipient_id") + actorId Int @map("actor_id") - @@index([trending_score]) - @@index([hashtag_id]) + // Actor snapshot to avoid N+1 queries + actorUsername String @map("actor_username") @db.VarChar(50) + actorDisplayName String? @map("actor_display_name") @db.VarChar(100) + actorAvatarUrl String? @map("actor_avatar_url") @db.VarChar(255) + + // Post-related (for LIKE, REPOST, QUOTE, REPLY, MENTION) + postId Int? @map("post_id") + quotePostId Int? @map("quote_post_id") // for QUOTE type + replyId Int? @map("reply_id") // for REPLY/MENTION in replies + threadPostId Int? @map("thread_post_id") // root post of thread + + // DM-related + conversationId Int? @map("conversation_id") + messagePreview String? @map("message_preview") @db.VarChar(200) + + postPreviewText String? @map("post_preview_text") @db.VarChar(200) + + isRead Boolean @default(false) @map("is_read") + createdAt DateTime @default(now()) @map("created_at") + + recipient User @relation("ReceivedNotifications", fields: [recipientId], references: [id], onDelete: Cascade) + actor User @relation("SentNotifications", fields: [actorId], references: [id], onDelete: Cascade) + + // Optional relations + post Post? @relation(fields: [postId], references: [id], onDelete: Cascade) + conversation Conversation? @relation(fields: [conversationId], references: [id], onDelete: Cascade) + + @@index([recipientId, createdAt(sort: Desc)]) + @@index([recipientId, isRead]) + @@map("notifications") +} + +enum NotificationType { + LIKE + REPOST + QUOTE + REPLY + MENTION + FOLLOW + DM +} + +model DeviceToken { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + token String @unique @db.VarChar(255) + platform Platform + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("device_tokens") +} + +enum Platform { + WEB + IOS + ANDROID } diff --git a/src/app.module.ts b/src/app.module.ts index 19c3156..e2f8aa4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,12 +22,22 @@ import { AiIntegrationModule } from './ai-integration/ai-integration.module'; import envSchema from './config/validate-config'; import { BullModule } from '@nestjs/bullmq'; import redisConfig from './config/redis.config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { FirebaseModule } from './firebase/firebase.module'; +import { NotificationsModule } from './notifications/notifications.module'; const envFilePath = '.env'; @Module({ imports: [ - ConfigModule.forRoot({ envFilePath, isGlobal: true, validationSchema: envSchema, load: [redisConfig] }), + ConfigModule.forRoot({ + envFilePath, + isGlobal: true, + validationSchema: envSchema, + load: [redisConfig], + }), + EventEmitterModule.forRoot(), + FirebaseModule, AuthModule, UserModule, UsersModule, @@ -73,6 +83,7 @@ const envFilePath = '.env'; ConversationsModule, PrismaModule, AiIntegrationModule, + NotificationsModule, ], controllers: [], providers: [ @@ -82,4 +93,4 @@ const envFilePath = '.env'; }, ], }) -export class AppModule { } +export class AppModule {} diff --git a/src/firebase/firebase.config.ts b/src/firebase/firebase.config.ts new file mode 100644 index 0000000..7e492e6 --- /dev/null +++ b/src/firebase/firebase.config.ts @@ -0,0 +1,7 @@ +import { ConfigService } from '@nestjs/config'; + +export const getFirebaseConfig = (configService: ConfigService) => ({ + projectId: configService.get('FIREBASE_PROJECT_ID'), + privateKey: configService.get('FIREBASE_PRIVATE_KEY')?.replace(/\\n/g, '\n'), + clientEmail: configService.get('FIREBASE_CLIENT_EMAIL'), +}); diff --git a/src/firebase/firebase.module.ts b/src/firebase/firebase.module.ts new file mode 100644 index 0000000..66e9036 --- /dev/null +++ b/src/firebase/firebase.module.ts @@ -0,0 +1,16 @@ +import { Module, Global } from '@nestjs/common'; +import { FirebaseService } from './firebase.service'; +import { Services } from 'src/utils/constants'; + +@Global() +@Module({ + providers: [ + FirebaseService, + { + provide: Services.FIREBASE, + useClass: FirebaseService, + }, + ], + exports: [FirebaseService, Services.FIREBASE], +}) +export class FirebaseModule {} diff --git a/src/firebase/firebase.service.ts b/src/firebase/firebase.service.ts new file mode 100644 index 0000000..46e6afc --- /dev/null +++ b/src/firebase/firebase.service.ts @@ -0,0 +1,58 @@ +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as admin from 'firebase-admin'; +import { getFirebaseConfig } from './firebase.config'; + +@Injectable() +export class FirebaseService implements OnModuleInit { + private readonly logger = new Logger(FirebaseService.name); + private firebaseApp: admin.app.App; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit() { + const firebaseConfig = getFirebaseConfig(this.configService); + + if (!firebaseConfig.projectId || !firebaseConfig.privateKey || !firebaseConfig.clientEmail) { + this.logger.error( + 'Firebase configuration is incomplete. Please check environment variables.', + ); + throw new Error('Firebase configuration is incomplete'); + } + + try { + // Check if Firebase app already exists + if (admin.apps.length === 0) { + this.firebaseApp = admin.initializeApp({ + credential: admin.credential.cert({ + projectId: firebaseConfig.projectId, + privateKey: firebaseConfig.privateKey, + clientEmail: firebaseConfig.clientEmail, + }), + databaseURL: this.configService.get('FIREBASE_DATABASE_URL'), + }); + + this.logger.log('Firebase Admin SDK initialized successfully'); + } else { + // Use existing Firebase app + this.firebaseApp = admin.app(); + this.logger.log('Using existing Firebase Admin SDK instance'); + } + } catch (error) { + this.logger.error('Failed to initialize Firebase Admin SDK', error); + throw error; + } + } + + getFirestore(): admin.firestore.Firestore { + return admin.firestore(this.firebaseApp); + } + + getMessaging(): admin.messaging.Messaging { + return admin.messaging(this.firebaseApp); + } + + getAuth(): admin.auth.Auth { + return admin.auth(this.firebaseApp); + } +} diff --git a/src/messages/messages.gateway.ts b/src/messages/messages.gateway.ts index 44bff8b..baef92d 100644 --- a/src/messages/messages.gateway.ts +++ b/src/messages/messages.gateway.ts @@ -20,6 +20,8 @@ import { CreateMessageDto } from './dto/create-message.dto'; import { UpdateMessageDto } from './dto/update-message.dto'; import { MarkSeenDto } from './dto/mark-seen.dto'; import { WebSocketExceptionFilter } from './exceptions/ws-exception.filter'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from 'src/notifications/enums/notification.enum'; @WebSocketGateway(8000, { cors: { @@ -33,6 +35,7 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect private readonly messagesService: MessagesService, @Inject(redisConfig.KEY) private readonly redisConfiguration: ConfigType, + private readonly eventEmitter: EventEmitter2, ) {} @WebSocketServer() @@ -201,6 +204,15 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect if (!isRecipientInConversation) { this.server.to(`user_${recipientId}`).emit('newMessageNotification', message); + + // Emit DM notification event + this.eventEmitter.emit('notification.create', { + type: NotificationType.DM, + recipientId, + actorId: userId, + conversationId: createMessageDto.conversationId, + messageText: createMessageDto.text, + }); } return { diff --git a/src/notifications/dto/get-notifications.dto.ts b/src/notifications/dto/get-notifications.dto.ts new file mode 100644 index 0000000..f28fccc --- /dev/null +++ b/src/notifications/dto/get-notifications.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetNotificationsDto { + @ApiProperty({ + description: 'Page number', + example: 1, + required: false, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiProperty({ + description: 'Number of items per page', + example: 20, + required: false, + default: 20, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; + + @ApiProperty({ + description: 'Filter by read status', + example: false, + required: false, + }) + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + unreadOnly?: boolean; +} diff --git a/src/notifications/dto/register-device.dto.ts b/src/notifications/dto/register-device.dto.ts new file mode 100644 index 0000000..aaee8ce --- /dev/null +++ b/src/notifications/dto/register-device.dto.ts @@ -0,0 +1,22 @@ +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Platform } from '../enums/notification.enum'; + +export class RegisterDeviceDto { + @ApiProperty({ + description: 'FCM device token', + example: 'fcm_token_example_123456789', + }) + @IsString() + @IsNotEmpty() + token: string; + + @ApiProperty({ + description: 'Platform type', + enum: Platform, + example: Platform.WEB, + }) + @IsEnum(Platform) + @IsNotEmpty() + platform: Platform; +} diff --git a/src/notifications/enums/notification.enum.ts b/src/notifications/enums/notification.enum.ts new file mode 100644 index 0000000..c8b78a5 --- /dev/null +++ b/src/notifications/enums/notification.enum.ts @@ -0,0 +1,15 @@ +export enum NotificationType { + LIKE = 'LIKE', + REPOST = 'REPOST', + QUOTE = 'QUOTE', + REPLY = 'REPLY', + MENTION = 'MENTION', + FOLLOW = 'FOLLOW', + DM = 'DM', +} + +export enum Platform { + WEB = 'WEB', + IOS = 'IOS', + ANDROID = 'ANDROID', +} diff --git a/src/notifications/events/notification.event.ts b/src/notifications/events/notification.event.ts new file mode 100644 index 0000000..a3c9360 --- /dev/null +++ b/src/notifications/events/notification.event.ts @@ -0,0 +1,13 @@ +import { NotificationType } from '../enums/notification.enum'; + +export class NotificationEvent { + recipientId: number; + type: NotificationType; + actorId: number; + postId?: number; + quotePostId?: number; + replyId?: number; + threadPostId?: number; + conversationId?: number; + messageText?: string; +} diff --git a/src/notifications/events/notification.listener.spec.ts b/src/notifications/events/notification.listener.spec.ts new file mode 100644 index 0000000..41c1b0b --- /dev/null +++ b/src/notifications/events/notification.listener.spec.ts @@ -0,0 +1,416 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationListener } from './notification.listener'; +import { NotificationService } from '../notification.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Services } from '../../utils/constants'; +import { NotificationType } from '../enums/notification.enum'; + +describe('NotificationListener', () => { + let listener: NotificationListener; + let notificationService: jest.Mocked; + let prismaService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationListener, + { + provide: Services.NOTIFICATION, + useValue: { + createNotification: jest.fn(), + sendPushNotification: jest.fn(), + truncateText: jest.fn((text) => text?.substring(0, 100) + '...'), + }, + }, + { + provide: Services.PRISMA, + useValue: { + user: { + findUnique: jest.fn(), + }, + post: { + findUnique: jest.fn(), + }, + }, + }, + ], + }).compile(); + + listener = module.get(NotificationListener); + notificationService = module.get(Services.NOTIFICATION); + prismaService = module.get(Services.PRISMA); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleNotificationCreate - LIKE', () => { + it('should create LIKE notification and send push', async () => { + const mockActor = { + id: 2, + username: 'john_doe', + avatar_url: 'https://example.com/avatar.jpg', + }; + + const mockPost = { + id: 100, + content: 'This is my post content that I wrote today', + user_id: 1, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (prismaService.post.findUnique as jest.Mock).mockResolvedValue(mockPost); + (notificationService.createNotification as jest.Mock).mockResolvedValue({ + id: 'notif-123', + }); + + const event = { + type: NotificationType.LIKE, + recipientId: 1, + actorId: 2, + postId: 100, + }; + + await listener.handleNotificationCreate(event); + + expect(prismaService.user.findUnique).toHaveBeenCalledWith({ + where: { id: 2 }, + select: { id: true, username: true, avatar_url: true }, + }); + + expect(prismaService.post.findUnique).toHaveBeenCalledWith({ + where: { id: 100 }, + select: { id: true, content: true, user_id: true }, + }); + + expect(notificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: NotificationType.LIKE, + recipientId: 1, + actorId: 2, + actorUsername: 'john_doe', + actorAvatarUrl: 'https://example.com/avatar.jpg', + postId: 100, + postPreviewText: expect.any(String), + }), + ); + + expect(notificationService.sendPushNotification).toHaveBeenCalledWith( + 1, + 'New Like', + '@john_doe liked your post', + expect.any(Object), + ); + }); + }); + + describe('handleNotificationCreate - REPOST', () => { + it('should create REPOST notification', async () => { + const mockActor = { + id: 3, + username: 'jane_smith', + avatar_url: null, + }; + + const mockPost = { + id: 200, + content: 'Original post', + user_id: 1, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (prismaService.post.findUnique as jest.Mock).mockResolvedValue(mockPost); + (notificationService.createNotification as jest.Mock).mockResolvedValue({ + id: 'notif-456', + }); + + const event = { + type: NotificationType.REPOST, + recipientId: 1, + actorId: 3, + postId: 200, + }; + + await listener.handleNotificationCreate(event); + + expect(notificationService.sendPushNotification).toHaveBeenCalledWith( + 1, + 'New Repost', + '@jane_smith reposted your post', + expect.any(Object), + ); + }); + }); + + describe('handleNotificationCreate - QUOTE', () => { + it('should create QUOTE notification with quotePostId', async () => { + const mockActor = { + id: 4, + username: 'bob_wilson', + avatar_url: 'https://example.com/bob.jpg', + }; + + const mockOriginalPost = { + id: 300, + content: 'Original post to quote', + user_id: 1, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (prismaService.post.findUnique as jest.Mock).mockResolvedValue(mockOriginalPost); + (notificationService.createNotification as jest.Mock).mockResolvedValue({ + id: 'notif-quote-1', + }); + + const event = { + type: NotificationType.QUOTE, + recipientId: 1, + actorId: 4, + postId: 400, // New quote post + quotePostId: 300, // Original post being quoted + }; + + await listener.handleNotificationCreate(event); + + expect(notificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: NotificationType.QUOTE, + postId: 400, + quotePostId: 300, + }), + ); + + expect(notificationService.sendPushNotification).toHaveBeenCalledWith( + 1, + 'New Quote', + '@bob_wilson quoted your post', + expect.any(Object), + ); + }); + }); + + describe('handleNotificationCreate - REPLY', () => { + it('should create REPLY notification with threadPostId', async () => { + const mockActor = { + id: 5, + username: 'alice_jones', + avatar_url: null, + }; + + const mockOriginalPost = { + id: 500, + content: 'Original post', + user_id: 1, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (prismaService.post.findUnique as jest.Mock).mockResolvedValue(mockOriginalPost); + (notificationService.createNotification as jest.Mock).mockResolvedValue({ + id: 'notif-reply-1', + }); + + const event = { + type: NotificationType.REPLY, + recipientId: 1, + actorId: 5, + replyId: 600, // Reply post + threadPostId: 500, // Original thread post + }; + + await listener.handleNotificationCreate(event); + + expect(notificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: NotificationType.REPLY, + replyId: 600, + threadPostId: 500, + }), + ); + + expect(notificationService.sendPushNotification).toHaveBeenCalledWith( + 1, + 'New Reply', + '@alice_jones replied to your post', + expect.any(Object), + ); + }); + }); + + describe('handleNotificationCreate - MENTION', () => { + it('should create MENTION notification', async () => { + const mockActor = { + id: 6, + username: 'charlie_brown', + avatar_url: 'https://example.com/charlie.jpg', + }; + + const mockPost = { + id: 700, + content: '@testuser check this out!', + user_id: 6, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (prismaService.post.findUnique as jest.Mock).mockResolvedValue(mockPost); + (notificationService.createNotification as jest.Mock).mockResolvedValue({ + id: 'notif-mention-1', + }); + + const event = { + type: NotificationType.MENTION, + recipientId: 1, + actorId: 6, + postId: 700, + }; + + await listener.handleNotificationCreate(event); + + expect(notificationService.sendPushNotification).toHaveBeenCalledWith( + 1, + 'New Mention', + '@charlie_brown mentioned you in a post', + expect.any(Object), + ); + }); + }); + + describe('handleNotificationCreate - FOLLOW', () => { + it('should create FOLLOW notification without post data', async () => { + const mockActor = { + id: 7, + username: 'diana_prince', + avatar_url: 'https://example.com/diana.jpg', + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (notificationService.createNotification as jest.Mock).mockResolvedValue({ + id: 'notif-follow-1', + }); + + const event = { + type: NotificationType.FOLLOW, + recipientId: 1, + actorId: 7, + }; + + await listener.handleNotificationCreate(event); + + expect(prismaService.post.findUnique).not.toHaveBeenCalled(); + + expect(notificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: NotificationType.FOLLOW, + recipientId: 1, + actorId: 7, + actorUsername: 'diana_prince', + }), + ); + + expect(notificationService.sendPushNotification).toHaveBeenCalledWith( + 1, + 'New Follower', + '@diana_prince started following you', + expect.any(Object), + ); + }); + }); + + describe('handleNotificationCreate - DM', () => { + it('should create DM notification with conversation data', async () => { + const mockActor = { + id: 8, + username: 'eve_adams', + avatar_url: null, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (notificationService.createNotification as jest.Mock).mockResolvedValue({ + id: 'notif-dm-1', + }); + + const event = { + type: NotificationType.DM, + recipientId: 1, + actorId: 8, + conversationId: 999, + messagePreview: 'Hey! How are you doing?', + }; + + await listener.handleNotificationCreate(event); + + expect(notificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: NotificationType.DM, + conversationId: 999, + messagePreview: 'Hey! How are you doing?', + }), + ); + + expect(notificationService.sendPushNotification).toHaveBeenCalledWith( + 1, + 'New Message', + '@eve_adams: Hey! How are you doing?', + expect.any(Object), + ); + }); + }); + + describe('Error Handling', () => { + it('should handle missing actor gracefully', async () => { + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(null); + + const event = { + type: NotificationType.LIKE, + recipientId: 1, + actorId: 999, + postId: 100, + }; + + await expect(listener.handleNotificationCreate(event)).resolves.not.toThrow(); + expect(notificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should handle missing post gracefully', async () => { + const mockActor = { + id: 2, + username: 'john_doe', + avatar_url: null, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (prismaService.post.findUnique as jest.Mock).mockResolvedValue(null); + + const event = { + type: NotificationType.LIKE, + recipientId: 1, + actorId: 2, + postId: 999, + }; + + await expect(listener.handleNotificationCreate(event)).resolves.not.toThrow(); + expect(notificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should continue even if notification creation fails', async () => { + const mockActor = { + id: 2, + username: 'john_doe', + avatar_url: null, + }; + + (prismaService.user.findUnique as jest.Mock).mockResolvedValue(mockActor); + (notificationService.createNotification as jest.Mock).mockRejectedValue( + new Error('Database error'), + ); + + const event = { + type: NotificationType.FOLLOW, + recipientId: 1, + actorId: 2, + }; + + await expect(listener.handleNotificationCreate(event)).resolves.not.toThrow(); + }); + }); +}); diff --git a/src/notifications/events/notification.listener.ts b/src/notifications/events/notification.listener.ts new file mode 100644 index 0000000..6ec73be --- /dev/null +++ b/src/notifications/events/notification.listener.ts @@ -0,0 +1,183 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { NotificationEvent } from './notification.event'; +import { NotificationService } from '../notification.service'; +import { NotificationType } from '../enums/notification.enum'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { Services } from 'src/utils/constants'; + +@Injectable() +export class NotificationListener { + private readonly logger = new Logger(NotificationListener.name); + + constructor( + @Inject(Services.NOTIFICATION) + private readonly notificationService: NotificationService, + @Inject(Services.PRISMA) + private readonly prismaService: PrismaService, + ) {} + + @OnEvent('notification.create') + async handleNotificationCreate(event: NotificationEvent) { + try { + this.logger.debug(`Received notification event: ${event.type} for user ${event.recipientId}`); + + // Fetch actor information + const actor = await this.prismaService.user.findUnique({ + where: { id: event.actorId }, + select: { + username: true, + Profile: { + select: { + name: true, + profile_image_url: true, + }, + }, + }, + }); + + if (!actor) { + this.logger.error(`Actor not found: ${event.actorId}`); + return; + } + + // Build notification data + let postPreviewText: string | undefined; + let messagePreview: string | undefined; + + // For post-related notifications, fetch post content + if (event.postId) { + const post = await this.prismaService.post.findUnique({ + where: { id: event.postId }, + select: { content: true }, + }); + + if (post?.content) { + postPreviewText = this.notificationService.truncateText(post.content, 100); + } + } + + // For DM notifications + if (event.conversationId && event.messageText) { + messagePreview = this.notificationService.truncateText(event.messageText, 100); + } + + // Create notification in database + const notification = await this.notificationService.createNotification({ + type: event.type, + recipientId: event.recipientId, + actorId: event.actorId, + actorUsername: actor.username, + actorDisplayName: actor.Profile?.name || null, + actorAvatarUrl: actor.Profile?.profile_image_url || null, + postId: event.postId, + quotePostId: event.quotePostId, + replyId: event.replyId, + threadPostId: event.threadPostId, + postPreviewText, + conversationId: event.conversationId, + messagePreview, + }); + + // If notification creation returned null, it means it was a duplicate + if (!notification) { + this.logger.debug( + `Duplicate notification skipped: ${event.type} for user ${event.recipientId}`, + ); + return; + } + + // Send push notification + const { title, body } = this.buildPushNotificationMessage( + event.type, + actor.Profile?.name || actor.username, + postPreviewText, + messagePreview, + ); + + // Send same data structure as Firestore for consistency + await this.notificationService.sendPushNotification(event.recipientId, title, body, { + id: notification.id, + type: notification.type, + recipientId: notification.recipientId.toString(), + actorId: notification.actor.id.toString(), + actorUsername: notification.actor.username, + actorDisplayName: notification.actor.displayName || '', + actorAvatarUrl: notification.actor.avatarUrl || '', + postId: notification.postId?.toString() || '', + quotePostId: notification.quotePostId?.toString() || '', + replyId: notification.replyId?.toString() || '', + threadPostId: notification.threadPostId?.toString() || '', + postPreviewText: notification.postPreviewText || '', + conversationId: notification.conversationId?.toString() || '', + messagePreview: notification.messagePreview || '', + isRead: notification.isRead.toString(), + createdAt: notification.createdAt, + }); + + this.logger.log(`Notification processed: ${event.type} for user ${event.recipientId}`); + } catch (error) { + this.logger.error('Failed to process notification event', error); + } + } + + /** + * Build push notification title and body based on notification type + */ + private buildPushNotificationMessage( + type: NotificationType, + actorDisplayName: string, + postPreview?: string, + messagePreview?: string, + ): { title: string; body: string } { + switch (type) { + case NotificationType.LIKE: + return { + title: 'New Like', + body: `${actorDisplayName} liked your post${postPreview ? `: "${postPreview}"` : ''}`, + }; + + case NotificationType.REPOST: + return { + title: 'New Repost', + body: `${actorDisplayName} reposted your post${postPreview ? `: "${postPreview}"` : ''}`, + }; + + case NotificationType.QUOTE: + return { + title: 'New Quote', + body: `${actorDisplayName} quoted your post${postPreview ? `: "${postPreview}"` : ''}`, + }; + + case NotificationType.REPLY: + return { + title: 'New Reply', + body: `${actorDisplayName} replied to your post${postPreview ? `: "${postPreview}"` : ''}`, + }; + + case NotificationType.MENTION: + return { + title: 'New Mention', + body: `${actorDisplayName} mentioned you in a post${postPreview ? `: "${postPreview}"` : ''}`, + }; + + case NotificationType.FOLLOW: + return { + title: 'New Follower', + body: `${actorDisplayName} started following you`, + }; + + case NotificationType.DM: + return { + title: `Message from ${actorDisplayName}`, + body: messagePreview || 'New message', + }; + + default: + return { + title: 'New Notification', + body: `${actorDisplayName} interacted with you`, + }; + } + } +} diff --git a/src/notifications/interfaces/notification.interface.ts b/src/notifications/interfaces/notification.interface.ts new file mode 100644 index 0000000..d74a399 --- /dev/null +++ b/src/notifications/interfaces/notification.interface.ts @@ -0,0 +1,46 @@ +import { NotificationType } from '../enums/notification.enum'; + +export interface NotificationActor { + id: number; + username: string; + displayName: string | null; + avatarUrl: string | null; +} + +export interface NotificationPayload { + id: string; + type: NotificationType; + recipientId: number; + actor: NotificationActor; + isRead: boolean; + createdAt: string; + + postId?: number; + quotePostId?: number; + replyId?: number; + threadPostId?: number; + postPreviewText?: string; + + conversationId?: number; + messagePreview?: string; +} + +export interface CreateNotificationDto { + type: NotificationType; + recipientId: number; + actorId: number; + actorUsername: string; + actorDisplayName?: string | null; + actorAvatarUrl?: string | null; + + // Post-related + postId?: number; + quotePostId?: number; + replyId?: number; + threadPostId?: number; + postPreviewText?: string; + + // DM-related + conversationId?: number; + messagePreview?: string; +} diff --git a/src/notifications/notification.service.spec.ts b/src/notifications/notification.service.spec.ts new file mode 100644 index 0000000..321e4b4 --- /dev/null +++ b/src/notifications/notification.service.spec.ts @@ -0,0 +1,443 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationService } from './notification.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { FirebaseService } from '../firebase/firebase.service'; +import { Services } from '../utils/constants'; +import { NotificationType, Platform } from './enums/notification.enum'; +import { NotFoundException } from '@nestjs/common'; + +describe('NotificationService', () => { + let service: NotificationService; + let prismaService: jest.Mocked; + let firebaseService: jest.Mocked; + + const mockFirestore = { + collection: jest.fn().mockReturnThis(), + doc: jest.fn().mockReturnThis(), + set: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ docs: [] }), + }; + + const mockMessaging = { + sendEachForMulticast: jest.fn(), + }; + + const mockBatch = { + update: jest.fn(), + commit: jest.fn().mockResolvedValue(undefined), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationService, + { + provide: Services.PRISMA, + useValue: { + notification: { + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + deviceToken: { + findMany: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + }, + }, + { + provide: Services.FIREBASE, + useValue: { + getFirestore: jest.fn().mockReturnValue({ + ...mockFirestore, + batch: jest.fn().mockReturnValue(mockBatch), + }), + getMessaging: jest.fn().mockReturnValue(mockMessaging), + }, + }, + ], + }).compile(); + + service = module.get(NotificationService); + prismaService = module.get(Services.PRISMA); + firebaseService = module.get(Services.FIREBASE); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createNotification', () => { + it('should create a notification in Prisma and sync to Firestore', async () => { + const mockNotification = { + id: 'notif-123', + type: NotificationType.LIKE, + recipientId: 1, + actorId: 2, + actorUsername: 'john_doe', + actorAvatarUrl: 'https://example.com/avatar.jpg', + postId: 'post-456', + quotePostId: null, + replyId: null, + threadPostId: null, + postPreviewText: 'Great post!', + conversationId: null, + messagePreview: null, + isRead: false, + createdAt: new Date('2025-11-29T10:00:00Z'), + }; + + prismaService.notification.create.mockResolvedValue(mockNotification as any); + + const dto = { + type: NotificationType.LIKE, + recipientId: 1, + actorId: 2, + actorUsername: 'john_doe', + actorAvatarUrl: 'https://example.com/avatar.jpg', + postId: 'post-456', + postPreviewText: 'Great post!', + }; + + const result = await service.createNotification(dto); + + expect(prismaService.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + type: NotificationType.LIKE, + recipientId: 1, + actorId: 2, + }), + }); + + expect(result).toEqual({ + id: 'notif-123', + type: NotificationType.LIKE, + actor: { + id: 2, + username: 'john_doe', + avatarUrl: 'https://example.com/avatar.jpg', + }, + postId: 'post-456', + postPreviewText: 'Great post!', + isRead: false, + createdAt: '2025-11-29T10:00:00.000Z', + }); + + expect(mockFirestore.collection).toHaveBeenCalledWith('users'); + expect(mockFirestore.set).toHaveBeenCalled(); + }); + + it('should handle FOLLOW notification without post data', async () => { + const mockNotification = { + id: 'notif-789', + type: NotificationType.FOLLOW, + recipientId: 1, + actorId: 3, + actorUsername: 'jane_smith', + actorAvatarUrl: null, + postId: null, + quotePostId: null, + replyId: null, + threadPostId: null, + postPreviewText: null, + conversationId: null, + messagePreview: null, + isRead: false, + createdAt: new Date('2025-11-29T11:00:00Z'), + }; + + prismaService.notification.create.mockResolvedValue(mockNotification as any); + + const dto = { + type: NotificationType.FOLLOW, + recipientId: 1, + actorId: 3, + actorUsername: 'jane_smith', + }; + + const result = await service.createNotification(dto); + + expect(result.type).toBe(NotificationType.FOLLOW); + expect(result.postId).toBeUndefined(); + expect(result.actor.username).toBe('jane_smith'); + }); + + it('should handle DM notification with conversation data', async () => { + const mockNotification = { + id: 'notif-dm-1', + type: NotificationType.DM, + recipientId: 1, + actorId: 4, + actorUsername: 'bob_wilson', + actorAvatarUrl: 'https://example.com/bob.jpg', + postId: null, + quotePostId: null, + replyId: null, + threadPostId: null, + postPreviewText: null, + conversationId: 'conv-123', + messagePreview: 'Hey, how are you?', + isRead: false, + createdAt: new Date('2025-11-29T12:00:00Z'), + }; + + prismaService.notification.create.mockResolvedValue(mockNotification as any); + + const dto = { + type: NotificationType.DM, + recipientId: 1, + actorId: 4, + actorUsername: 'bob_wilson', + actorAvatarUrl: 'https://example.com/bob.jpg', + conversationId: 'conv-123', + messagePreview: 'Hey, how are you?', + }; + + const result = await service.createNotification(dto); + + expect(result.type).toBe(NotificationType.DM); + expect(result.conversationId).toBe('conv-123'); + expect(result.messagePreview).toBe('Hey, how are you?'); + }); + }); + + describe('getNotifications', () => { + it('should return paginated notifications', async () => { + const mockNotifications = [ + { + id: 'notif-1', + type: NotificationType.LIKE, + recipientId: 1, + actorId: 2, + actorUsername: 'user2', + actorAvatarUrl: null, + postId: 'post-1', + isRead: false, + createdAt: new Date(), + }, + { + id: 'notif-2', + type: NotificationType.FOLLOW, + recipientId: 1, + actorId: 3, + actorUsername: 'user3', + actorAvatarUrl: null, + postId: null, + isRead: true, + createdAt: new Date(), + }, + ]; + + prismaService.notification.count.mockResolvedValueOnce(10); // total + prismaService.notification.findMany.mockResolvedValue(mockNotifications as any); + prismaService.notification.count.mockResolvedValueOnce(5); // unread + + const result = await service.getNotifications(1, 1, 20, false); + + expect(result.data).toHaveLength(2); + expect(result.metadata.totalItems).toBe(10); + expect(result.metadata.unreadCount).toBe(5); + expect(result.metadata.totalPages).toBe(1); + }); + + it('should filter unread notifications only', async () => { + prismaService.notification.count.mockResolvedValueOnce(5); + prismaService.notification.findMany.mockResolvedValue([]); + prismaService.notification.count.mockResolvedValueOnce(5); + + await service.getNotifications(1, 1, 20, true); + + expect(prismaService.notification.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { recipientId: 1, isRead: false }, + }), + ); + }); + }); + + describe('markAsRead', () => { + it('should mark a notification as read in Prisma and Firestore', async () => { + const mockNotification = { + id: 'notif-123', + recipientId: 1, + isRead: false, + }; + + prismaService.notification.findFirst.mockResolvedValue(mockNotification as any); + prismaService.notification.update.mockResolvedValue({ + ...mockNotification, + isRead: true, + } as any); + + await service.markAsRead('notif-123', 1); + + expect(prismaService.notification.update).toHaveBeenCalledWith({ + where: { id: 'notif-123' }, + data: { isRead: true }, + }); + + expect(mockFirestore.update).toHaveBeenCalledWith({ isRead: true }); + }); + + it('should throw NotFoundException if notification not found', async () => { + prismaService.notification.findFirst.mockResolvedValue(null); + + await expect(service.markAsRead('notif-999', 1)).rejects.toThrow(NotFoundException); + }); + + it('should not update if already read', async () => { + const mockNotification = { + id: 'notif-123', + recipientId: 1, + isRead: true, + }; + + prismaService.notification.findFirst.mockResolvedValue(mockNotification as any); + + await service.markAsRead('notif-123', 1); + + expect(prismaService.notification.update).not.toHaveBeenCalled(); + }); + }); + + describe('markAllAsRead', () => { + it('should mark all unread notifications as read', async () => { + const mockUnreadDocs = [{ ref: { id: 'notif-1' } }, { ref: { id: 'notif-2' } }]; + + mockFirestore.get.mockResolvedValue({ docs: mockUnreadDocs } as any); + + await service.markAllAsRead(1); + + expect(prismaService.notification.updateMany).toHaveBeenCalledWith({ + where: { recipientId: 1, isRead: false }, + data: { isRead: true }, + }); + + expect(mockBatch.commit).toHaveBeenCalled(); + }); + }); + + describe('sendPushNotification', () => { + it('should send push notification to all user devices', async () => { + const mockDeviceTokens = [ + { token: 'token-1', platform: Platform.IOS }, + { token: 'token-2', platform: Platform.ANDROID }, + ]; + + prismaService.deviceToken.findMany.mockResolvedValue(mockDeviceTokens as any); + mockMessaging.sendEachForMulticast.mockResolvedValue({ + successCount: 2, + failureCount: 0, + responses: [], + }); + + await service.sendPushNotification(1, 'New Like', 'John liked your post'); + + expect(mockMessaging.sendEachForMulticast).toHaveBeenCalledWith({ + tokens: ['token-1', 'token-2'], + notification: { + title: 'New Like', + body: 'John liked your post', + }, + data: {}, + }); + }); + + it('should handle invalid tokens and remove them', async () => { + const mockDeviceTokens = [ + { token: 'valid-token', platform: Platform.WEB }, + { token: 'invalid-token', platform: Platform.IOS }, + ]; + + prismaService.deviceToken.findMany.mockResolvedValue(mockDeviceTokens as any); + mockMessaging.sendEachForMulticast.mockResolvedValue({ + successCount: 1, + failureCount: 1, + responses: [ + { success: true }, + { + success: false, + error: { code: 'messaging/invalid-registration-token' }, + }, + ], + }); + + await service.sendPushNotification(1, 'Test', 'Test message'); + + expect(prismaService.deviceToken.deleteMany).toHaveBeenCalledWith({ + where: { token: { in: ['invalid-token'] } }, + }); + }); + + it('should not send if no device tokens found', async () => { + prismaService.deviceToken.findMany.mockResolvedValue([]); + + await service.sendPushNotification(1, 'Test', 'Test message'); + + expect(mockMessaging.sendEachForMulticast).not.toHaveBeenCalled(); + }); + }); + + describe('registerDevice', () => { + it('should register a new device token', async () => { + prismaService.deviceToken.upsert.mockResolvedValue({ + id: 'device-1', + userId: 1, + token: 'new-token', + platform: Platform.ANDROID, + } as any); + + await service.registerDevice(1, 'new-token', Platform.ANDROID); + + expect(prismaService.deviceToken.upsert).toHaveBeenCalledWith({ + where: { token: 'new-token' }, + update: expect.objectContaining({ + userId: 1, + platform: Platform.ANDROID, + }), + create: { + userId: 1, + token: 'new-token', + platform: Platform.ANDROID, + }, + }); + }); + }); + + describe('removeDevice', () => { + it('should remove a device token', async () => { + prismaService.deviceToken.delete.mockResolvedValue({} as any); + + await service.removeDevice('token-to-remove'); + + expect(prismaService.deviceToken.delete).toHaveBeenCalledWith({ + where: { token: 'token-to-remove' }, + }); + }); + }); + + describe('truncateText', () => { + it('should return original text if within limit', () => { + const text = 'Short text'; + expect(service.truncateText(text, 100)).toBe('Short text'); + }); + + it('should truncate text and add ellipsis', () => { + const text = 'a'.repeat(150); + const result = service.truncateText(text, 100); + expect(result).toHaveLength(103); // 100 + '...' + expect(result).toEndWith('...'); + }); + + it('should handle empty text', () => { + expect(service.truncateText('', 100)).toBe(''); + }); + }); +}); diff --git a/src/notifications/notification.service.ts b/src/notifications/notification.service.ts new file mode 100644 index 0000000..b47ecc3 --- /dev/null +++ b/src/notifications/notification.service.ts @@ -0,0 +1,467 @@ +import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { FirebaseService } from 'src/firebase/firebase.service'; +import { Services } from 'src/utils/constants'; +import { CreateNotificationDto, NotificationPayload } from './interfaces/notification.interface'; +import { NotificationType, Platform } from './enums/notification.enum'; + +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + constructor( + @Inject(Services.PRISMA) + private readonly prismaService: PrismaService, + @Inject(Services.FIREBASE) + private readonly firebaseService: FirebaseService, + ) {} + + /** + * Create a notification in Prisma (source of truth) and sync to Firestore + */ + async createNotification(dto: CreateNotificationDto): Promise { + try { + // Optional: Check for duplicates before attempting creation (early exit optimization) + const isDuplicate = await this.checkDuplicateNotification(dto); + if (isDuplicate) { + this.logger.debug( + `Duplicate notification prevented: ${dto.type} for user ${dto.recipientId} by ${dto.actorId}`, + ); + return null; + } + + // Create notification in Prisma + const notification = await this.prismaService.notification.create({ + data: { + type: dto.type, + recipientId: dto.recipientId, + actorId: dto.actorId, + actorUsername: dto.actorUsername, + actorDisplayName: dto.actorDisplayName, + actorAvatarUrl: dto.actorAvatarUrl, + postId: dto.postId, + quotePostId: dto.quotePostId, + replyId: dto.replyId, + threadPostId: dto.threadPostId, + postPreviewText: dto.postPreviewText, + conversationId: dto.conversationId, + messagePreview: dto.messagePreview, + }, + }); + + // Build notification payload + const payload = this.buildNotificationPayload(notification); + + // Sync to Firestore for real-time updates + await this.syncToFirestore(payload); + + this.logger.log( + `Notification created: ${notification.type} for user ${dto.recipientId} by ${dto.actorId}`, + ); + + return payload; + } catch (error) { + // Handle unique constraint violation gracefully (P2002 = Prisma unique constraint error) + if (error.code === 'P2002') { + this.logger.debug( + `Duplicate notification prevented by database constraint: ${dto.type} for user ${dto.recipientId}`, + ); + return null; // Exit gracefully - this is expected behavior + } + + this.logger.error('Failed to create notification', error); + throw error; + } + } + + /** + * Sync notification to Firestore for real-time updates + */ + private async syncToFirestore(payload: NotificationPayload): Promise { + try { + const firestore = this.firebaseService.getFirestore(); + const notificationRef = firestore + .collection('users') + .doc(payload.recipientId.toString()) + .collection('notifications') + .doc(payload.id); + + await notificationRef.set({ + ...payload, + createdAt: payload.createdAt, // Keep ISO string format + }); + + this.logger.debug(`Synced notification ${payload.id} to Firestore`); + } catch (error) { + this.logger.error('Failed to sync notification to Firestore', error); + // Don't throw - Firestore sync failure shouldn't break the flow + } + } + + /** + * Check if a duplicate notification already exists + * Returns true if duplicate exists, false otherwise + */ + private async checkDuplicateNotification(dto: CreateNotificationDto): Promise { + const whereClause = this.buildUniqueWhereClause(dto); + + if (!whereClause) { + // No deduplication needed for this type (e.g., DM) + return false; + } + + const existing = await this.prismaService.notification.findFirst({ + where: whereClause, + }); + + return existing !== null; + } + + /** + * Build where clause for duplicate checking based on notification type + */ + private buildUniqueWhereClause(dto: CreateNotificationDto): any { + switch (dto.type) { + case NotificationType.LIKE: + return dto.postId + ? { + type: NotificationType.LIKE, + recipientId: dto.recipientId, + actorId: dto.actorId, + postId: dto.postId, + } + : null; + + case NotificationType.REPOST: + return dto.postId + ? { + type: NotificationType.REPOST, + recipientId: dto.recipientId, + actorId: dto.actorId, + postId: dto.postId, + } + : null; + + case NotificationType.FOLLOW: + return { + type: NotificationType.FOLLOW, + recipientId: dto.recipientId, + actorId: dto.actorId, + }; + + case NotificationType.MENTION: + return dto.postId + ? { + type: NotificationType.MENTION, + recipientId: dto.recipientId, + actorId: dto.actorId, + postId: dto.postId, + } + : null; + + case NotificationType.QUOTE: + return dto.quotePostId + ? { + type: NotificationType.QUOTE, + recipientId: dto.recipientId, + actorId: dto.actorId, + quotePostId: dto.quotePostId, + } + : null; + + case NotificationType.REPLY: + return dto.replyId + ? { + type: NotificationType.REPLY, + recipientId: dto.recipientId, + actorId: dto.actorId, + replyId: dto.replyId, + } + : null; + + case NotificationType.DM: + // No deduplication for DMs - each message is unique + return null; + + default: + return null; + } + } + + /** + * Send push notification via FCM + */ + async sendPushNotification( + userId: number, + title: string, + body: string, + data?: Record, + ): Promise { + try { + // Get user's device tokens + const deviceTokens = await this.prismaService.deviceToken.findMany({ + where: { userId }, + select: { token: true, platform: true }, + }); + + if (deviceTokens.length === 0) { + this.logger.debug(`No device tokens found for user ${userId}`); + return; + } + + const messaging = this.firebaseService.getMessaging(); + const tokens = deviceTokens.map((dt) => dt.token); + + // Send to all devices + const response = await messaging.sendEachForMulticast({ + tokens, + notification: { + title, + body, + }, + data: data || {}, + }); + + this.logger.log( + `Push notification sent to ${response.successCount}/${tokens.length} devices for user ${userId}`, + ); + + // Handle failed tokens (invalid/expired) + if (response.failureCount > 0) { + await this.handleFailedTokens(response.responses, tokens); + } + } catch (error) { + this.logger.error(`Failed to send push notification to user ${userId}`, error); + // Don't throw - push notification failure shouldn't break the flow + } + } + + /** + * Remove invalid/expired FCM tokens + */ + private async handleFailedTokens(responses: any[], tokens: string[]): Promise { + const invalidTokens: string[] = []; + + responses.forEach((response, index) => { + if (!response.success) { + const errorCode = response.error?.code; + // Remove tokens that are invalid, not registered, or expired + if ( + errorCode === 'messaging/invalid-registration-token' || + errorCode === 'messaging/registration-token-not-registered' + ) { + invalidTokens.push(tokens[index]); + } + } + }); + + if (invalidTokens.length > 0) { + await this.prismaService.deviceToken.deleteMany({ + where: { token: { in: invalidTokens } }, + }); + this.logger.log(`Removed ${invalidTokens.length} invalid device tokens`); + } + } + + /** + * Build notification payload for API response and Firestore + */ + private buildNotificationPayload(notification: any): NotificationPayload { + const payload: NotificationPayload = { + id: notification.id, + type: notification.type as NotificationType, + recipientId: notification.recipientId, + actor: { + id: notification.actorId, + username: notification.actorUsername, + displayName: notification.actorDisplayName, + avatarUrl: notification.actorAvatarUrl, + }, + isRead: notification.isRead, + createdAt: notification.createdAt.toISOString(), + }; + + // Add post-related fields + if (notification.postId) payload.postId = notification.postId; + if (notification.quotePostId) payload.quotePostId = notification.quotePostId; + if (notification.replyId) payload.replyId = notification.replyId; + if (notification.threadPostId) payload.threadPostId = notification.threadPostId; + if (notification.postPreviewText) payload.postPreviewText = notification.postPreviewText; + + // Add DM-related fields + if (notification.conversationId) payload.conversationId = notification.conversationId; + if (notification.messagePreview) payload.messagePreview = notification.messagePreview; + + return payload; + } + + /** + * Get notifications for a user with pagination + */ + async getNotifications( + userId: number, + page: number = 1, + limit: number = 20, + unreadOnly: boolean = false, + ) { + const where: any = { recipientId: userId }; + if (unreadOnly) { + where.isRead = false; + } + + const [totalItems, notifications, unreadCount] = await Promise.all([ + this.prismaService.notification.count({ where }), + this.prismaService.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prismaService.notification.count({ + where: { recipientId: userId, isRead: false }, + }), + ]); + + const data = notifications.map((notification) => this.buildNotificationPayload(notification)); + + return { + data, + metadata: { + totalItems, + page, + limit, + totalPages: Math.ceil(totalItems / limit), + unreadCount, + }, + }; + } + + /** + * Get unread notifications count for a user + */ + async getUnreadCount(userId: number): Promise { + return this.prismaService.notification.count({ + where: { recipientId: userId, isRead: false }, + }); + } + + /** + * Mark a notification as read + */ + async markAsRead(notificationId: string, userId: number): Promise { + const notification = await this.prismaService.notification.findFirst({ + where: { id: notificationId, recipientId: userId }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + if (notification.isRead) { + return; // Already read + } + + // Update in Prisma + await this.prismaService.notification.update({ + where: { id: notificationId }, + data: { isRead: true }, + }); + + // Update in Firestore + try { + const firestore = this.firebaseService.getFirestore(); + await firestore + .collection('users') + .doc(userId.toString()) + .collection('notifications') + .doc(notificationId) + .update({ isRead: true }); + } catch (error) { + this.logger.error('Failed to update notification in Firestore', error); + } + + this.logger.debug(`Notification ${notificationId} marked as read`); + } + + /** + * Mark all notifications as read for a user + */ + async markAllAsRead(userId: number): Promise { + // Update in Prisma + await this.prismaService.notification.updateMany({ + where: { recipientId: userId, isRead: false }, + data: { isRead: true }, + }); + + // Update in Firestore (batch update) + try { + const firestore = this.firebaseService.getFirestore(); + const notificationsRef = firestore + .collection('users') + .doc(userId.toString()) + .collection('notifications'); + + const unreadNotifications = await notificationsRef.where('isRead', '==', false).get(); + + const batch = firestore.batch(); + unreadNotifications.docs.forEach((doc) => { + batch.update(doc.ref, { isRead: true }); + }); + + await batch.commit(); + } catch (error) { + this.logger.error('Failed to mark all notifications as read in Firestore', error); + } + + this.logger.log(`All notifications marked as read for user ${userId}`); + } + + /** + * Register a device token for push notifications + */ + async registerDevice(userId: number, token: string, platform: Platform): Promise { + try { + // Upsert device token + await this.prismaService.deviceToken.upsert({ + where: { token }, + update: { userId, platform, updatedAt: new Date() }, + create: { userId, token, platform }, + }); + + this.logger.log(`Device token registered for user ${userId} on ${platform}`); + } catch (error) { + this.logger.error('Failed to register device token', error); + throw error; + } + } + + /** + * Remove a device token + */ + async removeDevice(token: string): Promise { + try { + await this.prismaService.deviceToken.delete({ + where: { token }, + }); + + this.logger.log(`Device token removed: ${token}`); + } catch (error) { + if (error.code === 'P2025') { + this.logger.debug(`Device token not found: ${token}`); + return; + } + + this.logger.error('Failed to remove device token', error); + throw error; + } + } + + /** + * Truncate text to specified length and add ellipsis + */ + truncateText(text: string, maxLength: number = 100): string { + if (!text) return ''; + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + } +} diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..0cd9fd5 --- /dev/null +++ b/src/notifications/notifications.controller.ts @@ -0,0 +1,106 @@ +import { + Controller, + Get, + Patch, + Post, + Delete, + Body, + Param, + Query, + UseGuards, + Inject, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiCookieAuth } from '@nestjs/swagger'; +import { NotificationService } from './notification.service'; +import { GetNotificationsDto } from './dto/get-notifications.dto'; +import { RegisterDeviceDto } from './dto/register-device.dto'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth/jwt-auth.guard'; +import { CurrentUser } from 'src/auth/decorators/current-user.decorator'; +import { Services } from 'src/utils/constants'; + +@ApiTags('Notifications') +@ApiBearerAuth() +@ApiCookieAuth('access_token') +@Controller('notifications') +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor( + @Inject(Services.NOTIFICATION) + private readonly notificationService: NotificationService, + ) {} + + @Get() + @ApiOperation({ summary: 'Get notifications for authenticated user' }) + @ApiResponse({ + status: 200, + description: 'Returns paginated notifications', + }) + async getNotifications(@CurrentUser('id') userId: number, @Query() query: GetNotificationsDto) { + return this.notificationService.getNotifications( + userId, + query.page, + query.limit, + query.unreadOnly, + ); + } + + @Get('unread-count') + @ApiOperation({ summary: 'Get unread notifications count' }) + @ApiResponse({ + status: 200, + description: 'Returns the count of unread notifications', + schema: { + example: { + unreadCount: 5, + }, + }, + }) + async getUnreadCount(@CurrentUser('id') userId: number) { + const count = await this.notificationService.getUnreadCount(userId); + return { unreadCount: count }; + } + + @Patch(':id/read') + @ApiOperation({ summary: 'Mark a notification as read' }) + @ApiResponse({ + status: 200, + description: 'Notification marked as read', + }) + async markAsRead(@CurrentUser('id') userId: number, @Param('id') notificationId: string) { + await this.notificationService.markAsRead(notificationId, userId); + return { message: 'Notification marked as read' }; + } + + @Patch('read-all') + @ApiOperation({ summary: 'Mark all notifications as read' }) + @ApiResponse({ + status: 200, + description: 'All notifications marked as read', + }) + async markAllAsRead(@CurrentUser('id') userId: number) { + await this.notificationService.markAllAsRead(userId); + return { message: 'All notifications marked as read' }; + } + + @Post('device') + @ApiOperation({ summary: 'Register a device token for push notifications' }) + @ApiResponse({ + status: 201, + description: 'Device token registered successfully', + }) + async registerDevice(@CurrentUser('id') userId: number, @Body() dto: RegisterDeviceDto) { + await this.notificationService.registerDevice(userId, dto.token, dto.platform); + return { message: 'Device registered successfully' }; + } + + @Delete('device/:token') + @ApiOperation({ summary: 'Remove a device token' }) + @ApiResponse({ + status: 200, + description: 'Device token removed successfully', + }) + async removeDevice(@Param('token') token: string) { + await this.notificationService.removeDevice(token); + return { message: 'Device removed successfully' }; + } +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..7e023c3 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { NotificationService } from './notification.service'; +import { NotificationListener } from './events/notification.listener'; +import { NotificationsController } from './notifications.controller'; +import { Services } from 'src/utils/constants'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { FirebaseModule } from 'src/firebase/firebase.module'; + +@Module({ + imports: [PrismaModule, FirebaseModule], + controllers: [NotificationsController], + providers: [ + NotificationService, + { + provide: Services.NOTIFICATION, + useClass: NotificationService, + }, + NotificationListener, + ], + exports: [NotificationService, Services.NOTIFICATION], +}) +export class NotificationsModule {} diff --git a/src/post/services/like.service.ts b/src/post/services/like.service.ts index 5eec86e..a7250aa 100644 --- a/src/post/services/like.service.ts +++ b/src/post/services/like.service.ts @@ -1,12 +1,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { Services } from 'src/utils/constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from 'src/notifications/enums/notification.enum'; @Injectable() export class LikeService { constructor( @Inject(Services.PRISMA) private readonly prismaService: PrismaService, + private readonly eventEmitter: EventEmitter2, ) {} async togglePostLike(postId: number, userId: number) { @@ -31,6 +34,12 @@ export class LikeService { return { liked: false, message: 'Post unliked' }; } + // Fetch post to get author for notification + const post = await this.prismaService.post.findUnique({ + where: { id: postId }, + select: { user_id: true }, + }); + await this.prismaService.like.create({ data: { post_id: postId, @@ -38,6 +47,16 @@ export class LikeService { }, }); + // Emit notification event (don't notify yourself) + if (post && post.user_id !== userId) { + this.eventEmitter.emit('notification.create', { + type: NotificationType.LIKE, + recipientId: post.user_id, + actorId: userId, + postId, + }); + } + return { liked: true, message: 'Post liked' }; } diff --git a/src/post/services/mention.service.ts b/src/post/services/mention.service.ts index 68e7e41..2be0482 100644 --- a/src/post/services/mention.service.ts +++ b/src/post/services/mention.service.ts @@ -1,12 +1,15 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { Services } from 'src/utils/constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from 'src/notifications/enums/notification.enum'; @Injectable() export class MentionService { constructor( @Inject(Services.PRISMA) private readonly prismaService: PrismaService, + private readonly eventEmitter: EventEmitter2, ) {} private async checkUserExists(userId: number) { @@ -40,12 +43,32 @@ export class MentionService { await this.checkUserExists(userId); await this.checkPostExists(postId); - return this.prismaService.mention.create({ + const mention = await this.prismaService.mention.create({ data: { user_id: userId, post_id: postId, }, }); + + // Fetch post details for notification + const post = await this.prismaService.post.findUnique({ + where: { id: postId }, + select: { user_id: true, parent_id: true }, + }); + + // Emit notification event (don't notify yourself) + if (post && post.user_id !== userId) { + this.eventEmitter.emit('notification.create', { + type: NotificationType.MENTION, + recipientId: userId, + actorId: post.user_id, + postId, + replyId: post.parent_id ? postId : undefined, + threadPostId: post.parent_id || undefined, + }); + } + + return mention; } async getMentionedPosts(userId: number, page: number, limit: number) { diff --git a/src/post/services/post.service.ts b/src/post/services/post.service.ts index 23f67a9..1fe86b2 100644 --- a/src/post/services/post.service.ts +++ b/src/post/services/post.service.ts @@ -11,6 +11,8 @@ import { AiSummarizationService } from 'src/ai-integration/services/summarizatio import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { SummarizeJob } from 'src/common/interfaces/summarizeJob.interface'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from 'src/notifications/enums/notification.enum'; import { MLService } from './ml.service'; import { RawPost, TransformedPost } from '../interfaces/post.interface'; @@ -235,6 +237,7 @@ export class PostService { private readonly aiSummarizationService: AiSummarizationService, @InjectQueue(RedisQueues.postQueue.name) private readonly postQueue: Queue, + private readonly eventEmitter: EventEmitter2, ) { } private extractHashtags(content: string): string[] { @@ -385,6 +388,37 @@ export class PostService { })), }); + // Handle notifications after transaction + if (postData.parentId) { + // Fetch parent post to get author + const parentPost = await tx.post.findUnique({ + where: { id: postData.parentId }, + select: { user_id: true, type: true }, + }); + + if (parentPost && parentPost.user_id !== postData.userId) { + // Determine notification type based on post type + if (post.type === PostType.REPLY) { + this.eventEmitter.emit('notification.create', { + type: NotificationType.REPLY, + recipientId: parentPost.user_id, + actorId: postData.userId, + postId: postData.parentId, + replyId: post.id, + threadPostId: postData.parentId, + }); + } else if (post.type === PostType.QUOTE) { + this.eventEmitter.emit('notification.create', { + type: NotificationType.QUOTE, + recipientId: parentPost.user_id, + actorId: postData.userId, + quotePostId: post.id, + postId: postData.parentId, + }); + } + } + } + return { ...post, mediaUrls: mediaWithType.map((m) => m.url) }; }); } diff --git a/src/post/services/repost.service.ts b/src/post/services/repost.service.ts index 83200e7..f804228 100644 --- a/src/post/services/repost.service.ts +++ b/src/post/services/repost.service.ts @@ -1,12 +1,15 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { Services } from 'src/utils/constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from 'src/notifications/enums/notification.enum'; @Injectable() export class RepostService { constructor( @Inject(Services.PRISMA) private readonly prismaService: PrismaService, + private readonly eventEmitter: EventEmitter2, ) {} async toggleRepost(postId: number, userId: number) { @@ -22,10 +25,26 @@ export class RepostService { return { message: 'Repost removed' }; } else { + // Fetch post to get author for notification + const post = await tx.post.findUnique({ + where: { id: postId }, + select: { user_id: true }, + }); + await tx.repost.create({ data: { post_id: postId, user_id: userId }, }); + // Emit notification event (don't notify yourself) + if (post && post.user_id !== userId) { + this.eventEmitter.emit('notification.create', { + type: NotificationType.REPOST, + recipientId: post.user_id, + actorId: userId, + postId, + }); + } + return { message: 'Post reposted' }; } }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 4e883ae..8584a53 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -11,6 +11,8 @@ import { SuggestedUserDto } from './dto/suggested-users.dto'; import { INTEREST_SLUG_TO_ENUM, UserInterest } from './enums/user-interest.enum'; import { InterestDto, UserInterestDto } from './dto/interest.dto'; import { RedisService } from 'src/redis/redis.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationType } from 'src/notifications/enums/notification.enum'; @Injectable() export class UsersService { @@ -22,6 +24,7 @@ export class UsersService { private readonly prismaService: PrismaService, @Inject(Services.REDIS) private readonly redisService: RedisService, + private readonly eventEmitter: EventEmitter2, ) {} async followUser(followerId: number, followingId: number) { @@ -84,6 +87,14 @@ export class UsersService { }, }); await this.updateUserFollowingOnboarding(followerId); + + // Emit notification event + this.eventEmitter.emit('notification.create', { + type: NotificationType.FOLLOW, + recipientId: followingId, + actorId: followerId, + }); + return follow; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 699a605..17e81f4 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -27,6 +27,8 @@ export enum Services { AI_SUMMARIZATION = 'AI_SUMMARIZATION_SERVICE', QUEUE_CONSUMER = 'QUEUE_CONSUMER_SERVICE', ML = 'ML_SERVICE', + NOTIFICATION = 'NOTIFICATION_SERVICE', + FIREBASE = 'FIREBASE_SERVICE', } export enum RequestType {