From 21a8f093d1b62b3d75fa87a126d14e7cf5b91408 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sat, 14 Mar 2026 08:51:16 -0400 Subject: [PATCH] implemented all the new changes --- .dockerignore | 6 + Dockerfile | 27 + package-lock.json | 2275 ++++++++++++++++- package.json | 28 +- prisma/schema.prisma | 176 +- seed-demo.mjs | 407 +++ src/accounts/accounts.controller.ts | 23 +- src/accounts/accounts.service.ts | 63 +- src/app.module.ts | 73 +- src/auth/auth.controller.ts | 68 +- src/auth/auth.module.ts | 10 +- src/auth/auth.service.ts | 267 +- src/auth/dto/forgot-password.dto.ts | 6 + src/auth/dto/login.dto.ts | 18 +- src/auth/dto/register.dto.ts | 14 +- src/auth/dto/reset-password.dto.ts | 10 + src/auth/dto/update-profile.dto.ts | 24 +- src/auth/twofa/two-factor.controller.ts | 27 + src/auth/twofa/two-factor.module.ts | 10 + src/auth/twofa/two-factor.service.ts | 96 + src/common/common.module.ts | 9 + .../decorators/current-user.decorator.ts | 9 + src/common/decorators/public.decorator.ts | 4 + src/common/encryption.service.ts | 37 + src/common/guards/jwt-auth.guard.ts | 53 + src/common/sentry.filter.ts | 56 + src/config/env.validation.ts | 45 + src/email/email.module.ts | 9 + src/email/email.service.ts | 80 + src/exports/exports.controller.ts | 9 +- src/exports/exports.service.ts | 151 +- src/google/google.controller.ts | 33 + src/google/google.module.ts | 11 + src/google/google.service.ts | 94 + src/main.ts | 83 +- src/plaid/plaid.controller.ts | 15 +- src/plaid/plaid.service.ts | 100 +- src/rules/rules.controller.ts | 40 +- src/rules/rules.service.ts | 232 +- src/stripe/stripe.controller.ts | 56 + src/stripe/stripe.module.ts | 11 + src/stripe/stripe.service.ts | 128 + src/stripe/subscription.guard.ts | 46 + src/tax/dto/create-return.dto.ts | 20 +- src/tax/dto/update-return.dto.ts | 12 +- src/tax/tax.controller.ts | 26 +- src/tax/tax.module.ts | 2 +- src/tax/tax.service.ts | 127 +- .../dto/create-manual-transaction.dto.ts | 30 +- src/transactions/transactions.controller.ts | 130 +- src/transactions/transactions.service.ts | 311 ++- write-2fa-speakeasy.mjs | 322 +++ write-2fa.mjs | 150 ++ write-files.mjs | 295 +++ write-rules-tax.mjs | 414 +++ write-stripe-sheets.mjs | 429 ++++ write-transactions.mjs | 546 ++++ 57 files changed, 6997 insertions(+), 756 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 seed-demo.mjs create mode 100644 src/auth/dto/forgot-password.dto.ts create mode 100644 src/auth/dto/reset-password.dto.ts create mode 100644 src/auth/twofa/two-factor.controller.ts create mode 100644 src/auth/twofa/two-factor.module.ts create mode 100644 src/auth/twofa/two-factor.service.ts create mode 100644 src/common/common.module.ts create mode 100644 src/common/decorators/current-user.decorator.ts create mode 100644 src/common/decorators/public.decorator.ts create mode 100644 src/common/encryption.service.ts create mode 100644 src/common/guards/jwt-auth.guard.ts create mode 100644 src/common/sentry.filter.ts create mode 100644 src/config/env.validation.ts create mode 100644 src/email/email.module.ts create mode 100644 src/email/email.service.ts create mode 100644 src/google/google.controller.ts create mode 100644 src/google/google.module.ts create mode 100644 src/google/google.service.ts create mode 100644 src/stripe/stripe.controller.ts create mode 100644 src/stripe/stripe.module.ts create mode 100644 src/stripe/stripe.service.ts create mode 100644 src/stripe/subscription.guard.ts create mode 100644 write-2fa-speakeasy.mjs create mode 100644 write-2fa.mjs create mode 100644 write-files.mjs create mode 100644 write-rules-tax.mjs create mode 100644 write-stripe-sheets.mjs create mode 100644 write-transactions.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..01ac72b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +*.log +coverage +.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa8a617 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# ─── Stage 1: Build ────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --legacy-peer-deps + +COPY . . +RUN npx prisma generate +RUN npm run build + +# ─── Stage 2: Production ───────────────────────────────────────────────────── +FROM node:20-alpine AS production +WORKDIR /app + +ENV NODE_ENV=production + +COPY package*.json ./ +RUN npm ci --omit=dev --legacy-peer-deps + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/prisma ./prisma + +EXPOSE 3051 + +CMD ["node", "dist/main.js"] diff --git a/package-lock.json b/package-lock.json index 3aaa71b..9369310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,23 +10,47 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^10.3.4", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^10.3.4", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.3.4", + "@nestjs/swagger": "^11.2.6", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.18.0", + "@sentry/node": "^10.40.0", "@supabase/supabase-js": "^2.49.1", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.4", + "csv-parse": "^6.1.0", "dotenv": "^16.4.5", + "googleapis": "^171.4.0", + "helmet": "^8.1.0", + "joi": "^18.0.2", + "nestjs-pino": "^4.6.0", + "nodemailer": "^8.0.1", + "otplib": "^13.3.0", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "plaid": "^17.0.0", + "qrcode": "^1.5.4", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", + "stripe": "^20.4.0", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@nestjs/cli": "^10.3.2", "@nestjs/schematics": "^10.1.1", "@nestjs/testing": "^10.3.4", "@types/jest": "^29.5.12", + "@types/multer": "^2.0.0", "@types/node": "^20.11.20", + "@types/nodemailer": "^7.0.11", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^2.0.16", "jest": "^29.7.0", "prisma": "^5.18.0", @@ -792,11 +816,148 @@ "node": ">=0.1.90" } }, + "node_modules/@fastify/otel": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", + "integrity": "sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "minimatch": "^10.0.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", + "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@fastify/otel/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@fastify/otel/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@fastify/otel/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -814,7 +975,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -827,7 +987,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -840,14 +999,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -865,7 +1022,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -881,7 +1037,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -1385,6 +1540,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -1475,6 +1636,33 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.3.tgz", + "integrity": "sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==", + "license": "MIT", + "dependencies": { + "dotenv": "17.2.3", + "dotenv-expand": "12.0.3", + "lodash": "4.17.23" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", @@ -1526,6 +1714,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", + "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", @@ -1571,6 +1779,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/swagger": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz", + "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.0", + "js-yaml": "4.1.1", + "lodash": "4.17.23", + "path-to-regexp": "8.3.0", + "swagger-ui-dist": "5.31.0" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@nestjs/testing": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", @@ -1599,6 +1850,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1630,6 +1892,587 @@ "npm": ">=5.0.0" } }, + "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", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", + "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", + "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", + "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", + "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", + "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", + "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", + "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", + "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", + "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", + "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", + "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.0", + "@opentelemetry/instrumentation": "0.211.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", + "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", + "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", + "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", + "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", + "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", + "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", + "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", + "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", + "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", + "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", + "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", + "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", + "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", + "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", + "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", + "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", + "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@opentelemetry/resources": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@otplib/core": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.3.0.tgz", + "integrity": "sha512-pnQDOuCmFVeF/XnboJq9TOJgLoo2idNPJKMymOF8vGqJJ+ReKRYM9bUGjNPRWC0tHjMwu1TXbnzyBp494JgRag==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.3.0.tgz", + "integrity": "sha512-XJMZGz2bg4QJwK7ulvl1GUI2VMn/flaIk/E/BTKAejHsX2kUtPF1bRhlZ2+elq8uU5Fs9Z9FHcQK2CPZNQbbUQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@otplib/uri": "13.3.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.3.0.tgz", + "integrity": "sha512-/jYbL5S6GB0Ie3XGEWtLIr9s5ZICl/BfmNL7+8/W7usZaUU4GiyLd2S+JGsNCslPyqNekSudD864nDAvRI0s8w==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.3.0.tgz", + "integrity": "sha512-wmV+jBVncepgwv99G7Plrdzd0tHfbpXk2U+OD7MO7DzpDqOYEgOPi+IIneksJSTL8QvWdfi+uQEuhnER4fKouA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.3.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/totp": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.3.0.tgz", + "integrity": "sha512-XfjGNoN8d9S3Ove2j7AwkVV7+QDFsV7Lm7YwSiezNaHffkWtJ60aJYpmf+01dARdPST71U2ptueMsRJso4sq4A==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@otplib/hotp": "13.3.0", + "@otplib/uri": "13.3.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.3.0.tgz", + "integrity": "sha512-3oh6nBXy+cm3UX9cxEAGZiDrfxHU2gfelYFV+XNCx+8dq39VaQVymwlU2yjPZiMAi/3agaUeEftf2RwM5F+Cyg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1640,11 +2483,16 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -1673,14 +2521,14 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1694,14 +2542,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -1713,12 +2561,189 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" } }, + "node_modules/@prisma/instrumentation": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", + "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", + "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sentry/core": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.40.0.tgz", + "integrity": "sha512-/wrcHPp9Avmgl6WBimPjS4gj810a1wU5oX9fF1bzJfeIIbF3jTsAbv0oMbgDp0cSDnkwv2+NvcPnn3+c5J6pBA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.40.0.tgz", + "integrity": "sha512-HQETLoNZTUUM8PBxFPT4X0qepzk5NcyWg3jyKUmF7Hh/19KSJItBXXZXxx+8l3PC2eASXUn70utXi65PoXEHWA==", + "license": "MIT", + "dependencies": { + "@fastify/otel": "0.16.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.5.1", + "@opentelemetry/core": "^2.5.1", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/instrumentation-amqplib": "0.58.0", + "@opentelemetry/instrumentation-connect": "0.54.0", + "@opentelemetry/instrumentation-dataloader": "0.28.0", + "@opentelemetry/instrumentation-express": "0.59.0", + "@opentelemetry/instrumentation-fs": "0.30.0", + "@opentelemetry/instrumentation-generic-pool": "0.54.0", + "@opentelemetry/instrumentation-graphql": "0.58.0", + "@opentelemetry/instrumentation-hapi": "0.57.0", + "@opentelemetry/instrumentation-http": "0.211.0", + "@opentelemetry/instrumentation-ioredis": "0.59.0", + "@opentelemetry/instrumentation-kafkajs": "0.20.0", + "@opentelemetry/instrumentation-knex": "0.55.0", + "@opentelemetry/instrumentation-koa": "0.59.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", + "@opentelemetry/instrumentation-mongodb": "0.64.0", + "@opentelemetry/instrumentation-mongoose": "0.57.0", + "@opentelemetry/instrumentation-mysql": "0.57.0", + "@opentelemetry/instrumentation-mysql2": "0.57.0", + "@opentelemetry/instrumentation-pg": "0.63.0", + "@opentelemetry/instrumentation-redis": "0.59.0", + "@opentelemetry/instrumentation-tedious": "0.30.0", + "@opentelemetry/instrumentation-undici": "0.21.0", + "@opentelemetry/resources": "^2.5.1", + "@opentelemetry/sdk-trace-base": "^2.5.1", + "@opentelemetry/semantic-conventions": "^1.39.0", + "@prisma/instrumentation": "7.2.0", + "@sentry/core": "10.40.0", + "@sentry/node-core": "10.40.0", + "@sentry/opentelemetry": "10.40.0", + "import-in-the-middle": "^2.0.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.40.0.tgz", + "integrity": "sha512-ciZGOF54rJH9Fkg7V3v4gmWVufnJRqQQOrn0KStuo49vfPQAJLGePDx+crQv0iNVoLc6Hmrr6E7ebNHSb4NSAw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.40.0", + "@sentry/opentelemetry": "10.40.0", + "import-in-the-middle": "^2.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.40.0.tgz", + "integrity": "sha512-Zx6T258qlEhQfdghIlazSTbK7uRO0pXWw4/4/VPR8pMOiRPh8dAoJg8AB0L55PYPMpVdXxNf7L9X0EZoDYibJw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.40.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1746,6 +2771,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.90.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz", @@ -1918,6 +2949,26 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "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/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -1954,6 +3005,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1964,6 +3040,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-errors": { + "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": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2025,6 +3108,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -2034,12 +3136,127 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, "node_modules/@types/phoenix": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "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/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": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2070,6 +3287,21 @@ "@types/superagent": "*" } }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2288,7 +3520,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2297,6 +3528,24 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -2375,7 +3624,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2433,7 +3681,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -2462,6 +3709,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -2603,14 +3859,18 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", "license": "MIT" }, "node_modules/base64-js": { "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", @@ -2643,6 +3903,15 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "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/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2892,7 +4161,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3010,6 +4278,23 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3141,6 +4426,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3341,7 +4632,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3352,6 +4642,30 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz", + "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3361,6 +4675,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -3476,6 +4799,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3488,6 +4817,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3506,7 +4850,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -3548,7 +4891,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -3560,6 +4902,15 @@ "node": ">= 0.8" } }, + "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", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -3851,6 +5202,12 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "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/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -3866,6 +5223,12 @@ "node": ">=4" } }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3896,6 +5259,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -3971,7 +5357,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -4005,7 +5390,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -4063,6 +5447,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -4090,6 +5486,12 @@ "node": ">= 0.6" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -4132,6 +5534,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4151,6 +5554,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4165,7 +5615,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4235,7 +5684,6 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -4276,7 +5724,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4286,7 +5733,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -4298,6 +5744,82 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4420,6 +5942,21 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4447,6 +5984,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4515,6 +6088,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + } + }, + "node_modules/import-in-the-middle/node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -4649,7 +6240,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4728,7 +6318,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4850,7 +6439,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -5596,6 +7184,33 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", + "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5607,7 +7222,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5629,6 +7243,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-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5745,6 +7368,12 @@ "node": ">=6" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.37", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.37.tgz", + "integrity": "sha512-rDU6bkpuMs8YRt/UpkuYEAsYSoNuDEbrE41I3KNvmXREGH6DGBJ8Wbak4by29wNOQ27zk4g4HL82zf0OGhwRuw==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5770,7 +7399,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -5780,10 +7408,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.includes": { @@ -5856,7 +7483,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/magic-string": { @@ -6057,7 +7683,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -6075,6 +7700,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -6129,6 +7760,21 @@ "dev": true, "license": "MIT" }, + "node_modules/nestjs-pino": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.6.0.tgz", + "integrity": "sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==", + "license": "MIT", + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "pino": "^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "pino-http": "^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -6136,6 +7782,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -6180,6 +7846,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -6224,6 +7899,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6240,7 +7924,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6296,6 +7979,20 @@ "node": ">=0.10.0" } }, + "node_modules/otplib": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.3.0.tgz", + "integrity": "sha512-VYMKyyDG8yt2q+z58sz54/EIyTh7+tyMrjeemR44iVh5+dkKtIs57irTqxjH+IkAL1uMmG1JIFhG5CxTpqdU5g==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@otplib/hotp": "13.3.0", + "@otplib/plugin-base32-scure": "13.3.0", + "@otplib/plugin-crypto-noble": "13.3.0", + "@otplib/totp": "13.3.0", + "@otplib/uri": "13.3.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6316,7 +8013,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6329,7 +8025,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6345,7 +8040,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6355,7 +8049,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -6403,7 +8096,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6423,7 +8115,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6440,7 +8131,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -6469,6 +8159,37 @@ "node": ">=8" } }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6489,6 +8210,91 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", + "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^10.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -6534,6 +8340,54 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -6566,7 +8420,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -6582,6 +8436,22 @@ "fsevents": "2.3.3" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6615,6 +8485,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6642,6 +8522,75 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -6657,6 +8606,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -6738,6 +8693,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", @@ -6758,7 +8722,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6774,6 +8737,48 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/require-in-the-middle/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/require-in-the-middle/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6859,6 +8864,21 @@ "dev": true, "license": "ISC" }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -6898,6 +8918,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6957,6 +8986,22 @@ "dev": true, "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -7024,6 +9069,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7052,7 +9103,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7065,7 +9115,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7147,7 +9196,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -7173,6 +9221,15 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -7204,6 +9261,27 @@ "node": ">=0.10.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -7278,7 +9356,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7294,7 +9371,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7309,7 +9385,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7323,7 +9398,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7365,6 +9439,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -7490,6 +9581,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -7632,6 +9747,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -7978,6 +10105,12 @@ "punycode": "^2.1.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8008,6 +10141,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8051,6 +10193,15 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -8138,7 +10289,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8150,6 +10300,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -8161,7 +10317,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8177,7 +10332,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8195,7 +10349,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 27f116e..a517845 100644 --- a/package.json +++ b/package.json @@ -14,23 +14,47 @@ }, "dependencies": { "@nestjs/common": "^10.3.4", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^10.3.4", - "@nestjs/platform-express": "^10.3.4", "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.3.4", + "@nestjs/swagger": "^11.2.6", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.18.0", + "@sentry/node": "^10.40.0", "@supabase/supabase-js": "^2.49.1", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.4", + "csv-parse": "^6.1.0", "dotenv": "^16.4.5", + "googleapis": "^171.4.0", + "helmet": "^8.1.0", + "joi": "^18.0.2", + "nestjs-pino": "^4.6.0", + "nodemailer": "^8.0.1", + "otplib": "^13.3.0", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "plaid": "^17.0.0", + "qrcode": "^1.5.4", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", + "stripe": "^20.4.0", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@nestjs/cli": "^10.3.2", "@nestjs/schematics": "^10.1.1", "@nestjs/testing": "^10.3.4", "@types/jest": "^29.5.12", + "@types/multer": "^2.0.0", "@types/node": "^20.11.20", + "@types/nodemailer": "^7.0.11", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^2.0.16", "jest": "^29.7.0", "prisma": "^5.18.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 69e9a8e..fc0f22b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,99 +8,108 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - passwordHash String - fullName String? - phone String? - companyName String? - addressLine1 String? - addressLine2 String? - city String? - state String? - postalCode String? - country String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + email String @unique + passwordHash String + fullName String? + phone String? + companyName String? + addressLine1 String? + addressLine2 String? + city String? + state String? + postalCode String? + country String? + emailVerified Boolean @default(false) + twoFactorEnabled Boolean @default(false) + twoFactorSecret String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - accounts Account[] - rules Rule[] - exports ExportLog[] - auditLogs AuditLog[] + accounts Account[] + rules Rule[] + exports ExportLog[] + auditLogs AuditLog[] + googleConnection GoogleConnection? + emailVerificationToken EmailVerificationToken? + passwordResetTokens PasswordResetToken[] + refreshTokens RefreshToken[] + subscription Subscription? + taxReturns TaxReturn[] } model Account { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String institutionName String accountType String mask String? plaidAccessToken String? plaidItemId String? - plaidAccountId String? @unique + plaidAccountId String? @unique currentBalance Decimal? availableBalance Decimal? isoCurrencyCode String? lastBalanceSync DateTime? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) transactionsRaw TransactionRaw[] } model TransactionRaw { - id String @id @default(uuid()) + id String @id @default(uuid()) accountId String - bankTransactionId String @unique + bankTransactionId String @unique date DateTime amount Decimal description String rawPayload Json - ingestedAt DateTime @default(now()) + ingestedAt DateTime @default(now()) source String - account Account @relation(fields: [accountId], references: [id]) + account Account @relation(fields: [accountId], references: [id]) derived TransactionDerived? ruleExecutions RuleExecution[] } model TransactionDerived { - id String @id @default(uuid()) - rawTransactionId String @unique + id String @id @default(uuid()) + rawTransactionId String @unique userCategory String? userNotes String? - isHidden Boolean @default(false) - modifiedAt DateTime @default(now()) + isHidden Boolean @default(false) + modifiedAt DateTime @default(now()) modifiedBy String raw TransactionRaw @relation(fields: [rawTransactionId], references: [id]) } model Rule { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String name String priority Int conditions Json actions Json - isActive Boolean @default(true) - createdAt DateTime @default(now()) + isActive Boolean @default(true) + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) executions RuleExecution[] } model RuleExecution { - id String @id @default(uuid()) + id String @id @default(uuid()) ruleId String transactionId String - executedAt DateTime @default(now()) + executedAt DateTime @default(now()) result Json - rule Rule @relation(fields: [ruleId], references: [id]) - transaction TransactionRaw @relation(fields: [transactionId], references: [id]) + rule Rule @relation(fields: [ruleId], references: [id]) + transaction TransactionRaw @relation(fields: [transactionId], references: [id]) } model ExportLog { @@ -122,3 +131,90 @@ model AuditLog { user User @relation(fields: [userId], references: [id]) } + +model GoogleConnection { + id String @id @default(uuid()) + userId String @unique + googleEmail String + refreshToken String + accessToken String? + spreadsheetId String? + isConnected Boolean @default(true) + connectedAt DateTime @default(now()) + lastSyncedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model EmailVerificationToken { + id String @id @default(uuid()) + userId String @unique + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model PasswordResetToken { + id String @id @default(uuid()) + userId String + token String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model RefreshToken { + id String @id @default(uuid()) + userId String + tokenHash String @unique + expiresAt DateTime + revokedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model Subscription { + id String @id @default(uuid()) + userId String @unique + plan String @default("free") + stripeCustomerId String? + stripeSubId String? + currentPeriodEnd DateTime? + cancelAtPeriodEnd Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model TaxReturn { + id String @id @default(uuid()) + userId String + taxYear Int + filingType String + jurisdictions Json + status String @default("draft") + summary Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + documents TaxDocument[] +} + +model TaxDocument { + id String @id @default(uuid()) + taxReturnId String + docType String + metadata Json @default("{}") + createdAt DateTime @default(now()) + + taxReturn TaxReturn @relation(fields: [taxReturnId], references: [id], onDelete: Cascade) +} diff --git a/seed-demo.mjs b/seed-demo.mjs new file mode 100644 index 0000000..e4668f2 --- /dev/null +++ b/seed-demo.mjs @@ -0,0 +1,407 @@ +/** + * LedgerOne Demo Account Seed Script + * Creates a fully-populated demo account for testing all features. + * + * Usage: node seed-demo.mjs + * Creds: demo@ledgerone.app / Demo1234! + */ + +import { PrismaClient } from "@prisma/client"; +import * as crypto from "crypto"; + +const prisma = new PrismaClient(); + +const DEMO_EMAIL = "demo@ledgerone.app"; +const DEMO_PASSWORD = "Demo1234!"; + +function hashPassword(password) { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); + return `${salt}:${hash}`; +} + +function daysAgo(n) { + const d = new Date(); + d.setHours(12, 0, 0, 0); + d.setDate(d.getDate() - n); + return d; +} + +async function main() { + console.log("🧹 Cleaning up existing demo account..."); + + const existing = await prisma.user.findUnique({ where: { email: DEMO_EMAIL } }); + if (existing) { + const uid = existing.id; + // Delete leaf models first, then parents + await prisma.auditLog.deleteMany({ where: { userId: uid } }); + await prisma.refreshToken.deleteMany({ where: { userId: uid } }); + await prisma.emailVerificationToken.deleteMany({ where: { userId: uid } }); + await prisma.passwordResetToken.deleteMany({ where: { userId: uid } }); + await prisma.subscription.deleteMany({ where: { userId: uid } }); + await prisma.exportLog.deleteMany({ where: { userId: uid } }); + await prisma.googleConnection.deleteMany({ where: { userId: uid } }); + + const taxReturns = await prisma.taxReturn.findMany({ where: { userId: uid } }); + for (const tr of taxReturns) { + await prisma.taxDocument.deleteMany({ where: { taxReturnId: tr.id } }); + } + await prisma.taxReturn.deleteMany({ where: { userId: uid } }); + + const accounts = await prisma.account.findMany({ where: { userId: uid } }); + for (const account of accounts) { + const txRaws = await prisma.transactionRaw.findMany({ where: { accountId: account.id } }); + for (const tx of txRaws) { + await prisma.ruleExecution.deleteMany({ where: { transactionId: tx.id } }); + await prisma.transactionDerived.deleteMany({ where: { rawTransactionId: tx.id } }); + } + await prisma.transactionRaw.deleteMany({ where: { accountId: account.id } }); + } + await prisma.account.deleteMany({ where: { userId: uid } }); + + const rules = await prisma.rule.findMany({ where: { userId: uid } }); + for (const rule of rules) { + await prisma.ruleExecution.deleteMany({ where: { ruleId: rule.id } }); + } + await prisma.rule.deleteMany({ where: { userId: uid } }); + + await prisma.user.delete({ where: { id: uid } }); + console.log(" ✓ Removed previous demo account"); + } + + // ── User ────────────────────────────────────────────────────────────────── + console.log("\n👤 Creating demo user..."); + const user = await prisma.user.create({ + data: { + email: DEMO_EMAIL, + passwordHash: hashPassword(DEMO_PASSWORD), + fullName: "Alex Chen", + emailVerified: true, // skip email verification step + companyName: "LedgerOne Demo Corp", + city: "San Francisco", + state: "CA", + country: "US", + }, + }); + console.log(` ✓ ${user.email} (id: ${user.id})`); + + // ── Subscription ────────────────────────────────────────────────────────── + await prisma.subscription.create({ + data: { + userId: user.id, + plan: "pro", + currentPeriodEnd: new Date(Date.now() + 30 * 86_400_000), + cancelAtPeriodEnd: false, + }, + }); + console.log(" ✓ Pro subscription"); + + // ── Accounts ────────────────────────────────────────────────────────────── + console.log("\n🏦 Creating accounts..."); + const checking = await prisma.account.create({ + data: { + userId: user.id, + institutionName: "Chase Bank", + accountType: "checking", + mask: "4521", + currentBalance: 12847.53, + availableBalance: 12347.53, + isoCurrencyCode: "USD", + isActive: true, + }, + }); + const credit = await prisma.account.create({ + data: { + userId: user.id, + institutionName: "American Express", + accountType: "credit", + mask: "2834", + currentBalance: -2341.88, // negative = you owe this + availableBalance: 7658.12, + isoCurrencyCode: "USD", + isActive: true, + }, + }); + const savings = await prisma.account.create({ + data: { + userId: user.id, + institutionName: "Ally Bank", + accountType: "savings", + mask: "9012", + currentBalance: 28500.00, + availableBalance: 28500.00, + isoCurrencyCode: "USD", + isActive: true, + }, + }); + console.log(" ✓ Chase Checking *4521"); + console.log(" ✓ Amex Credit *2834"); + console.log(" ✓ Ally Savings *9012"); + + // ── Transactions ────────────────────────────────────────────────────────── + // Convention: positive = money leaving (expense), negative = money entering (income/deposit) + const txDataset = [ + // ── INCOME (checking) ── + { acct: checking, d: 2, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" }, + { acct: checking, d: 32, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" }, + { acct: checking, d: 62, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" }, + { acct: checking, d: 92, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" }, + { acct: checking, d: 122, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" }, + { acct: checking, d: 152, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" }, + { acct: savings, d: 5, amt: -200.00, desc: "INTEREST EARNED - ALLY BANK", cat: "Income" }, + { acct: savings, d: 35, amt: -194.50, desc: "INTEREST EARNED - ALLY BANK", cat: "Income" }, + { acct: savings, d: 65, amt: -201.20, desc: "INTEREST EARNED - ALLY BANK", cat: "Income" }, + + // ── RENT (checking) ── + { acct: checking, d: 5, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" }, + { acct: checking, d: 35, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" }, + { acct: checking, d: 65, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" }, + { acct: checking, d: 95, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" }, + { acct: checking, d: 125, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" }, + { acct: checking, d: 155, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" }, + + // ── UTILITIES (checking) ── + { acct: checking, d: 8, amt: 94.50, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" }, + { acct: checking, d: 38, amt: 87.20, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" }, + { acct: checking, d: 68, amt: 112.80, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" }, + { acct: checking, d: 98, amt: 103.40, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" }, + { acct: checking, d: 10, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" }, + { acct: checking, d: 40, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" }, + { acct: checking, d: 70, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" }, + { acct: checking, d: 100, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" }, + + // ── GROCERIES (checking) ── + { acct: checking, d: 3, amt: 127.43, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" }, + { acct: checking, d: 11, amt: 89.22, desc: "TRADER JOE S #456 SF", cat: "Groceries" }, + { acct: checking, d: 18, amt: 145.67, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" }, + { acct: checking, d: 25, amt: 73.11, desc: "SAFEWAY #789", cat: "Groceries" }, + { acct: checking, d: 33, amt: 118.54, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" }, + { acct: checking, d: 45, amt: 92.30, desc: "TRADER JOE S #456 SF", cat: "Groceries" }, + { acct: checking, d: 55, amt: 131.20, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" }, + { acct: checking, d: 70, amt: 85.75, desc: "SAFEWAY #789", cat: "Groceries" }, + { acct: checking, d: 82, amt: 104.88, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" }, + { acct: checking, d: 105, amt: 76.42, desc: "TRADER JOE S #456 SF", cat: "Groceries" }, + + // ── HEALTHCARE (checking) ── + { acct: checking, d: 15, amt: 30.00, desc: "CVS PHARMACY #1122", cat: "Healthcare" }, + { acct: checking, d: 60, amt: 250.00, desc: "KAISER PERMANENTE COPAY", cat: "Healthcare" }, + { acct: checking, d: 110, amt: 45.00, desc: "CVS PHARMACY #1122", cat: "Healthcare" }, + { acct: checking, d: 140, amt: 180.00, desc: "UCSF MEDICAL CENTER", cat: "Healthcare" }, + + // ── DINING (credit) ── + { acct: credit, d: 1, amt: 68.40, desc: "NOBU RESTAURANT SF", cat: "Dining & Restaurants" }, + { acct: credit, d: 4, amt: 14.50, desc: "STARBUCKS #3421 SAN FRANCISCO CA", cat: "Dining & Restaurants" }, + { acct: credit, d: 7, amt: 42.80, desc: "CHIPOTLE MEXICAN GRILL", cat: "Dining & Restaurants" }, + { acct: credit, d: 13, amt: 89.20, desc: "MOURAD RESTAURANT SF", cat: "Dining & Restaurants" }, + { acct: credit, d: 16, amt: 23.60, desc: "SWEETGREEN #55 SF", cat: "Dining & Restaurants" }, + { acct: credit, d: 22, amt: 112.50, desc: "BENU RESTAURANT SAN FRANCISCO", cat: "Dining & Restaurants" }, + { acct: credit, d: 29, amt: 18.90, desc: "STARBUCKS #3421 SAN FRANCISCO CA", cat: "Dining & Restaurants" }, + { acct: credit, d: 36, amt: 55.30, desc: "IN-N-OUT BURGER #77", cat: "Dining & Restaurants" }, + { acct: credit, d: 48, amt: 78.10, desc: "NOBU RESTAURANT SF", cat: "Dining & Restaurants" }, + { acct: credit, d: 75, amt: 32.40, desc: "CHIPOTLE MEXICAN GRILL", cat: "Dining & Restaurants" }, + { acct: credit, d: 90, amt: 44.20, desc: "THE FRENCH LAUNDRY YOUNTVILLE", cat: "Dining & Restaurants" }, + + // ── SUBSCRIPTIONS (credit) ── + { acct: credit, d: 9, amt: 15.99, desc: "NETFLIX.COM SUBSCRIPTION", cat: "Subscriptions" }, + { acct: credit, d: 9, amt: 9.99, desc: "SPOTIFY PREMIUM", cat: "Subscriptions" }, + { acct: credit, d: 9, amt: 14.99, desc: "OPENAI CHATGPT PLUS", cat: "Subscriptions" }, + { acct: credit, d: 9, amt: 19.99, desc: "GITHUB COPILOT SUBSCRIPTION", cat: "Subscriptions" }, + { acct: credit, d: 39, amt: 15.99, desc: "NETFLIX.COM SUBSCRIPTION", cat: "Subscriptions" }, + { acct: credit, d: 39, amt: 9.99, desc: "SPOTIFY PREMIUM", cat: "Subscriptions" }, + { acct: credit, d: 39, amt: 14.99, desc: "OPENAI CHATGPT PLUS", cat: "Subscriptions" }, + { acct: credit, d: 39, amt: 19.99, desc: "GITHUB COPILOT SUBSCRIPTION", cat: "Subscriptions" }, + { acct: credit, d: 69, amt: 15.99, desc: "NETFLIX.COM SUBSCRIPTION", cat: "Subscriptions" }, + { acct: credit, d: 69, amt: 9.99, desc: "SPOTIFY PREMIUM", cat: "Subscriptions" }, + + // ── ENTERTAINMENT (credit) ── + { acct: credit, d: 20, amt: 24.99, desc: "AMC THEATRES TICKET SAN FRANCISCO", cat: "Entertainment" }, + { acct: credit, d: 85, amt: 189.00, desc: "GOLDEN STATE WARRIORS - CHASE CENTER", cat: "Entertainment" }, + { acct: credit, d: 130, amt: 95.00, desc: "TICKETMASTER CONCERT TICKETS", cat: "Entertainment" }, + + // ── TRANSPORTATION (credit) ── + { acct: credit, d: 2, amt: 18.40, desc: "UBER TRIP SAN FRANCISCO CA", cat: "Transportation" }, + { acct: credit, d: 6, amt: 22.10, desc: "UBER TRIP SAN FRANCISCO CA", cat: "Transportation" }, + { acct: credit, d: 13, amt: 15.80, desc: "LYFT RIDE SAN FRANCISCO", cat: "Transportation" }, + { acct: credit, d: 27, amt: 89.00, desc: "SHELL OIL GAS STATION #4421", cat: "Transportation" }, + { acct: credit, d: 48, amt: 76.50, desc: "SHELL OIL GAS STATION #4421", cat: "Transportation" }, + { acct: credit, d: 58, amt: 21.60, desc: "UBER TRIP SAN FRANCISCO CA", cat: "Transportation" }, + { acct: credit, d: 72, amt: 88.00, desc: "BART CLIPPER CARD RELOAD", cat: "Transportation" }, + + // ── SHOPPING (credit) ── + { acct: credit, d: 14, amt: 243.50, desc: "AMAZON.COM PURCHASE", cat: "Shopping" }, + { acct: credit, d: 30, amt: 89.99, desc: "AMAZON.COM PURCHASE", cat: "Shopping" }, + { acct: credit, d: 53, amt: 1450.00, desc: "APPLE.COM/BILL - IPAD PRO", cat: "Shopping" }, + { acct: credit, d: 66, amt: 178.00, desc: "NORDSTROM #0234 SF", cat: "Shopping" }, + { acct: credit, d: 90, amt: 340.00, desc: "BEST BUY #1234 DALY CITY", cat: "Shopping" }, + { acct: credit, d: 115, amt: 67.50, desc: "AMAZON.COM PURCHASE", cat: "Shopping" }, + + // ── CREDIT CARD PAYMENTS (checking) ── + { acct: checking, d: 20, amt: 1800.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" }, + { acct: checking, d: 50, amt: 2100.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" }, + { acct: checking, d: 80, amt: 1650.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" }, + { acct: checking, d: 110, amt: 1950.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" }, + + // ── SAVINGS TRANSFERS (checking → savings) ── + { acct: checking, d: 3, amt: 500.00, desc: "TRANSFER TO ALLY SAVINGS *9012", cat: "Transfer" }, + { acct: checking, d: 33, amt: 500.00, desc: "TRANSFER TO ALLY SAVINGS *9012", cat: "Transfer" }, + { acct: checking, d: 63, amt: 500.00, desc: "TRANSFER TO ALLY SAVINGS *9012", cat: "Transfer" }, + ]; + + console.log("\n💳 Creating transactions..."); + let txCount = 0; + for (const [i, tx] of txDataset.entries()) { + const raw = await prisma.transactionRaw.create({ + data: { + accountId: tx.acct.id, + bankTransactionId: `demo-${user.id.slice(0, 8)}-${String(i).padStart(3, "0")}`, + date: daysAgo(tx.d), + amount: tx.amt, + description: tx.desc, + source: "manual", + rawPayload: { demo: true }, + }, + }); + await prisma.transactionDerived.create({ + data: { + rawTransactionId: raw.id, + userCategory: tx.cat, + isHidden: false, + modifiedBy: user.id, + }, + }); + txCount++; + } + console.log(` ✓ ${txCount} transactions created`); + + // ── Categorization Rules ────────────────────────────────────────────────── + console.log("\n📋 Creating categorization rules..."); + const rules = [ + { + name: "Whole Foods → Groceries", + priority: 10, + conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "WHOLE FOODS" }] }, + actions: [{ type: "setCategory", value: "Groceries" }], + }, + { + name: "Trader Joe's → Groceries", + priority: 10, + conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "TRADER JOE" }] }, + actions: [{ type: "setCategory", value: "Groceries" }], + }, + { + name: "Netflix → Subscriptions", + priority: 10, + conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "NETFLIX" }] }, + actions: [{ type: "setCategory", value: "Subscriptions" }], + }, + { + name: "Uber/Lyft → Transportation", + priority: 10, + conditions: { operator: "OR", filters: [ + { field: "description", op: "contains", value: "UBER" }, + { field: "description", op: "contains", value: "LYFT" }, + ]}, + actions: [{ type: "setCategory", value: "Transportation" }], + }, + { + name: "Amazon → Shopping", + priority: 10, + conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "AMAZON" }] }, + actions: [{ type: "setCategory", value: "Shopping" }], + }, + { + name: "Payroll → Income", + priority: 20, + conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "PAYROLL" }] }, + actions: [{ type: "setCategory", value: "Income" }], + }, + { + name: "Starbucks → Dining", + priority: 10, + conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "STARBUCKS" }] }, + actions: [{ type: "setCategory", value: "Dining & Restaurants" }], + }, + { + name: "Large purchase note (>$500)", + priority: 5, + conditions: { operator: "AND", filters: [{ field: "amount", op: "gt", value: 500 }] }, + actions: [{ type: "addNote", value: "Large purchase — review for tax deduction" }], + }, + ]; + + for (const rule of rules) { + await prisma.rule.create({ + data: { + userId: user.id, + name: rule.name, + priority: rule.priority, + conditions: rule.conditions, + actions: rule.actions, + isActive: true, + }, + }); + } + console.log(` ✓ ${rules.length} rules created`); + + // ── Draft Tax Return ────────────────────────────────────────────────────── + console.log("\n📄 Creating draft tax return..."); + await prisma.taxReturn.create({ + data: { + userId: user.id, + taxYear: 2025, + filingType: "individual", + jurisdictions: ["federal", "CA"], + status: "draft", + summary: { + totalIncome: 69600, + totalExpenses: 42580, + netIncome: 27020, + categories: { + "Income": 69600.00, + "Rent & Mortgage": 13200.00, + "Groceries": 1044.52, + "Dining & Restaurants": 679.90, + "Transportation": 411.40, + "Subscriptions": 165.93, + "Shopping": 2469.99, + "Healthcare": 505.00, + "Utilities": 755.90, + "Entertainment": 308.99, + "Transfer": 10500.00, + }, + }, + }, + }); + console.log(" ✓ Draft 2025 tax return"); + + // ── Audit Log ───────────────────────────────────────────────────────────── + await prisma.auditLog.create({ + data: { userId: user.id, action: "auth.register", metadata: { email: DEMO_EMAIL, source: "seed-script" } }, + }); + + // ── Summary ─────────────────────────────────────────────────────────────── + console.log(` +╔══════════════════════════════════════════════╗ +║ Demo Account Ready ║ +╠══════════════════════════════════════════════╣ +║ Email: demo@ledgerone.app ║ +║ Password: Demo1234! ║ +╠══════════════════════════════════════════════╣ +║ Features populated: ║ +║ ✓ Email verified (no confirmation needed) ║ +║ ✓ Pro subscription (30-day period) ║ +║ ✓ 3 accounts (checking/credit/savings) ║ +║ ✓ ${String(txCount).padEnd(3)} transactions (6 months of data) ║ +║ ✓ ${String(rules.length).padEnd(3)} categorization rules ║ +║ ✓ Draft 2025 tax return ║ +║ ✓ 2FA disabled (enable via Settings → 2FA) ║ +╚══════════════════════════════════════════════╝ +`); +} + +main() + .catch((e) => { + console.error("❌ Seed failed:", e.message); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/accounts/accounts.controller.ts b/src/accounts/accounts.controller.ts index e1ec5a1..02692a3 100644 --- a/src/accounts/accounts.controller.ts +++ b/src/accounts/accounts.controller.ts @@ -1,35 +1,40 @@ import { Body, Controller, Get, Post, Query } from "@nestjs/common"; import { ok } from "../common/response"; import { AccountsService } from "./accounts.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; @Controller("accounts") export class AccountsController { constructor(private readonly accountsService: AccountsService) {} @Get() - async list(@Query("user_id") userId?: string) { - const data = await this.accountsService.list(userId); + async list( + @CurrentUser() userId: string, + @Query("page") page = 1, + @Query("limit") limit = 20, + ) { + const data = await this.accountsService.list(userId, +page, +limit); return ok(data); } @Post("link") - async link() { - const data = await this.accountsService.createLinkToken(); + async link(@CurrentUser() userId: string) { + const data = await this.accountsService.createLinkToken(userId); return ok(data); } @Post("manual") async manual( - @Body() - payload: { userId: string; institutionName: string; accountType: string; mask?: string } + @CurrentUser() userId: string, + @Body() payload: { institutionName: string; accountType: string; mask?: string }, ) { - const data = await this.accountsService.createManualAccount(payload.userId, payload); + const data = await this.accountsService.createManualAccount(userId, payload); return ok(data); } @Post("balances") - async balances(@Body() payload: { userId: string }) { - const data = await this.accountsService.refreshBalances(payload.userId); + async balances(@CurrentUser() userId: string) { + const data = await this.accountsService.refreshBalances(userId); return ok(data); } } diff --git a/src/accounts/accounts.service.ts b/src/accounts/accounts.service.ts index 6fffecb..031fe65 100644 --- a/src/accounts/accounts.service.ts +++ b/src/accounts/accounts.service.ts @@ -2,44 +2,71 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { PlaidService } from "../plaid/plaid.service"; +const MAX_PAGE_SIZE = 100; + @Injectable() export class AccountsService { constructor( private readonly prisma: PrismaService, - private readonly plaidService: PlaidService + private readonly plaidService: PlaidService, ) {} - async list(userId?: string) { - if (!userId) { - return []; - } - return this.prisma.account.findMany({ - where: { userId }, - orderBy: { createdAt: "desc" } - }); + async list(userId: string, page = 1, limit = 20) { + const take = Math.min(limit, MAX_PAGE_SIZE); + const skip = (page - 1) * take; + const [accounts, total] = await Promise.all([ + this.prisma.account.findMany({ + where: { userId, isActive: true }, + orderBy: { createdAt: "desc" }, + skip, + take, + select: { + id: true, + institutionName: true, + accountType: true, + mask: true, + currentBalance: true, + availableBalance: true, + isoCurrencyCode: true, + lastBalanceSync: true, + isActive: true, + createdAt: true, + // Intentionally omit plaidAccessToken — never expose the encrypted token + }, + }), + this.prisma.account.count({ where: { userId, isActive: true } }), + ]); + return { accounts, total, page, limit: take }; } - createLinkToken() { - return { linkToken: "stub_link_token" }; + async createLinkToken(userId: string) { + return this.plaidService.createLinkToken(userId); } async refreshBalances(userId: string) { return this.plaidService.syncBalancesForUser(userId); } - async createManualAccount(userId: string, payload: { - institutionName: string; - accountType: string; - mask?: string; - }) { + async createManualAccount( + userId: string, + payload: { institutionName: string; accountType: string; mask?: string }, + ) { return this.prisma.account.create({ data: { userId, institutionName: payload.institutionName, accountType: payload.accountType, mask: payload.mask ?? null, - isActive: true - } + isActive: true, + }, + select: { + id: true, + institutionName: true, + accountType: true, + mask: true, + isActive: true, + createdAt: true, + }, }); } } diff --git a/src/app.module.ts b/src/app.module.ts index af9b0e7..838967e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,27 +1,80 @@ import { Module } from "@nestjs/common"; -import { AccountsModule } from "./accounts/accounts.module"; -import { ExportsModule } from "./exports/exports.module"; -import { RulesModule } from "./rules/rules.module"; -import { TransactionsModule } from "./transactions/transactions.module"; +import { ConfigModule } from "@nestjs/config"; +import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler"; +import { APP_GUARD } from "@nestjs/core"; + +import { envValidationSchema } from "./config/env.validation"; +import { CommonModule } from "./common/common.module"; +import { PrismaModule } from "./prisma/prisma.module"; +import { StorageModule } from "./storage/storage.module"; +import { SupabaseModule } from "./supabase/supabase.module"; +import { EmailModule } from "./email/email.module"; import { AuthModule } from "./auth/auth.module"; import { PlaidModule } from "./plaid/plaid.module"; -import { StorageModule } from "./storage/storage.module"; import { TaxModule } from "./tax/tax.module"; -import { SupabaseModule } from "./supabase/supabase.module"; -import { PrismaModule } from "./prisma/prisma.module"; +import { TransactionsModule } from "./transactions/transactions.module"; +import { AccountsModule } from "./accounts/accounts.module"; +import { RulesModule } from "./rules/rules.module"; +import { ExportsModule } from "./exports/exports.module"; +import { StripeModule } from "./stripe/stripe.module"; +import { TwoFactorModule } from "./auth/twofa/two-factor.module"; +import { GoogleModule } from "./google/google.module"; +import { LoggerModule } from "nestjs-pino"; +import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; @Module({ imports: [ - StorageModule, + // ─── Env validation ────────────────────────────────────────────────────── + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: envValidationSchema, + validationOptions: { allowUnknown: true, abortEarly: false }, + }), + + // ─── Rate limiting: 100 requests / 60 seconds per IP ───────────────────── + ThrottlerModule.forRoot([ + { + name: "default", + ttl: 60_000, + limit: 100, + }, + ]), + + // ─── Structured logging (Pino) ──────────────────────────────────────────── + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.NODE_ENV === "production" ? "info" : "debug", + transport: process.env.NODE_ENV !== "production" + ? { target: "pino-pretty", options: { colorize: true, singleLine: true } } + : undefined, + redact: ["req.headers.authorization", "req.headers.cookie"], + }, + }), + + // ─── Core infrastructure ───────────────────────────────────────────────── + CommonModule, PrismaModule, + StorageModule, SupabaseModule, + EmailModule, + + // ─── Feature modules ───────────────────────────────────────────────────── AuthModule, PlaidModule, TaxModule, TransactionsModule, AccountsModule, RulesModule, - ExportsModule - ] + ExportsModule, + StripeModule, + TwoFactorModule, + GoogleModule, + ], + providers: [ + // Apply rate limiting globally + { provide: APP_GUARD, useClass: ThrottlerGuard }, + // Apply JWT auth globally (routes decorated with @Public() are exempt) + { provide: APP_GUARD, useClass: JwtAuthGuard }, + ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 876f766..39517b7 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,42 +1,68 @@ -import { Body, Controller, Headers, Patch, Post, UnauthorizedException } from "@nestjs/common"; +import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common"; import { ok } from "../common/response"; import { AuthService } from "./auth.service"; import { LoginDto } from "./dto/login.dto"; import { RegisterDto } from "./dto/register.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ForgotPasswordDto } from "./dto/forgot-password.dto"; +import { ResetPasswordDto } from "./dto/reset-password.dto"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { Public } from "../common/decorators/public.decorator"; @Controller("auth") +@UseGuards(JwtAuthGuard) export class AuthController { constructor(private readonly authService: AuthService) {} + @Public() @Post("register") async register(@Body() payload: RegisterDto) { - const data = await this.authService.register(payload); - return ok(data); + return ok(await this.authService.register(payload)); } + @Public() @Post("login") async login(@Body() payload: LoginDto) { - const data = await this.authService.login(payload); - return ok(data); + return ok(await this.authService.login(payload)); + } + + @Public() + @Get("verify-email") + async verifyEmail(@Query("token") token: string) { + return ok(await this.authService.verifyEmail(token)); + } + + @Public() + @Post("refresh") + async refresh(@Body("refreshToken") refreshToken: string) { + return ok(await this.authService.refreshAccessToken(refreshToken)); + } + + @Post("logout") + async logout(@Body("refreshToken") refreshToken: string) { + return ok(await this.authService.logout(refreshToken)); + } + + @Public() + @Post("forgot-password") + async forgotPassword(@Body() payload: ForgotPasswordDto) { + return ok(await this.authService.forgotPassword(payload)); + } + + @Public() + @Post("reset-password") + async resetPassword(@Body() payload: ResetPasswordDto) { + return ok(await this.authService.resetPassword(payload)); + } + + @Get("me") + async me(@CurrentUser() userId: string) { + return ok(await this.authService.getProfile(userId)); } @Patch("profile") - async updateProfile( - @Headers("authorization") authorization: string | undefined, - @Body() payload: UpdateProfileDto - ) { - const token = authorization?.startsWith("Bearer ") - ? authorization.slice("Bearer ".length) - : ""; - if (!token) { - throw new UnauthorizedException("Missing bearer token."); - } - const decoded = this.authService.verifyToken(token); - if (!decoded?.sub) { - throw new UnauthorizedException("Invalid token."); - } - const data = await this.authService.updateProfile(decoded.sub, payload); - return ok(data); + async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) { + return ok(await this.authService.updateProfile(userId, payload)); } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index d93fd0e..e126ee5 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -2,15 +2,17 @@ import { Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; @Module({ imports: [ JwtModule.register({ - secret: process.env.JWT_SECRET ?? "change_me", - signOptions: { expiresIn: "7d" } - }) + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: "15m" }, + }), ], controllers: [AuthController], - providers: [AuthService] + providers: [AuthService, JwtAuthGuard], + exports: [AuthService, JwtModule, JwtAuthGuard], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 78c7068..5808e76 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,146 +1,215 @@ -import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common"; +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; import * as crypto from "crypto"; +import * as speakeasy from "speakeasy"; import { JwtService } from "@nestjs/jwt"; import { PrismaService } from "../prisma/prisma.service"; +import { EmailService } from "../email/email.service"; +import { EncryptionService } from "../common/encryption.service"; import { LoginDto } from "./dto/login.dto"; import { RegisterDto } from "./dto/register.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ForgotPasswordDto } from "./dto/forgot-password.dto"; +import { ResetPasswordDto } from "./dto/reset-password.dto"; + +const VERIFY_TOKEN_TTL_HOURS = 24; +const RESET_TOKEN_TTL_HOURS = 1; +const REFRESH_TOKEN_TTL_DAYS = 30; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, - private readonly jwtService: JwtService + private readonly jwtService: JwtService, + private readonly emailService: EmailService, + private readonly encryption: EncryptionService, ) {} async register(payload: RegisterDto) { - const email = payload.email?.toLowerCase().trim(); - if (!email || !payload.password) { - throw new BadRequestException("Email and password are required."); - } - + const email = payload.email.toLowerCase().trim(); const existing = await this.prisma.user.findUnique({ where: { email } }); - if (existing) { - throw new BadRequestException("Email already registered."); - } + if (existing) throw new BadRequestException("Email already registered."); const passwordHash = this.hashPassword(payload.password); - const user = await this.prisma.user.create({ - data: { - email, - passwordHash, - fullName: null, - phone: null - } - }); - await this.prisma.auditLog.create({ - data: { - userId: user.id, - action: "auth.register", - metadata: { email } - } - }); + const user = await this.prisma.user.create({ data: { email, passwordHash } }); + await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } }); - const token = this.signToken(user.id); - return { user: { id: user.id, email: user.email, fullName: user.fullName }, token }; + const verifyToken = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000); + await this.prisma.emailVerificationToken.upsert({ + where: { userId: user.id }, + update: { token: verifyToken, expiresAt }, + create: { userId: user.id, token: verifyToken, expiresAt }, + }); + await this.emailService.sendVerificationEmail(email, verifyToken); + + const accessToken = this.signAccessToken(user.id); + const refreshToken = await this.createRefreshToken(user.id); + return { + user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, + accessToken, + refreshToken, + message: "Registration successful. Please verify your email.", + }; } async login(payload: LoginDto) { - const email = payload.email?.toLowerCase().trim(); - if (!email || !payload.password) { - throw new BadRequestException("Email and password are required."); - } - + const email = payload.email.toLowerCase().trim(); const user = await this.prisma.user.findUnique({ where: { email } }); - if (!user) { + if (!user || !this.verifyPassword(payload.password, user.passwordHash)) { throw new UnauthorizedException("Invalid credentials."); } - if (!this.verifyPassword(payload.password, user.passwordHash)) { - throw new UnauthorizedException("Invalid credentials."); - } - - await this.prisma.auditLog.create({ - data: { - userId: user.id, - action: "auth.login", - metadata: { email } + // ── 2FA enforcement ────────────────────────────────────────────────────── + if (user.twoFactorEnabled && user.twoFactorSecret) { + if (!payload.totpToken) { + return { requiresTwoFactor: true, accessToken: null, refreshToken: null }; } - }); + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token: payload.totpToken, window: 1 }); + if (!isValid) { + throw new UnauthorizedException("Invalid TOTP code."); + } + } - const token = this.signToken(user.id); - return { user: { id: user.id, email: user.email, fullName: user.fullName }, token }; + await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } }); + const accessToken = this.signAccessToken(user.id); + const refreshToken = await this.createRefreshToken(user.id); + return { + user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, + accessToken, + refreshToken, + }; + } + + async verifyEmail(token: string) { + const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } }); + if (!record || record.expiresAt < new Date()) { + throw new BadRequestException("Invalid or expired verification token."); + } + await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } }); + await this.prisma.emailVerificationToken.delete({ where: { token } }); + return { message: "Email verified successfully." }; + } + + async refreshAccessToken(rawRefreshToken: string) { + const tokenHash = this.hashToken(rawRefreshToken); + const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } }); + if (!record || record.revokedAt || record.expiresAt < new Date()) { + throw new UnauthorizedException("Invalid or expired refresh token."); + } + await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } }); + const accessToken = this.signAccessToken(record.userId); + const refreshToken = await this.createRefreshToken(record.userId); + return { accessToken, refreshToken }; + } + + async logout(rawRefreshToken: string) { + const tokenHash = this.hashToken(rawRefreshToken); + await this.prisma.refreshToken.updateMany({ + where: { tokenHash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + return { message: "Logged out." }; + } + + async forgotPassword(payload: ForgotPasswordDto) { + const email = payload.email.toLowerCase().trim(); + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) return { message: "If that email exists, a reset link has been sent." }; + const token = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000); + await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } }); + await this.emailService.sendPasswordResetEmail(email, token); + return { message: "If that email exists, a reset link has been sent." }; + } + + async resetPassword(payload: ResetPasswordDto) { + const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } }); + if (!record || record.usedAt || record.expiresAt < new Date()) { + throw new BadRequestException("Invalid or expired reset token."); + } + const passwordHash = this.hashPassword(payload.password); + await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }); + await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } }); + await this.prisma.refreshToken.updateMany({ + where: { userId: record.userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + return { message: "Password reset successfully. Please log in." }; } async updateProfile(userId: string, payload: UpdateProfileDto) { - const data: UpdateProfileDto = { - fullName: payload.fullName?.trim(), - phone: payload.phone?.trim(), - companyName: payload.companyName?.trim(), - addressLine1: payload.addressLine1?.trim(), - addressLine2: payload.addressLine2?.trim(), - city: payload.city?.trim(), - state: payload.state?.trim(), - postalCode: payload.postalCode?.trim(), - country: payload.country?.trim() - }; - Object.keys(data).forEach((key) => { - const value = data[key as keyof UpdateProfileDto]; - if (value === undefined || value === "") { - delete data[key as keyof UpdateProfileDto]; - } - }); - if (Object.keys(data).length === 0) { - throw new BadRequestException("No profile fields provided."); + const data: Record = {}; + for (const [key, value] of Object.entries(payload)) { + const trimmed = (value as string | undefined)?.trim(); + if (trimmed) data[key] = trimmed; } - const user = await this.prisma.user.update({ - where: { id: userId }, - data - }); + if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided."); + const user = await this.prisma.user.update({ where: { id: userId }, data }); await this.prisma.auditLog.create({ - data: { - userId: user.id, - action: "auth.profile.update", - metadata: { updatedFields: Object.keys(data) } - } + data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } }, }); return { user: { - id: user.id, - email: user.email, - fullName: user.fullName, - phone: user.phone, - companyName: user.companyName, - addressLine1: user.addressLine1, - addressLine2: user.addressLine2, - city: user.city, - state: user.state, - postalCode: user.postalCode, - country: user.country - } + id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, + companyName: user.companyName, addressLine1: user.addressLine1, + addressLine2: user.addressLine2, city: user.city, state: user.state, + postalCode: user.postalCode, country: user.country, + }, }; } - private hashPassword(password: string) { - const salt = crypto.randomBytes(16).toString("hex"); - const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex"); - return `${salt}:${hash}`; + async getProfile(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException("User not found."); + return { + user: { + id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, + companyName: user.companyName, addressLine1: user.addressLine1, + addressLine2: user.addressLine2, city: user.city, state: user.state, + postalCode: user.postalCode, country: user.country, + emailVerified: user.emailVerified, + twoFactorEnabled: user.twoFactorEnabled, + createdAt: user.createdAt, + }, + }; } - private verifyPassword(password: string, stored: string) { - const [salt, hash] = stored.split(":"); - if (!salt || !hash) { - return false; - } - const computed = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex"); - return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed)); + verifyToken(token: string): { sub: string } { + return this.jwtService.verify<{ sub: string }>(token); } - private signToken(userId: string) { + private signAccessToken(userId: string): string { return this.jwtService.sign({ sub: userId }); } - verifyToken(token: string) { - return this.jwtService.verify<{ sub: string }>(token); + private async createRefreshToken(userId: string): Promise { + const raw = crypto.randomBytes(40).toString("hex"); + const tokenHash = this.hashToken(raw); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000); + await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } }); + return raw; + } + + private hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); + } + + private hashPassword(password: string): string { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); + return `${salt}:${hash}`; + } + + private verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(":"); + if (!salt || !hash) return false; + const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); + return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed)); } } diff --git a/src/auth/dto/forgot-password.dto.ts b/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..c16b16a --- /dev/null +++ b/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from "class-validator"; + +export class ForgotPasswordDto { + @IsEmail({}, { message: "Invalid email address." }) + email!: string; +} diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index 83fe445..fdb7af6 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,4 +1,14 @@ -export type LoginDto = { - email: string; - password: string; -}; +import { IsEmail, IsOptional, IsString, MinLength } from "class-validator"; + +export class LoginDto { + @IsEmail({}, { message: "Invalid email address." }) + email!: string; + + @IsString() + @MinLength(1, { message: "Password is required." }) + password!: string; + + @IsOptional() + @IsString() + totpToken?: string; +} diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index 4832ce9..4c0163b 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -1,4 +1,10 @@ -export type RegisterDto = { - email: string; - password: string; -}; +import { IsEmail, IsString, MinLength } from "class-validator"; + +export class RegisterDto { + @IsEmail({}, { message: "Invalid email address." }) + email!: string; + + @IsString() + @MinLength(8, { message: "Password must be at least 8 characters." }) + password!: string; +} diff --git a/src/auth/dto/reset-password.dto.ts b/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..d7fe5be --- /dev/null +++ b/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MinLength } from "class-validator"; + +export class ResetPasswordDto { + @IsString() + token!: string; + + @IsString() + @MinLength(8, { message: "Password must be at least 8 characters." }) + password!: string; +} diff --git a/src/auth/dto/update-profile.dto.ts b/src/auth/dto/update-profile.dto.ts index 8e3d04d..fc21021 100644 --- a/src/auth/dto/update-profile.dto.ts +++ b/src/auth/dto/update-profile.dto.ts @@ -1,11 +1,13 @@ -export type UpdateProfileDto = { - fullName?: string; - phone?: string; - companyName?: string; - addressLine1?: string; - addressLine2?: string; - city?: string; - state?: string; - postalCode?: string; - country?: string; -}; +import { IsOptional, IsString, MaxLength } from "class-validator"; + +export class UpdateProfileDto { + @IsOptional() @IsString() @MaxLength(200) fullName?: string; + @IsOptional() @IsString() @MaxLength(30) phone?: string; + @IsOptional() @IsString() @MaxLength(200) companyName?: string; + @IsOptional() @IsString() @MaxLength(300) addressLine1?: string; + @IsOptional() @IsString() @MaxLength(300) addressLine2?: string; + @IsOptional() @IsString() @MaxLength(100) city?: string; + @IsOptional() @IsString() @MaxLength(100) state?: string; + @IsOptional() @IsString() @MaxLength(20) postalCode?: string; + @IsOptional() @IsString() @MaxLength(100) country?: string; +} diff --git a/src/auth/twofa/two-factor.controller.ts b/src/auth/twofa/two-factor.controller.ts new file mode 100644 index 0000000..21311d4 --- /dev/null +++ b/src/auth/twofa/two-factor.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Delete, Post } from "@nestjs/common"; +import { ok } from "../../common/response"; +import { TwoFactorService } from "./two-factor.service"; +import { CurrentUser } from "../../common/decorators/current-user.decorator"; + +@Controller("auth/2fa") +export class TwoFactorController { + constructor(private readonly twoFactorService: TwoFactorService) {} + + @Post("generate") + async generate(@CurrentUser() userId: string) { + const data = await this.twoFactorService.generateSecret(userId); + return ok(data); + } + + @Post("enable") + async enable(@CurrentUser() userId: string, @Body("token") token: string) { + const data = await this.twoFactorService.enableTwoFactor(userId, token); + return ok(data); + } + + @Delete("disable") + async disable(@CurrentUser() userId: string, @Body("token") token: string) { + const data = await this.twoFactorService.disableTwoFactor(userId, token); + return ok(data); + } +} diff --git a/src/auth/twofa/two-factor.module.ts b/src/auth/twofa/two-factor.module.ts new file mode 100644 index 0000000..7aff36b --- /dev/null +++ b/src/auth/twofa/two-factor.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { TwoFactorController } from "./two-factor.controller"; +import { TwoFactorService } from "./two-factor.service"; + +@Module({ + controllers: [TwoFactorController], + providers: [TwoFactorService], + exports: [TwoFactorService], +}) +export class TwoFactorModule {} diff --git a/src/auth/twofa/two-factor.service.ts b/src/auth/twofa/two-factor.service.ts new file mode 100644 index 0000000..c2d7158 --- /dev/null +++ b/src/auth/twofa/two-factor.service.ts @@ -0,0 +1,96 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import * as speakeasy from "speakeasy"; +import * as QRCode from "qrcode"; +import { PrismaService } from "../../prisma/prisma.service"; +import { EncryptionService } from "../../common/encryption.service"; + +@Injectable() +export class TwoFactorService { + constructor( + private readonly prisma: PrismaService, + private readonly encryption: EncryptionService, + ) {} + + async generateSecret(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, twoFactorEnabled: true }, + }); + if (!user) throw new BadRequestException("User not found."); + if (user.twoFactorEnabled) { + throw new BadRequestException("2FA is already enabled."); + } + + const secret = speakeasy.generateSecret({ name: `LedgerOne:${user.email}`, length: 20 }); + const otpAuthUrl = secret.otpauth_url ?? ""; + const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl); + + // Store encrypted secret temporarily (not yet enabled) + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorSecret: this.encryption.encrypt(secret.base32) }, + }); + + return { qrCode: qrCodeDataUrl, otpAuthUrl }; + } + + async enableTwoFactor(userId: string, token: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecret: true, twoFactorEnabled: true }, + }); + if (!user?.twoFactorSecret) { + throw new BadRequestException("Please generate a 2FA secret first."); + } + if (user.twoFactorEnabled) { + throw new BadRequestException("2FA is already enabled."); + } + + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 }); + if (!isValid) { + throw new BadRequestException("Invalid TOTP token."); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: true }, + }); + await this.prisma.auditLog.create({ + data: { userId, action: "auth.2fa.enabled", metadata: {} }, + }); + + return { message: "2FA enabled successfully." }; + } + + async disableTwoFactor(userId: string, token: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecret: true, twoFactorEnabled: true }, + }); + if (!user?.twoFactorEnabled || !user.twoFactorSecret) { + throw new BadRequestException("2FA is not enabled."); + } + + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 }); + if (!isValid) { + throw new BadRequestException("Invalid TOTP token."); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: false, twoFactorSecret: null }, + }); + await this.prisma.auditLog.create({ + data: { userId, action: "auth.2fa.disabled", metadata: {} }, + }); + + return { message: "2FA disabled successfully." }; + } + + verifyToken(encryptedSecret: string, token: string): boolean { + const secret = this.encryption.decrypt(encryptedSecret); + return speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 }); + } +} diff --git a/src/common/common.module.ts b/src/common/common.module.ts new file mode 100644 index 0000000..7a4bcbd --- /dev/null +++ b/src/common/common.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { EncryptionService } from "./encryption.service"; + +@Global() +@Module({ + providers: [EncryptionService], + exports: [EncryptionService], +}) +export class CommonModule {} diff --git a/src/common/decorators/current-user.decorator.ts b/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..b7905bd --- /dev/null +++ b/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { Request } from "express"; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + return request.user?.sub ?? ""; + }, +); diff --git a/src/common/decorators/public.decorator.ts b/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..4e76877 --- /dev/null +++ b/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from "@nestjs/common"; +import { IS_PUBLIC_KEY } from "../guards/jwt-auth.guard"; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/common/encryption.service.ts b/src/common/encryption.service.ts new file mode 100644 index 0000000..fdf3e2c --- /dev/null +++ b/src/common/encryption.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@nestjs/common"; +import * as crypto from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 16; +const TAG_LENGTH = 16; + +@Injectable() +export class EncryptionService { + private readonly key: Buffer; + + constructor() { + const hexKey = process.env.ENCRYPTION_KEY; + if (!hexKey || hexKey.length !== 64) { + throw new Error("ENCRYPTION_KEY must be a 64-char hex string (32 bytes)."); + } + this.key = Buffer.from(hexKey, "hex"); + } + + encrypt(plaintext: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString("base64"); + } + + decrypt(ciphertext: string): string { + const buf = Buffer.from(ciphertext, "base64"); + const iv = buf.subarray(0, IV_LENGTH); + const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH); + const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv); + decipher.setAuthTag(tag); + return decipher.update(encrypted) + decipher.final("utf8"); + } +} diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..3058184 --- /dev/null +++ b/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,53 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { JwtService } from "@nestjs/jwt"; +import { Request } from "express"; + +export const IS_PUBLIC_KEY = "isPublic"; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly reflector: Reflector, + ) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + if (!token) { + throw new UnauthorizedException("Missing bearer token."); + } + + try { + const payload = this.jwtService.verify<{ sub: string }>(token, { + secret: process.env.JWT_SECRET, + }); + (request as Request & { user: { sub: string } }).user = payload; + return true; + } catch { + throw new UnauthorizedException("Invalid or expired token."); + } + } + + private extractToken(request: Request): string | null { + const auth = request.headers.authorization; + if (auth?.startsWith("Bearer ")) { + return auth.slice(7); + } + return null; + } +} diff --git a/src/common/sentry.filter.ts b/src/common/sentry.filter.ts new file mode 100644 index 0000000..71fff69 --- /dev/null +++ b/src/common/sentry.filter.ts @@ -0,0 +1,56 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from "@nestjs/common"; +import * as Sentry from "@sentry/node"; +import { Request, Response } from "express"; + +@Catch() +export class SentryExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("ExceptionFilter"); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + // Only report 5xx errors to Sentry + if (status >= 500 && process.env.SENTRY_DSN) { + Sentry.captureException(exception, { + extra: { + url: request.url, + method: request.method, + userId: (request as Request & { user?: { sub: string } }).user?.sub, + }, + }); + } else if (status >= 500) { + this.logger.error(exception); + } + + const message = + exception instanceof HttpException + ? exception.getResponse() + : "Internal server error"; + + response.status(status).json({ + data: null, + error: { + statusCode: status, + message: + typeof message === "string" + ? message + : (message as { message?: string }).message ?? "Internal server error", + }, + meta: { timestamp: new Date().toISOString(), version: "1.0" }, + }); + } +} diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..f9e274f --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,45 @@ +import * as Joi from "joi"; + +export const envValidationSchema = Joi.object({ + DATABASE_URL: Joi.string().required(), + JWT_SECRET: Joi.string().min(32).required(), + JWT_REFRESH_SECRET: Joi.string().min(32).required(), + ENCRYPTION_KEY: Joi.string().length(64).required(), + + PLAID_CLIENT_ID: Joi.string().required(), + PLAID_SECRET: Joi.string().required(), + PLAID_ENV: Joi.string().valid("sandbox", "development", "production").default("sandbox"), + PLAID_PRODUCTS: Joi.string().default("transactions"), + PLAID_COUNTRY_CODES: Joi.string().default("US"), + PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""), + + SUPABASE_URL: Joi.string().optional().allow(""), + SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""), + SUPABASE_ANON_KEY: Joi.string().optional().allow(""), + + STRIPE_SECRET_KEY: Joi.string().optional().allow(""), + STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""), + STRIPE_PRICE_PRO: Joi.string().optional().allow(""), + STRIPE_PRICE_ELITE: Joi.string().optional().allow(""), + + SMTP_HOST: Joi.string().optional().allow(""), + SMTP_PORT: Joi.number().default(587), + SMTP_USER: Joi.string().optional().allow(""), + SMTP_PASS: Joi.string().optional().allow(""), + SMTP_FROM: Joi.string().default("noreply@ledgerone.app"), + + APP_URL: Joi.string().uri().default("http://localhost:3052"), + PORT: Joi.number().default(3051), + NODE_ENV: Joi.string().valid("development", "production", "test").default("development"), + + CORS_ORIGIN: Joi.string().default("http://localhost:3052"), + + AUTO_SYNC_ENABLED: Joi.boolean().default(true), + AUTO_SYNC_INTERVAL_MINUTES: Joi.number().default(15), + + SENTRY_DSN: Joi.string().optional().allow(""), + + GOOGLE_CLIENT_ID: Joi.string().optional().allow(""), + GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""), + GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""), +}); diff --git a/src/email/email.module.ts b/src/email/email.module.ts new file mode 100644 index 0000000..b813c04 --- /dev/null +++ b/src/email/email.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { EmailService } from "./email.service"; + +@Global() +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/src/email/email.service.ts b/src/email/email.service.ts new file mode 100644 index 0000000..fe471fb --- /dev/null +++ b/src/email/email.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from "@nestjs/common"; +import * as nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private readonly transporter: Transporter; + private readonly from: string; + private readonly appUrl: string; + + constructor() { + this.from = process.env.SMTP_FROM ?? "noreply@ledgerone.app"; + this.appUrl = process.env.APP_URL ?? "http://localhost:3052"; + + const smtpHost = process.env.SMTP_HOST; + if (smtpHost) { + this.transporter = nodemailer.createTransport({ + host: smtpHost, + port: Number(process.env.SMTP_PORT ?? 587), + secure: Number(process.env.SMTP_PORT ?? 587) === 465, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + } else { + // Dev mode: log emails instead of sending + this.transporter = nodemailer.createTransport({ jsonTransport: true }); + } + } + + async sendVerificationEmail(email: string, token: string): Promise { + const url = `${this.appUrl}/verify-email?token=${token}`; + try { + const info = await this.transporter.sendMail({ + from: this.from, + to: email, + subject: "Verify your LedgerOne account", + html: ` +

Welcome to LedgerOne!

+

Please verify your email address by clicking the link below:

+

Verify Email

+

Or copy this link: ${url}

+

This link expires in 24 hours.

+ `, + }); + if (!process.env.SMTP_HOST) { + this.logger.log(`[DEV] Verification email for ${email}: ${url}`); + this.logger.debug(JSON.stringify(info)); + } + } catch (err) { + this.logger.error(`Failed to send verification email to ${email}`, err); + } + } + + async sendPasswordResetEmail(email: string, token: string): Promise { + const url = `${this.appUrl}/reset-password?token=${token}`; + try { + const info = await this.transporter.sendMail({ + from: this.from, + to: email, + subject: "Reset your LedgerOne password", + html: ` +

Password Reset Request

+

Click the button below to reset your password. This link expires in 1 hour.

+

Reset Password

+

Or copy this link: ${url}

+

If you did not request this, you can safely ignore this email.

+ `, + }); + if (!process.env.SMTP_HOST) { + this.logger.log(`[DEV] Password reset email for ${email}: ${url}`); + this.logger.debug(JSON.stringify(info)); + } + } catch (err) { + this.logger.error(`Failed to send password reset email to ${email}`, err); + } + } +} diff --git a/src/exports/exports.controller.ts b/src/exports/exports.controller.ts index 0f49225..621f5e9 100644 --- a/src/exports/exports.controller.ts +++ b/src/exports/exports.controller.ts @@ -1,20 +1,21 @@ import { Controller, Get, Post, Query } from "@nestjs/common"; import { ok } from "../common/response"; import { ExportsService } from "./exports.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; @Controller("exports") export class ExportsController { constructor(private readonly exportsService: ExportsService) {} @Get("csv") - async exportCsv(@Query("user_id") userId?: string, @Query() query?: Record) { - const data = await this.exportsService.exportCsv(userId, query ?? {}); + async exportCsv(@CurrentUser() userId: string, @Query() query: Record) { + const data = await this.exportsService.exportCsv(userId, query); return ok(data); } @Post("sheets") - async exportSheets() { - const data = await this.exportsService.exportSheets(); + async exportSheets(@CurrentUser() userId: string, @Query() query: Record) { + const data = await this.exportsService.exportSheets(userId, query); return ok(data); } } diff --git a/src/exports/exports.service.ts b/src/exports/exports.service.ts index 3af2319..4b1a72c 100644 --- a/src/exports/exports.service.ts +++ b/src/exports/exports.service.ts @@ -1,12 +1,16 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { google } from "googleapis"; import { PrismaService } from "../prisma/prisma.service"; @Injectable() export class ExportsService { + private readonly logger = new Logger(ExportsService.name); + constructor(private readonly prisma: PrismaService) {} private toCsv(rows: Array>) { - const headers = Object.keys(rows[0] ?? {}); + if (!rows.length) return ""; + const headers = Object.keys(rows[0]); const escape = (value: string) => `"${value.replace(/"/g, '""')}"`; const lines = [headers.join(",")]; for (const row of rows) { @@ -15,59 +19,45 @@ export class ExportsService { return lines.join("\n"); } - async exportCsv(userId?: string, filters: Record = {}) { - if (!userId) { - return { status: "missing_user", csv: "" }; - } - - const where: Record = { - account: { userId } - }; + private async getTransactions( + userId: string, + filters: Record, + limit = 1000, + ) { + const where: Record = { account: { userId } }; if (filters.start_date || filters.end_date) { where.date = { gte: filters.start_date ? new Date(filters.start_date) : undefined, - lte: filters.end_date ? new Date(filters.end_date) : undefined + lte: filters.end_date ? new Date(filters.end_date) : undefined, }; } if (filters.min_amount || filters.max_amount) { - const minAmount = filters.min_amount ? Number(filters.min_amount) : undefined; - const maxAmount = filters.max_amount ? Number(filters.max_amount) : undefined; where.amount = { - gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount, - lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : maxAmount + gte: filters.min_amount ? Number(filters.min_amount) : undefined, + lte: filters.max_amount ? Number(filters.max_amount) : undefined, }; } if (filters.category) { where.derived = { - is: { - userCategory: { - contains: filters.category, - mode: "insensitive" - } - } + is: { userCategory: { contains: filters.category, mode: "insensitive" } }, }; } if (filters.source) { - where.source = { - contains: filters.source, - mode: "insensitive" - }; + where.source = { contains: filters.source, mode: "insensitive" }; } if (filters.include_hidden !== "true") { - where.OR = [ - { derived: null }, - { derived: { isHidden: false } } - ]; + where.OR = [{ derived: null }, { derived: { isHidden: false } }]; } - - const transactions = await this.prisma.transactionRaw.findMany({ + return this.prisma.transactionRaw.findMany({ where, include: { derived: true }, orderBy: { date: "desc" }, - take: 1000 + take: limit, }); + } - const rows = transactions.map((tx) => ({ + private toRows(transactions: Awaited>) { + return transactions.map((tx) => ({ id: tx.id, date: tx.date.toISOString().slice(0, 10), description: tx.description, @@ -75,25 +65,104 @@ export class ExportsService { category: tx.derived?.userCategory ?? "", notes: tx.derived?.userNotes ?? "", hidden: tx.derived?.isHidden ? "true" : "false", - source: tx.source + source: tx.source, })); + } + async exportCsv(userId: string, filters: Record = {}) { + const transactions = await this.getTransactions(userId, filters); + const rows = this.toRows(transactions); const csv = this.toCsv(rows); await this.prisma.exportLog.create({ - data: { - userId, - filters, - rowCount: rows.length - } + data: { userId, filters, rowCount: rows.length }, }); return { status: "ready", csv, rowCount: rows.length }; } - exportSheets() { + async exportSheets(userId: string, filters: Record = {}) { + // Get the user's Google connection + const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); + if (!gc || !gc.isConnected) { + throw new BadRequestException( + "Google account not connected. Please connect via /api/google/connect.", + ); + } + + const oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + ); + oauth2Client.setCredentials({ + access_token: gc.accessToken, + refresh_token: gc.refreshToken, + }); + + // Refresh the access token if needed + const { credentials } = await oauth2Client.refreshAccessToken(); + await this.prisma.googleConnection.update({ + where: { userId }, + data: { + accessToken: credentials.access_token ?? gc.accessToken, + lastSyncedAt: new Date(), + }, + }); + oauth2Client.setCredentials(credentials); + + const sheets = google.sheets({ version: "v4", auth: oauth2Client }); + const transactions = await this.getTransactions(userId, filters); + const rows = this.toRows(transactions); + + const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`; + + let spreadsheetId = gc.spreadsheetId; + if (!spreadsheetId) { + // Create a new spreadsheet + const spreadsheet = await sheets.spreadsheets.create({ + requestBody: { properties: { title: "LedgerOne" } }, + }); + spreadsheetId = spreadsheet.data.spreadsheetId!; + await this.prisma.googleConnection.update({ + where: { userId }, + data: { spreadsheetId }, + }); + } + + // Add a new sheet tab + await sheets.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { + requests: [{ addSheet: { properties: { title: sheetTitle } } }], + }, + }); + + // Build values: header + data rows + const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"]; + const values = [ + headers, + ...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")), + ]; + + await sheets.spreadsheets.values.update({ + spreadsheetId, + range: `'${sheetTitle}'!A1`, + valueInputOption: "RAW", + requestBody: { values }, + }); + + await this.prisma.exportLog.create({ + data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length }, + }); + + this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`); + return { - status: "queued", + status: "exported", + rowCount: rows.length, + spreadsheetId, + sheetTitle, + url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, }; } } diff --git a/src/google/google.controller.ts b/src/google/google.controller.ts new file mode 100644 index 0000000..c07d68e --- /dev/null +++ b/src/google/google.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Delete, Get, HttpCode, Post } from "@nestjs/common"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { GoogleService } from "./google.service"; + +@Controller("google") +export class GoogleController { + constructor(private readonly googleService: GoogleService) {} + + /** Returns the Google OAuth URL — frontend redirects the user to it. */ + @Get("connect") + getAuthUrl(@CurrentUser() userId: string) { + return this.googleService.getAuthUrl(userId); + } + + /** Exchanges the OAuth code for tokens and saves the connection. */ + @Post("exchange") + @HttpCode(200) + exchange(@CurrentUser() userId: string, @Body() body: { code: string }) { + return this.googleService.exchangeCode(userId, body.code); + } + + /** Removes the stored Google connection. */ + @Delete("disconnect") + disconnect(@CurrentUser() userId: string) { + return this.googleService.disconnect(userId); + } + + /** Returns whether the user has a connected Google account. */ + @Get("status") + status(@CurrentUser() userId: string) { + return this.googleService.getStatus(userId); + } +} diff --git a/src/google/google.module.ts b/src/google/google.module.ts new file mode 100644 index 0000000..292f338 --- /dev/null +++ b/src/google/google.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module"; +import { GoogleController } from "./google.controller"; +import { GoogleService } from "./google.service"; + +@Module({ + imports: [PrismaModule], + controllers: [GoogleController], + providers: [GoogleService], +}) +export class GoogleModule {} diff --git a/src/google/google.service.ts b/src/google/google.service.ts new file mode 100644 index 0000000..3f46527 --- /dev/null +++ b/src/google/google.service.ts @@ -0,0 +1,94 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { google } from "googleapis"; +import { PrismaService } from "../prisma/prisma.service"; + +const SCOPES = [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/userinfo.email", +]; + +@Injectable() +export class GoogleService { + private readonly logger = new Logger(GoogleService.name); + + constructor(private readonly prisma: PrismaService) {} + + private createClient() { + return new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + process.env.GOOGLE_REDIRECT_URI, + ); + } + + async getAuthUrl(userId: string) { + if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) { + throw new BadRequestException("Google OAuth is not configured on this server."); + } + const client = this.createClient(); + const authUrl = client.generateAuthUrl({ + access_type: "offline", + scope: SCOPES, + prompt: "consent", // always request a refresh_token + state: userId, // passed back in callback to identify the user + }); + return { authUrl }; + } + + async exchangeCode(userId: string, code: string) { + const client = this.createClient(); + let tokens: Awaited>["tokens"]; + try { + const { tokens: t } = await client.getToken(code); + tokens = t; + } catch { + throw new BadRequestException("Invalid or expired authorization code."); + } + + if (!tokens.refresh_token) { + throw new BadRequestException( + "No refresh token received. Please disconnect and try again.", + ); + } + + // Fetch the Google account email + client.setCredentials(tokens); + const oauth2 = google.oauth2({ version: "v2", auth: client }); + const { data } = await oauth2.userinfo.get(); + const googleEmail = data.email ?? ""; + + await this.prisma.googleConnection.upsert({ + where: { userId }, + update: { + googleEmail, + refreshToken: tokens.refresh_token, + accessToken: tokens.access_token ?? null, + isConnected: true, + connectedAt: new Date(), + spreadsheetId: null, // reset so a new spreadsheet is created on next export + }, + create: { + userId, + googleEmail, + refreshToken: tokens.refresh_token, + accessToken: tokens.access_token ?? null, + isConnected: true, + }, + }); + + this.logger.log(`Google account connected for user ${userId}: ${googleEmail}`); + return { connected: true, googleEmail }; + } + + async disconnect(userId: string) { + await this.prisma.googleConnection.deleteMany({ where: { userId } }); + return { disconnected: true }; + } + + async getStatus(userId: string) { + const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); + if (!gc || !gc.isConnected) return { connected: false }; + return { connected: true, googleEmail: gc.googleEmail, connectedAt: gc.connectedAt }; + } +} diff --git a/src/main.ts b/src/main.ts index 60f0ec2..f0e582f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,85 @@ import "dotenv/config"; +import * as Sentry from "@sentry/node"; import { NestFactory } from "@nestjs/core"; +import { ValidationPipe } from "@nestjs/common"; +import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; +import { Logger } from "nestjs-pino"; +import helmet from "helmet"; import { AppModule } from "./app.module"; +import { SentryExceptionFilter } from "./common/sentry.filter"; async function bootstrap() { - const app = await NestFactory.create(AppModule); - app.setGlobalPrefix("api"); - app.enableCors({ - origin: true, - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"] + // ─── Sentry initialization (before app creation) ────────────────────────── + if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV ?? "development", + }); + } + + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + rawBody: true, // Required for Stripe webhook signature verification }); - await app.listen(3051); + + // ─── Security headers ───────────────────────────────────────────────────── + app.use( + helmet({ + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "https:"], + }, + }, + }), + ); + + // ─── CORS ───────────────────────────────────────────────────────────────── + const corsOrigin = process.env.CORS_ORIGIN ?? "http://localhost:3052"; + app.enableCors({ + origin: corsOrigin.split(",").map((o) => o.trim()), + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + }); + + // ─── Global prefix ──────────────────────────────────────────────────────── + app.setGlobalPrefix("api"); + + // ─── Global validation pipe ─────────────────────────────────────────────── + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + + // ─── Swagger / OpenAPI ──────────────────────────────────────────────────── + if (process.env.NODE_ENV !== "production") { + const config = new DocumentBuilder() + .setTitle("LedgerOne API") + .setDescription("Personal finance & bookkeeping SaaS API") + .setVersion("1.0") + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup("api/docs", app, document); + } + + // ─── Global exception filter (Sentry + structured error response) ───────── + app.useGlobalFilters(new SentryExceptionFilter()); + + // ─── Use Pino as the logger ──────────────────────────────────────────────── + app.useLogger(app.get(Logger)); + + const port = process.env.PORT ?? 3051; + await app.listen(port); + app.get(Logger).log(`LedgerOne backend running on port ${port}`, "Bootstrap"); } bootstrap(); diff --git a/src/plaid/plaid.controller.ts b/src/plaid/plaid.controller.ts index aa45bbe..dc1d02e 100644 --- a/src/plaid/plaid.controller.ts +++ b/src/plaid/plaid.controller.ts @@ -1,23 +1,24 @@ import { Body, Controller, Post } from "@nestjs/common"; import { ok } from "../common/response"; import { PlaidService } from "./plaid.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; @Controller("plaid") export class PlaidController { constructor(private readonly plaidService: PlaidService) {} @Post("link-token") - async createLinkToken() { - const data = await this.plaidService.createLinkToken(); + async createLinkToken(@CurrentUser() userId: string) { + const data = await this.plaidService.createLinkToken(userId); return ok(data); } @Post("exchange") - async exchange(@Body() payload: { publicToken: string; userId: string }) { - const data = await this.plaidService.exchangePublicTokenForUser( - payload.userId, - payload.publicToken - ); + async exchange( + @CurrentUser() userId: string, + @Body() payload: { publicToken: string }, + ) { + const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken); return ok(data); } } diff --git a/src/plaid/plaid.service.ts b/src/plaid/plaid.service.ts index dba1460..6f0a3a5 100644 --- a/src/plaid/plaid.service.ts +++ b/src/plaid/plaid.service.ts @@ -4,17 +4,21 @@ import { CountryCode, PlaidApi, PlaidEnvironments, - Products + Products, } from "plaid"; import * as crypto from "crypto"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; +import { EncryptionService } from "../common/encryption.service"; @Injectable() export class PlaidService { private readonly client: PlaidApi; - constructor(private readonly prisma: PrismaService) { + constructor( + private readonly prisma: PrismaService, + private readonly encryption: EncryptionService, + ) { const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments; const clientId = this.requireEnv("PLAID_CLIENT_ID"); const secret = this.requireEnv("PLAID_SECRET"); @@ -25,14 +29,14 @@ export class PlaidService { headers: { "PLAID-CLIENT-ID": clientId, "PLAID-SECRET": secret, - "Plaid-Version": "2020-09-14" - } - } + "Plaid-Version": "2020-09-14", + }, + }, }); this.client = new PlaidApi(config); } - async createLinkToken() { + async createLinkToken(userId: string) { const products = (process.env.PLAID_PRODUCTS ?? "transactions") .split(",") .map((item) => item.trim()) @@ -45,19 +49,17 @@ export class PlaidService { try { const response = await this.client.linkTokenCreate({ - user: { - client_user_id: crypto.randomUUID() - }, + user: { client_user_id: userId }, client_name: "LedgerOne", products, country_codes: countryCodes, language: "en", - redirect_uri: redirectUri || undefined + redirect_uri: redirectUri || undefined, }); return { linkToken: response.data.link_token, - expiration: response.data.expiration + expiration: response.data.expiration, }; } catch (error: unknown) { const err = error as { response?: { data?: { error_message?: string } } }; @@ -69,12 +71,17 @@ export class PlaidService { async exchangePublicTokenForUser(userId: string, publicToken: string) { const exchange = await this.client.itemPublicTokenExchange({ - public_token: publicToken + public_token: publicToken, }); - const accessToken = exchange.data.access_token; + const rawAccessToken = exchange.data.access_token; const itemId = exchange.data.item_id; - const accountsResponse = await this.client.accountsGet({ access_token: accessToken }); + // Encrypt before storing + const encryptedToken = this.encryption.encrypt(rawAccessToken); + + const accountsResponse = await this.client.accountsGet({ + access_token: rawAccessToken, + }); const institutionId = accountsResponse.data.item?.institution_id; const institutionName = institutionId ? await this.getInstitutionName(institutionId) @@ -87,49 +94,53 @@ export class PlaidService { institutionName, accountType: account.subtype ?? account.type, mask: account.mask ?? null, - plaidAccessToken: accessToken, + plaidAccessToken: encryptedToken, plaidItemId: itemId, currentBalance: account.balances.current ?? null, availableBalance: account.balances.available ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null, lastBalanceSync: new Date(), - userId + userId, }, create: { userId, institutionName, accountType: account.subtype ?? account.type, mask: account.mask ?? null, - plaidAccessToken: accessToken, + plaidAccessToken: encryptedToken, plaidItemId: itemId, plaidAccountId: account.account_id, currentBalance: account.balances.current ?? null, availableBalance: account.balances.available ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null, lastBalanceSync: new Date(), - isActive: true - } + isActive: true, + }, }); } return { - accessToken, itemId, - accountCount: accountsResponse.data.accounts.length + accountCount: accountsResponse.data.accounts.length, }; } async syncBalancesForUser(userId: string) { const accounts = await this.prisma.account.findMany({ - where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } } + where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, }); - const tokens = Array.from(new Set(accounts.map((acct) => acct.plaidAccessToken))).filter( - Boolean - ) as string[]; + + // Deduplicate by decrypted token + const tokenMap = new Map(); + for (const acct of accounts) { + if (!acct.plaidAccessToken) continue; + const raw = this.encryption.decrypt(acct.plaidAccessToken); + tokenMap.set(acct.plaidAccessToken, raw); + } let updated = 0; - for (const token of tokens) { - const response = await this.client.accountsBalanceGet({ access_token: token }); + for (const [, rawToken] of tokenMap) { + const response = await this.client.accountsBalanceGet({ access_token: rawToken }); for (const account of response.data.accounts) { const record = await this.prisma.account.updateMany({ where: { plaidAccountId: account.account_id, userId }, @@ -137,8 +148,8 @@ export class PlaidService { currentBalance: account.balances.current ?? null, availableBalance: account.balances.available ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null, - lastBalanceSync: new Date() - } + lastBalanceSync: new Date(), + }, }); updated += record.count; } @@ -148,32 +159,31 @@ export class PlaidService { async syncTransactionsForUser(userId: string, startDate: string, endDate: string) { const accounts = await this.prisma.account.findMany({ - where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } } + where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, }); + + // Build map: raw decrypted token → plaidAccountIds const tokenMap = new Map(); for (const account of accounts) { - if (!account.plaidAccessToken || !account.plaidAccountId) { - continue; - } - const list = tokenMap.get(account.plaidAccessToken) ?? []; + if (!account.plaidAccessToken || !account.plaidAccountId) continue; + const raw = this.encryption.decrypt(account.plaidAccessToken); + const list = tokenMap.get(raw) ?? []; list.push(account.plaidAccountId); - tokenMap.set(account.plaidAccessToken, list); + tokenMap.set(raw, list); } let created = 0; - for (const [token] of tokenMap) { + for (const [rawToken] of tokenMap) { const response = await this.client.transactionsGet({ - access_token: token, + access_token: rawToken, start_date: startDate, end_date: endDate, - options: { count: 500, offset: 0 } + options: { count: 500, offset: 0 }, }); for (const tx of response.data.transactions) { const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id); - if (!account) { - continue; - } + if (!account) continue; const rawPayload = tx as unknown as Prisma.InputJsonValue; await this.prisma.transactionRaw.upsert({ where: { bankTransactionId: tx.transaction_id }, @@ -184,7 +194,7 @@ export class PlaidService { description: tx.name ?? "Plaid transaction", rawPayload, source: "plaid", - ingestedAt: new Date() + ingestedAt: new Date(), }, create: { accountId: account.id, @@ -194,8 +204,8 @@ export class PlaidService { description: tx.name ?? "Plaid transaction", rawPayload, ingestedAt: new Date(), - source: "plaid" - } + source: "plaid", + }, }); created += 1; } @@ -216,7 +226,7 @@ export class PlaidService { try { const response = await this.client.institutionsGetById({ institution_id: institutionId, - country_codes: ["US" as CountryCode] + country_codes: ["US" as CountryCode], }); return response.data.institution.name ?? "Plaid institution"; } catch { diff --git a/src/rules/rules.controller.ts b/src/rules/rules.controller.ts index 6d717ab..bbcc9fe 100644 --- a/src/rules/rules.controller.ts +++ b/src/rules/rules.controller.ts @@ -1,37 +1,59 @@ -import { Body, Controller, Get, Param, Post, Put, Query } from "@nestjs/common"; +import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common"; import { ok } from "../common/response"; import { RulesService } from "./rules.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; @Controller("rules") export class RulesController { constructor(private readonly rulesService: RulesService) {} @Get() - async list(@Query("user_id") userId?: string) { + async list(@CurrentUser() userId: string) { const data = await this.rulesService.list(userId); return ok(data); } @Post() - async create(@Body() payload: Record) { - const data = await this.rulesService.create(payload as never); + async create( + @CurrentUser() userId: string, + @Body() + payload: { + name: string; + priority?: number; + conditions: Record; + actions: Record; + isActive?: boolean; + }, + ) { + const data = await this.rulesService.create(userId, payload); return ok(data); } @Put(":id") - async update(@Param("id") id: string, @Body() payload: Record) { - const data = await this.rulesService.update(id, payload as never); + async update( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() + payload: { + name?: string; + priority?: number; + conditions?: Record; + actions?: Record; + isActive?: boolean; + }, + ) { + const data = await this.rulesService.update(userId, id, payload); return ok(data); } @Post(":id/execute") - async execute(@Param("id") id: string) { - const data = await this.rulesService.execute(id); + async execute(@CurrentUser() userId: string, @Param("id") id: string) { + const data = await this.rulesService.execute(userId, id); return ok(data); } @Get("suggestions") - async suggestions(@Query("user_id") userId?: string) { + async suggestions(@CurrentUser() userId: string) { const data = await this.rulesService.suggest(userId); return ok(data); } diff --git a/src/rules/rules.service.ts b/src/rules/rules.service.ts index 7b3aeee..b7f2d7c 100644 --- a/src/rules/rules.service.ts +++ b/src/rules/rules.service.ts @@ -1,161 +1,145 @@ -import { Injectable } from "@nestjs/common"; -import { StorageService } from "../storage/storage.service"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; @Injectable() export class RulesService { - constructor(private readonly storage: StorageService) {} + constructor(private readonly prisma: PrismaService) {} - async list(userId?: string) { - if (!userId) { - return []; - } - const snapshot = await this.storage.load(); - return snapshot.rules - .filter((rule) => rule.userId === userId) - .sort((a, b) => a.priority - b.priority); + async list(userId: string) { + return this.prisma.rule.findMany({ + where: { userId, isActive: true }, + orderBy: { priority: "asc" }, + }); } - async create(payload: Record) { - const snapshot = await this.storage.load(); - const now = this.storage.now(); - const rule = { - id: this.storage.createId(), - userId: String(payload.userId ?? ""), - name: String(payload.name ?? "Untitled rule"), - priority: Number(payload.priority ?? snapshot.rules.length + 1), - conditions: (payload.conditions as Record) ?? {}, - actions: (payload.actions as Record) ?? {}, - isActive: payload.isActive !== false, - createdAt: now - }; - snapshot.rules.push(rule); - await this.storage.save(snapshot); - return rule; + async create( + userId: string, + payload: { + name: string; + priority?: number; + conditions: Record; + actions: Record; + isActive?: boolean; + }, + ) { + const count = await this.prisma.rule.count({ where: { userId } }); + return this.prisma.rule.create({ + data: { + userId, + name: payload.name ?? "Untitled rule", + priority: payload.priority ?? count + 1, + conditions: payload.conditions as Prisma.InputJsonValue, + actions: payload.actions as Prisma.InputJsonValue, + isActive: payload.isActive !== false, + }, + }); } - async update(id: string, payload: Record) { - const snapshot = await this.storage.load(); - const index = snapshot.rules.findIndex((rule) => rule.id === id); - if (index === -1) { - return null; - } - const existing = snapshot.rules[index]; - const next = { - ...existing, - name: typeof payload.name === "string" ? payload.name : existing.name, - priority: typeof payload.priority === "number" ? payload.priority : existing.priority, - conditions: - typeof payload.conditions === "object" && payload.conditions !== null - ? (payload.conditions as Record) - : existing.conditions, - actions: - typeof payload.actions === "object" && payload.actions !== null - ? (payload.actions as Record) - : existing.actions, - isActive: typeof payload.isActive === "boolean" ? payload.isActive : existing.isActive - }; - snapshot.rules[index] = next; - await this.storage.save(snapshot); - return next; + async update( + userId: string, + id: string, + payload: { + name?: string; + priority?: number; + conditions?: Record; + actions?: Record; + isActive?: boolean; + }, + ) { + const existing = await this.prisma.rule.findFirst({ where: { id, userId } }); + if (!existing) throw new BadRequestException("Rule not found."); + return this.prisma.rule.update({ + where: { id }, + data: { + ...(payload.name !== undefined && { name: payload.name }), + ...(payload.priority !== undefined && { priority: payload.priority }), + ...(payload.conditions !== undefined && { conditions: payload.conditions as Prisma.InputJsonValue }), + ...(payload.actions !== undefined && { actions: payload.actions as Prisma.InputJsonValue }), + ...(payload.isActive !== undefined && { isActive: payload.isActive }), + }, + }); } - private matchesRule(rule: { conditions: Record }, tx: { description: string; amount: number }) { - const conditions = rule.conditions as Record; + private matchesRule( + conditions: Record, + tx: { description: string; amount: number | string }, + ): boolean { const textContains = typeof conditions.textContains === "string" ? conditions.textContains : ""; - const amountGreater = - typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null; - const amountLess = - typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null; + const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null; + const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null; - const description = tx.description.toLowerCase(); - if (textContains && !description.includes(textContains.toLowerCase())) { + if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) { return false; } const amount = Number(tx.amount); - if (amountGreater !== null && amount <= amountGreater) { - return false; - } - if (amountLess !== null && amount >= amountLess) { - return false; - } + if (amountGt !== null && amount <= amountGt) return false; + if (amountLt !== null && amount >= amountLt) return false; return true; } - async execute(id: string) { - const snapshot = await this.storage.load(); - const rule = snapshot.rules.find((item) => item.id === id); - if (!rule || !rule.isActive) { - return { id, status: "skipped" }; - } + async execute(userId: string, id: string) { + const rule = await this.prisma.rule.findFirst({ where: { id, userId } }); + if (!rule || !rule.isActive) return { id, status: "skipped" }; - const userAccounts = snapshot.accounts.filter((acct) => acct.userId === rule.userId); - const accountIds = new Set(userAccounts.map((acct) => acct.id)); - const transactions = snapshot.transactionsRaw.filter((tx) => accountIds.has(tx.accountId)); + const conditions = rule.conditions as Record; + const actions = rule.actions as Record; + + const transactions = await this.prisma.transactionRaw.findMany({ + where: { account: { userId } }, + include: { derived: true }, + }); let applied = 0; for (const tx of transactions) { - if (!this.matchesRule(rule, tx)) { + if (!this.matchesRule(conditions, { description: tx.description, amount: Number(tx.amount) })) { continue; } - const actions = rule.actions as Record; - const existingIndex = snapshot.transactionsDerived.findIndex( - (item) => item.rawTransactionId === tx.id - ); - const derived = { - id: - existingIndex >= 0 - ? snapshot.transactionsDerived[existingIndex].id - : this.storage.createId(), - rawTransactionId: tx.id, - userCategory: - typeof actions.setCategory === "string" ? actions.setCategory : undefined, - userNotes: - existingIndex >= 0 ? snapshot.transactionsDerived[existingIndex].userNotes : undefined, - isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false, - modifiedAt: this.storage.now(), - modifiedBy: "rule" - }; - if (existingIndex >= 0) { - snapshot.transactionsDerived[existingIndex] = derived; - } else { - snapshot.transactionsDerived.push(derived); - } - snapshot.ruleExecutions.push({ - id: this.storage.createId(), - ruleId: rule.id, - transactionId: tx.id, - executedAt: this.storage.now(), - result: { applied: true } + await this.prisma.transactionDerived.upsert({ + where: { rawTransactionId: tx.id }, + update: { + userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null, + isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false, + modifiedAt: new Date(), + modifiedBy: "rule", + }, + create: { + rawTransactionId: tx.id, + userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null, + isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false, + modifiedAt: new Date(), + modifiedBy: "rule", + }, }); + + await this.prisma.ruleExecution.create({ + data: { + ruleId: rule.id, + transactionId: tx.id, + result: { applied: true } as Prisma.InputJsonValue, + }, + }); + applied += 1; } - await this.storage.save(snapshot); return { id: rule.id, status: "completed", applied }; } - async suggest(userId?: string) { - if (!userId) { - return []; - } - const snapshot = await this.storage.load(); - const userAccounts = snapshot.accounts.filter((acct) => acct.userId === userId); - const accountIds = new Set(userAccounts.map((acct) => acct.id)); - const derived = snapshot.transactionsDerived - .filter((item) => { - const raw = snapshot.transactionsRaw.find((tx) => tx.id === item.rawTransactionId); - return raw && accountIds.has(raw.accountId) && item.userCategory; - }) - .slice(0, 200); + async suggest(userId: string) { + const derived = await this.prisma.transactionDerived.findMany({ + where: { + raw: { account: { userId } }, + userCategory: { not: null }, + }, + include: { raw: { select: { description: true } } }, + take: 200, + }); const bucket = new Map(); for (const item of derived) { - const raw = snapshot.transactionsRaw.find((tx) => tx.id === item.rawTransactionId); - if (!raw) { - continue; - } - const key = raw.description.toLowerCase(); + const key = item.raw.description.toLowerCase(); const category = item.userCategory ?? "Uncategorized"; const entry = bucket.get(key) ?? { category, count: 0 }; entry.count += 1; @@ -170,7 +154,7 @@ export class RulesService { name: `Auto: ${value.category}`, conditions: { textContains: description }, actions: { setCategory: value.category }, - confidence: Math.min(0.95, 0.5 + value.count * 0.1) + confidence: Math.min(0.95, 0.5 + value.count * 0.1), })); } } diff --git a/src/stripe/stripe.controller.ts b/src/stripe/stripe.controller.ts new file mode 100644 index 0000000..e1d66a6 --- /dev/null +++ b/src/stripe/stripe.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + Get, + Headers, + Post, + RawBodyRequest, + Req, +} from "@nestjs/common"; +import { Request } from "express"; +import { ok } from "../common/response"; +import { StripeService } from "./stripe.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { Public } from "../common/decorators/public.decorator"; +import { PrismaService } from "../prisma/prisma.service"; + +@Controller("billing") +export class StripeController { + constructor( + private readonly stripeService: StripeService, + private readonly prisma: PrismaService, + ) {} + + @Get("subscription") + async getSubscription(@CurrentUser() userId: string) { + const data = await this.stripeService.getSubscription(userId); + return ok(data); + } + + @Post("checkout") + async checkout(@CurrentUser() userId: string, @Body("priceId") priceId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + if (!user) return ok({ error: "User not found" }); + const data = await this.stripeService.createCheckoutSession(userId, user.email, priceId); + return ok(data); + } + + @Post("portal") + async portal(@CurrentUser() userId: string) { + const data = await this.stripeService.createPortalSession(userId); + return ok(data); + } + + @Public() + @Post("webhook") + async webhook( + @Req() req: RawBodyRequest, + @Headers("stripe-signature") signature: string, + ) { + const data = await this.stripeService.handleWebhook(req.rawBody!, signature); + return data; + } +} diff --git a/src/stripe/stripe.module.ts b/src/stripe/stripe.module.ts new file mode 100644 index 0000000..504ddb8 --- /dev/null +++ b/src/stripe/stripe.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { StripeController } from "./stripe.controller"; +import { StripeService } from "./stripe.service"; +import { SubscriptionGuard } from "./subscription.guard"; + +@Module({ + controllers: [StripeController], + providers: [StripeService, SubscriptionGuard], + exports: [StripeService, SubscriptionGuard], +}) +export class StripeModule {} diff --git a/src/stripe/stripe.service.ts b/src/stripe/stripe.service.ts new file mode 100644 index 0000000..ba248e0 --- /dev/null +++ b/src/stripe/stripe.service.ts @@ -0,0 +1,128 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import Stripe from "stripe"; +import { PrismaService } from "../prisma/prisma.service"; + +export const PLAN_LIMITS: Record = { + free: { accounts: 2, exports: 5 }, + pro: { accounts: 10, exports: 100 }, + elite: { accounts: -1, exports: -1 }, // -1 = unlimited +}; + +@Injectable() +export class StripeService { + private readonly stripe: Stripe; + private readonly logger = new Logger(StripeService.name); + + constructor(private readonly prisma: PrismaService) { + const key = process.env.STRIPE_SECRET_KEY; + // Allow empty key in dev mode — operations will fail gracefully when called + this.stripe = new Stripe(key || "sk_test_placeholder", { apiVersion: "2026-02-25.clover" }); + } + + async getOrCreateCustomer(userId: string, email: string): Promise { + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + if (sub?.stripeCustomerId) return sub.stripeCustomerId; + + const customer = await this.stripe.customers.create({ email, metadata: { userId } }); + await this.prisma.subscription.upsert({ + where: { userId }, + update: { stripeCustomerId: customer.id }, + create: { userId, plan: "free", stripeCustomerId: customer.id }, + }); + return customer.id; + } + + async createCheckoutSession(userId: string, email: string, priceId: string) { + const customerId = await this.getOrCreateCustomer(userId, email); + const session = await this.stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ["card"], + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${process.env.APP_URL}/settings/billing?success=true`, + cancel_url: `${process.env.APP_URL}/settings/billing?cancelled=true`, + metadata: { userId }, + }); + return { url: session.url }; + } + + async createPortalSession(userId: string) { + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + if (!sub?.stripeCustomerId) { + throw new BadRequestException("No Stripe customer found. Please upgrade first."); + } + const session = await this.stripe.billingPortal.sessions.create({ + customer: sub.stripeCustomerId, + return_url: `${process.env.APP_URL}/settings/billing`, + }); + return { url: session.url }; + } + + async getSubscription(userId: string) { + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + return sub ?? { userId, plan: "free" }; + } + + async handleWebhook(rawBody: Buffer, signature: string) { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required."); + + let event: Stripe.Event; + try { + event = this.stripe.webhooks.constructEvent(rawBody, signature, secret); + } catch (err) { + this.logger.warn(`Webhook signature verification failed: ${err}`); + throw new BadRequestException("Invalid webhook signature."); + } + + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": { + const subscription = event.data.object as Stripe.Subscription; + await this.syncSubscription(subscription); + break; + } + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + const customerId = subscription.customer as string; + const sub = await this.prisma.subscription.findFirst({ + where: { stripeCustomerId: customerId }, + }); + if (sub) { + await this.prisma.subscription.update({ + where: { userId: sub.userId }, + data: { plan: "free", stripeSubId: null, currentPeriodEnd: null, cancelAtPeriodEnd: false }, + }); + } + break; + } + default: + this.logger.debug(`Unhandled Stripe event: ${event.type}`); + } + + return { received: true }; + } + + private async syncSubscription(subscription: Stripe.Subscription) { + const customerId = subscription.customer as string; + const sub = await this.prisma.subscription.findFirst({ + where: { stripeCustomerId: customerId }, + }); + if (!sub) return; + + const priceId = subscription.items.data[0]?.price.id; + let plan = "free"; + if (priceId === process.env.STRIPE_PRICE_PRO) plan = "pro"; + else if (priceId === process.env.STRIPE_PRICE_ELITE) plan = "elite"; + + await this.prisma.subscription.update({ + where: { userId: sub.userId }, + data: { + plan, + stripeSubId: subscription.id, + currentPeriodEnd: new Date(subscription.billing_cycle_anchor * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }, + }); + } +} diff --git a/src/stripe/subscription.guard.ts b/src/stripe/subscription.guard.ts new file mode 100644 index 0000000..f98ec42 --- /dev/null +++ b/src/stripe/subscription.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; +import { PrismaService } from "../prisma/prisma.service"; + +export const REQUIRED_PLAN_KEY = "requiredPlan"; +export const RequiredPlan = (plan: "pro" | "elite") => + SetMetadata(REQUIRED_PLAN_KEY, plan); + +const PLAN_RANK: Record = { free: 0, pro: 1, elite: 2 }; + +@Injectable() +export class SubscriptionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const required = this.reflector.getAllAndOverride(REQUIRED_PLAN_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required) return true; + + const request = context.switchToHttp().getRequest(); + const userId = request.user?.sub; + if (!userId) throw new ForbiddenException("Authentication required."); + + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + const currentPlan = sub?.plan ?? "free"; + const currentRank = PLAN_RANK[currentPlan] ?? 0; + const requiredRank = PLAN_RANK[required] ?? 0; + + if (currentRank < requiredRank) { + throw new ForbiddenException(`This feature requires a ${required} plan.`); + } + return true; + } +} diff --git a/src/tax/dto/create-return.dto.ts b/src/tax/dto/create-return.dto.ts index 5fc59f4..67b7372 100644 --- a/src/tax/dto/create-return.dto.ts +++ b/src/tax/dto/create-return.dto.ts @@ -1,6 +1,14 @@ -export type CreateTaxReturnDto = { - userId: string; - taxYear: number; - filingType: "individual" | "business"; - jurisdictions: string[]; -}; +import { IsArray, IsIn, IsInt, IsString } from "class-validator"; + +export class CreateTaxReturnDto { + @IsInt() + taxYear!: number; + + @IsString() + @IsIn(["individual", "business"]) + filingType!: "individual" | "business"; + + @IsArray() + @IsString({ each: true }) + jurisdictions!: string[]; +} diff --git a/src/tax/dto/update-return.dto.ts b/src/tax/dto/update-return.dto.ts index 1a4a357..53d568d 100644 --- a/src/tax/dto/update-return.dto.ts +++ b/src/tax/dto/update-return.dto.ts @@ -1,4 +1,12 @@ -export type UpdateTaxReturnDto = { +import { IsIn, IsObject, IsOptional, IsString } from "class-validator"; + +export class UpdateTaxReturnDto { + @IsOptional() + @IsString() + @IsIn(["draft", "ready", "exported"]) status?: "draft" | "ready" | "exported"; + + @IsOptional() + @IsObject() summary?: Record; -}; +} diff --git a/src/tax/tax.controller.ts b/src/tax/tax.controller.ts index 9eb5519..0ec0437 100644 --- a/src/tax/tax.controller.ts +++ b/src/tax/tax.controller.ts @@ -1,43 +1,49 @@ -import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common"; import { ok } from "../common/response"; import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { UpdateTaxReturnDto } from "./dto/update-return.dto"; import { TaxService } from "./tax.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; @Controller("tax") export class TaxController { constructor(private readonly taxService: TaxService) {} @Get("returns") - async listReturns(@Query("user_id") userId?: string) { + async listReturns(@CurrentUser() userId: string) { const data = await this.taxService.listReturns(userId); return ok(data); } @Post("returns") - async createReturn(@Body() payload: CreateTaxReturnDto) { - const data = await this.taxService.createReturn(payload); + async createReturn(@CurrentUser() userId: string, @Body() payload: CreateTaxReturnDto) { + const data = await this.taxService.createReturn(userId, payload); return ok(data); } @Patch("returns/:id") - async updateReturn(@Param("id") id: string, @Body() payload: UpdateTaxReturnDto) { - const data = await this.taxService.updateReturn(id, payload); + async updateReturn( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: UpdateTaxReturnDto, + ) { + const data = await this.taxService.updateReturn(userId, id, payload); return ok(data); } @Post("returns/:id/documents") async addDocument( + @CurrentUser() userId: string, @Param("id") id: string, - @Body() payload: { docType: string; metadata: Record } + @Body() payload: { docType: string; metadata?: Record }, ) { - const data = await this.taxService.addDocument(id, payload.docType, payload.metadata ?? {}); + const data = await this.taxService.addDocument(userId, id, payload.docType, payload.metadata ?? {}); return ok(data); } @Post("returns/:id/export") - async exportReturn(@Param("id") id: string) { - const data = await this.taxService.exportReturn(id); + async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) { + const data = await this.taxService.exportReturn(userId, id); return ok(data); } } diff --git a/src/tax/tax.module.ts b/src/tax/tax.module.ts index 76eff3e..ecd4b89 100644 --- a/src/tax/tax.module.ts +++ b/src/tax/tax.module.ts @@ -4,6 +4,6 @@ import { TaxService } from "./tax.service"; @Module({ controllers: [TaxController], - providers: [TaxService] + providers: [TaxService], }) export class TaxModule {} diff --git a/src/tax/tax.service.ts b/src/tax/tax.service.ts index 2fcf54e..33089eb 100644 --- a/src/tax/tax.service.ts +++ b/src/tax/tax.service.ts @@ -1,84 +1,75 @@ -import { Injectable } from "@nestjs/common"; -import { StorageService } from "../storage/storage.service"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { UpdateTaxReturnDto } from "./dto/update-return.dto"; @Injectable() export class TaxService { - constructor(private readonly storage: StorageService) {} + constructor(private readonly prisma: PrismaService) {} - async listReturns(userId?: string) { - const snapshot = await this.storage.load(); - return userId - ? snapshot.taxReturns.filter((ret) => ret.userId === userId) - : []; + async listReturns(userId: string) { + return this.prisma.taxReturn.findMany({ + where: { userId }, + include: { documents: true }, + orderBy: { createdAt: "desc" }, + }); } - async createReturn(payload: CreateTaxReturnDto) { - const snapshot = await this.storage.load(); - const now = this.storage.now(); - const next = { - id: this.storage.createId(), - userId: payload.userId, - taxYear: payload.taxYear, - filingType: payload.filingType, - jurisdictions: payload.jurisdictions, - status: "draft" as const, - createdAt: now, - updatedAt: now, - summary: {} - }; - snapshot.taxReturns.push(next); - await this.storage.save(snapshot); - return next; + async createReturn(userId: string, payload: CreateTaxReturnDto) { + return this.prisma.taxReturn.create({ + data: { + userId, + taxYear: payload.taxYear, + filingType: payload.filingType, + jurisdictions: payload.jurisdictions as Prisma.InputJsonValue, + status: "draft", + summary: {}, + }, + }); } - async updateReturn(id: string, payload: UpdateTaxReturnDto) { - const snapshot = await this.storage.load(); - const index = snapshot.taxReturns.findIndex((ret) => ret.id === id); - if (index === -1) { - return null; - } - const existing = snapshot.taxReturns[index]; - const next = { - ...existing, - status: payload.status ?? existing.status, - summary: payload.summary ?? existing.summary, - updatedAt: this.storage.now() - }; - snapshot.taxReturns[index] = next; - await this.storage.save(snapshot); - return next; + async updateReturn(userId: string, id: string, payload: UpdateTaxReturnDto) { + const existing = await this.prisma.taxReturn.findFirst({ where: { id, userId } }); + if (!existing) throw new BadRequestException("Tax return not found."); + return this.prisma.taxReturn.update({ + where: { id }, + data: { + ...(payload.status !== undefined && { status: payload.status }), + ...(payload.summary !== undefined && { summary: payload.summary as Prisma.InputJsonValue }), + }, + }); } - async addDocument(returnId: string, docType: string, metadata: Record) { - const snapshot = await this.storage.load(); - const doc = { - id: this.storage.createId(), - taxReturnId: returnId, - docType, - metadata, - createdAt: this.storage.now() - }; - snapshot.taxDocuments.push(doc); - await this.storage.save(snapshot); - return doc; + async addDocument( + userId: string, + returnId: string, + docType: string, + metadata: Record, + ) { + const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id: returnId, userId } }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + return this.prisma.taxDocument.create({ + data: { + taxReturnId: returnId, + docType, + metadata: metadata as Prisma.InputJsonValue, + }, + }); } - async exportReturn(id: string) { - const snapshot = await this.storage.load(); - const taxReturn = snapshot.taxReturns.find((ret) => ret.id === id); - if (!taxReturn) { - return null; - } - const docs = snapshot.taxDocuments.filter((doc) => doc.taxReturnId === id); - const payload = { - return: taxReturn, - documents: docs - }; - taxReturn.status = "exported"; - taxReturn.updatedAt = this.storage.now(); - await this.storage.save(snapshot); - return payload; + async exportReturn(userId: string, id: string) { + const taxReturn = await this.prisma.taxReturn.findFirst({ + where: { id, userId }, + include: { documents: true }, + }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + + await this.prisma.taxReturn.update({ + where: { id }, + data: { status: "exported" }, + }); + + return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents }; } } diff --git a/src/transactions/dto/create-manual-transaction.dto.ts b/src/transactions/dto/create-manual-transaction.dto.ts index 71bfe55..ace5f19 100644 --- a/src/transactions/dto/create-manual-transaction.dto.ts +++ b/src/transactions/dto/create-manual-transaction.dto.ts @@ -1,10 +1,28 @@ -export type CreateManualTransactionDto = { - userId: string; +import { IsBoolean, IsNumber, IsOptional, IsString, IsDateString } from "class-validator"; + +export class CreateManualTransactionDto { + @IsOptional() + @IsString() accountId?: string; - date: string; - description: string; - amount: number; + + @IsDateString() + date!: string; + + @IsString() + description!: string; + + @IsNumber() + amount!: number; + + @IsOptional() + @IsString() category?: string; + + @IsOptional() + @IsString() note?: string; + + @IsOptional() + @IsBoolean() hidden?: boolean; -}; +} diff --git a/src/transactions/transactions.controller.ts b/src/transactions/transactions.controller.ts index 5c522dd..9037d8d 100644 --- a/src/transactions/transactions.controller.ts +++ b/src/transactions/transactions.controller.ts @@ -1,86 +1,128 @@ -import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; import { ok } from "../common/response"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { TransactionsService } from "./transactions.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; @Controller("transactions") export class TransactionsController { constructor(private readonly transactionsService: TransactionsService) {} @Get() - async list(@Query() query: Record) { - const data = await this.transactionsService.list({ - userId: query.user_id, - startDate: query.start_date, - endDate: query.end_date, - accountId: query.account_id, - minAmount: query.min_amount, - maxAmount: query.max_amount, - category: query.category, - source: query.source, - search: query.search, - includeHidden: query.include_hidden + async list( + @CurrentUser() userId: string, + @Query("start_date") startDate?: string, + @Query("end_date") endDate?: string, + @Query("account_id") accountId?: string, + @Query("min_amount") minAmount?: string, + @Query("max_amount") maxAmount?: string, + @Query("category") category?: string, + @Query("source") source?: string, + @Query("search") search?: string, + @Query("include_hidden") includeHidden?: string, + @Query("page") page = 1, + @Query("limit") limit = 50, + ) { + const data = await this.transactionsService.list(userId, { + startDate, + endDate, + accountId, + minAmount, + maxAmount, + category, + source, + search, + includeHidden, + page: +page, + limit: +limit, }); return ok(data); } @Post("import") - async importCsv() { - const data = await this.transactionsService.importCsv(); + @UseInterceptors(FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } })) + async importCsv( + @CurrentUser() userId: string, + @UploadedFile() file: Express.Multer.File, + ) { + const data = await this.transactionsService.importCsv(userId, file); return ok(data); } @Post("sync") - async sync(@Body() payload: { userId: string; startDate?: string; endDate?: string }) { - const endDate = payload.endDate ?? new Date().toISOString().slice(0, 10); - const startDate = - payload.startDate ?? - new Date(new Date().setDate(new Date(endDate).getDate() - 30)) - .toISOString() - .slice(0, 10); - const data = await this.transactionsService.sync(payload.userId, startDate, endDate); + async sync( + @CurrentUser() userId: string, + @Body("startDate") startDate?: string, + @Body("endDate") endDate?: string, + ) { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10); + const data = await this.transactionsService.sync(userId, start, end); return ok(data); } @Post("manual") - async manual(@Body() payload: CreateManualTransactionDto) { - const data = await this.transactionsService.createManualTransaction(payload); + async manual( + @CurrentUser() userId: string, + @Body() payload: CreateManualTransactionDto, + ) { + const data = await this.transactionsService.createManualTransaction(userId, payload); return ok(data); } @Get("summary") - async summary(@Query() query: Record) { - const endDate = query.end_date ?? new Date().toISOString().slice(0, 10); - const startDate = - query.start_date ?? - new Date(new Date().setDate(new Date(endDate).getDate() - 30)) - .toISOString() - .slice(0, 10); - const data = await this.transactionsService.summary(query.user_id ?? "", startDate, endDate); + async summary( + @CurrentUser() userId: string, + @Query("start_date") startDate?: string, + @Query("end_date") endDate?: string, + ) { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10); + const data = await this.transactionsService.summary(userId, start, end); return ok(data); } @Get("cashflow") - async cashflow(@Query() query: Record) { - const months = query.months ? Number(query.months) : 6; - const data = await this.transactionsService.cashflow(query.user_id ?? "", months); + async cashflow( + @CurrentUser() userId: string, + @Query("months") months = 6, + ) { + const data = await this.transactionsService.cashflow(userId, +months); return ok(data); } @Get("merchants") - async merchants(@Query() query: Record) { - const limit = query.limit ? Number(query.limit) : 6; - const data = await this.transactionsService.merchantInsights( - query.user_id ?? "", - limit - ); + async merchants( + @CurrentUser() userId: string, + @Query("limit") limit = 6, + ) { + const data = await this.transactionsService.merchantInsights(userId, +limit); return ok(data); } @Patch(":id/derived") - async updateDerived(@Param("id") id: string, @Body() payload: UpdateDerivedDto) { - const data = await this.transactionsService.updateDerived(id, payload); + async updateDerived( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: UpdateDerivedDto, + ) { + const data = await this.transactionsService.updateDerived(userId, id, payload); return ok(data); } } diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index 45a42df..86ba147 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -1,96 +1,147 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import * as crypto from "crypto"; +import { parse } from "csv-parse/sync"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { PlaidService } from "../plaid/plaid.service"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; +const MAX_PAGE_SIZE = 100; + +// ─── Bank CSV format auto-detection ────────────────────────────────────────── +type ParsedRow = { date: string; description: string; amount: number }; + +function detectAndParse(buffer: Buffer): ParsedRow[] { + const text = buffer.toString("utf8").trim(); + const rows: Record[] = parse(text, { + columns: true, + skip_empty_lines: true, + trim: true, + bom: true, + }); + + if (!rows.length) return []; + const headers = Object.keys(rows[0]).map((h) => h.toLowerCase()); + + // Chase format: Transaction Date, Description, Amount + if (headers.includes("transaction date") && headers.includes("description") && headers.includes("amount")) { + return rows.map((r) => ({ + date: r["Transaction Date"] ?? r["transaction date"], + description: r["Description"] ?? r["description"], + amount: parseFloat(r["Amount"] ?? r["amount"] ?? "0"), + })).filter((r) => r.date && r.description); + } + + // Bank of America format: Date, Description, Amount, Running Bal. + if (headers.includes("date") && headers.includes("description") && headers.includes("amount") && headers.some((h) => h.includes("running"))) { + return rows.map((r) => ({ + date: r["Date"] ?? r["date"], + description: r["Description"] ?? r["description"], + amount: parseFloat((r["Amount"] ?? r["amount"] ?? "0").replace(/,/g, "")), + })).filter((r) => r.date && r.description); + } + + // Wells Fargo format: 5 unnamed columns — Date, Amount, *, *, Description + if (headers.length >= 5 && (headers[0] === "" || /^[0-9]/.test(rows[0][Object.keys(rows[0])[0]] ?? ""))) { + const keys = Object.keys(rows[0]); + return rows.map((r) => ({ + date: r[keys[0]], + description: r[keys[4]] ?? r[keys[3]], + amount: parseFloat((r[keys[1]] ?? "0").replace(/,/g, "")), + })).filter((r) => r.date && r.description); + } + + // Generic: look for date, amount, description columns + const dateKey = Object.keys(rows[0]).find((k) => /date/i.test(k)); + const amountKey = Object.keys(rows[0]).find((k) => /amount/i.test(k)); + const descKey = Object.keys(rows[0]).find((k) => /desc|memo|narr|payee/i.test(k)); + + if (dateKey && amountKey && descKey) { + return rows.map((r) => ({ + date: r[dateKey], + description: r[descKey], + amount: parseFloat((r[amountKey] ?? "0").replace(/[^0-9.-]/g, "")), + })).filter((r) => r.date && r.description); + } + + throw new BadRequestException("Unrecognized CSV format. Supported: Chase, Bank of America, Wells Fargo, or generic (date/amount/description columns)."); +} + @Injectable() export class TransactionsService { constructor( private readonly prisma: PrismaService, - private readonly plaidService: PlaidService + private readonly plaidService: PlaidService, ) {} - async list(filters: { - startDate?: string; - endDate?: string; - accountId?: string; - userId?: string; - minAmount?: string; - maxAmount?: string; - category?: string; - source?: string; - search?: string; - includeHidden?: string; - }) { + async list( + userId: string, + filters: { + startDate?: string; + endDate?: string; + accountId?: string; + minAmount?: string; + maxAmount?: string; + category?: string; + source?: string; + search?: string; + includeHidden?: string; + page?: number; + limit?: number; + }, + ) { const end = filters.endDate ? new Date(filters.endDate) : new Date(); const start = filters.startDate ? new Date(filters.startDate) : new Date(new Date().setDate(end.getDate() - 30)); - const where: Record = { - date: { gte: start, lte: end } + const where: Prisma.TransactionRawWhereInput = { + account: { userId }, + date: { gte: start, lte: end }, }; if (filters.minAmount || filters.maxAmount) { - const minAmount = filters.minAmount ? Number(filters.minAmount) : undefined; - const maxAmount = filters.maxAmount ? Number(filters.maxAmount) : undefined; - where.amount = { - gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount, - lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : maxAmount - }; + const min = filters.minAmount ? parseFloat(filters.minAmount) : undefined; + const max = filters.maxAmount ? parseFloat(filters.maxAmount) : undefined; + where.amount = { gte: min, lte: max }; } if (filters.category) { - where.derived = { - is: { - userCategory: { - contains: filters.category, - mode: "insensitive" - } - } - }; + where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } } }; } if (filters.source) { - where.source = { - contains: filters.source, - mode: "insensitive" - }; + where.source = { contains: filters.source, mode: "insensitive" }; } if (filters.search) { - where.description = { - contains: filters.search, - mode: "insensitive" - }; + where.description = { contains: filters.search, mode: "insensitive" }; } if (filters.accountId) { where.accountId = filters.accountId; } - if (filters.userId) { - where.account = { userId: filters.userId }; - } - if (filters.includeHidden !== "true") { - where.OR = [ - { derived: null }, - { derived: { isHidden: false } } - ]; + where.OR = [{ derived: null }, { derived: { isHidden: false } }]; } - const rows = await this.prisma.transactionRaw.findMany({ - where, - include: { derived: true }, - orderBy: { date: "desc" }, - take: 100 - }); + const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE); + const skip = ((filters.page ?? 1) - 1) * take; - return rows.map((row) => ({ + const [rows, total] = await Promise.all([ + this.prisma.transactionRaw.findMany({ + where, + include: { derived: true }, + orderBy: { date: "desc" }, + take, + skip, + }), + this.prisma.transactionRaw.count({ where }), + ]); + + const transactions = rows.map((row) => ({ id: row.id, name: row.description, amount: Number(row.amount).toFixed(2), @@ -98,25 +149,87 @@ export class TransactionsService { note: row.derived?.userNotes ?? "", status: row.derived?.modifiedBy ?? "raw", hidden: row.derived?.isHidden ?? false, - date: row.date.toISOString().slice(0, 10) + date: row.date.toISOString().slice(0, 10), + source: row.source, + accountId: row.accountId, })); + + return { transactions, total, page: filters.page ?? 1, limit: take }; } - async importCsv() { - return { status: "queued" }; - } + async importCsv(userId: string, file: Express.Multer.File) { + if (!file?.buffer) { + throw new BadRequestException("No file uploaded."); + } + if (!file.originalname.toLowerCase().endsWith(".csv")) { + throw new BadRequestException("File must be a CSV."); + } - async createManualTransaction(payload: CreateManualTransactionDto) { - const account = payload.accountId - ? await this.prisma.account.findFirst({ - where: { id: payload.accountId, userId: payload.userId } - }) - : await this.prisma.account.findFirst({ - where: { userId: payload.userId } + const rows = detectAndParse(file.buffer); + if (!rows.length) { + throw new BadRequestException("CSV file is empty or could not be parsed."); + } + + // Find or create a manual import account for this user + let account = await this.prisma.account.findFirst({ + where: { userId, institutionName: "CSV Import", plaidAccessToken: null }, + }); + if (!account) { + account = await this.prisma.account.create({ + data: { + userId, + institutionName: "CSV Import", + accountType: "checking", + isActive: true, + }, + }); + } + + let imported = 0; + let skipped = 0; + + for (const row of rows) { + const dateObj = new Date(row.date); + if (isNaN(dateObj.getTime())) { + skipped++; + continue; + } + const bankTransactionId = `csv_${crypto.createHash("sha256") + .update(`${userId}:${row.date}:${row.description}:${row.amount}`) + .digest("hex") + .slice(0, 16)}`; + + try { + await this.prisma.transactionRaw.upsert({ + where: { bankTransactionId }, + update: {}, + create: { + accountId: account.id, + bankTransactionId, + date: dateObj, + amount: row.amount, + description: row.description, + rawPayload: row as unknown as Prisma.InputJsonValue, + ingestedAt: new Date(), + source: "csv", + }, }); + imported++; + } catch { + skipped++; + } + } + + return { imported, skipped, total: rows.length }; + } + + async createManualTransaction(userId: string, payload: CreateManualTransactionDto) { + const account = payload.accountId + ? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } }) + : await this.prisma.account.findFirst({ where: { userId } }); if (!account) { - return null; + throw new BadRequestException("No account found for user."); } const id = crypto.randomUUID(); @@ -127,10 +240,10 @@ export class TransactionsService { date: new Date(payload.date), amount: payload.amount, description: payload.description, - rawPayload: payload as Prisma.InputJsonValue, + rawPayload: payload as unknown as Prisma.InputJsonValue, ingestedAt: new Date(), - source: "manual" - } + source: "manual", + }, }); if (payload.category || payload.note || payload.hidden) { @@ -141,15 +254,21 @@ export class TransactionsService { userNotes: payload.note ?? null, isHidden: payload.hidden ?? false, modifiedAt: new Date(), - modifiedBy: "user" - } + modifiedBy: "user", + }, }); } - return raw; + return { id: raw.id }; } - async updateDerived(id: string, payload: UpdateDerivedDto) { + async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) { + // Ensure the transaction belongs to the user + const tx = await this.prisma.transactionRaw.findFirst({ + where: { id, account: { userId } }, + }); + if (!tx) throw new BadRequestException("Transaction not found."); + return this.prisma.transactionDerived.upsert({ where: { rawTransactionId: id }, update: { @@ -157,7 +276,7 @@ export class TransactionsService { userNotes: payload.userNotes, isHidden: payload.isHidden ?? false, modifiedAt: new Date(), - modifiedBy: "user" + modifiedBy: "user", }, create: { rawTransactionId: id, @@ -165,8 +284,8 @@ export class TransactionsService { userNotes: payload.userNotes, isHidden: payload.isHidden ?? false, modifiedAt: new Date(), - modifiedBy: "user" - } + modifiedBy: "user", + }, }); } @@ -178,25 +297,25 @@ export class TransactionsService { const rows = await this.prisma.transactionRaw.findMany({ where: { account: { userId }, - date: { gte: new Date(startDate), lte: new Date(endDate) } - } + date: { gte: new Date(startDate), lte: new Date(endDate) }, + }, }); const total = rows.reduce((sum, row) => sum + Number(row.amount), 0); const income = rows.reduce( (sum, row) => sum + (Number(row.amount) < 0 ? Math.abs(Number(row.amount)) : 0), - 0 + 0, ); const expense = rows.reduce( (sum, row) => sum + (Number(row.amount) > 0 ? Number(row.amount) : 0), - 0 + 0, ); return { total: total.toFixed(2), count: rows.length, income: income.toFixed(2), expense: expense.toFixed(2), - net: (income - expense).toFixed(2) + net: (income - expense).toFixed(2), }; } @@ -204,7 +323,7 @@ export class TransactionsService { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1); const rows = await this.prisma.transactionRaw.findMany({ - where: { account: { userId }, date: { gte: start, lte: now } } + where: { account: { userId }, date: { gte: start, lte: now } }, }); const buckets = new Map(); @@ -217,49 +336,43 @@ export class TransactionsService { for (const row of rows) { const key = `${row.date.getFullYear()}-${String(row.date.getMonth() + 1).padStart(2, "0")}`; const bucket = buckets.get(key); - if (!bucket) { - continue; - } + if (!bucket) continue; const amount = Number(row.amount); - if (amount < 0) { - bucket.income += Math.abs(amount); - } else { - bucket.expense += amount; - } + if (amount < 0) bucket.income += Math.abs(amount); + else bucket.expense += amount; } return Array.from(buckets.entries()).map(([month, value]) => ({ month, income: value.income.toFixed(2), expense: value.expense.toFixed(2), - net: (value.income - value.expense).toFixed(2) + net: (value.income - value.expense).toFixed(2), })); } async merchantInsights(userId: string, limit = 6) { + const capped = Math.min(limit, MAX_PAGE_SIZE); const rows = await this.prisma.transactionRaw.findMany({ - where: { account: { userId } } + where: { account: { userId } }, + select: { description: true, amount: true }, }); const bucket = new Map(); for (const row of rows) { - const merchant = row.description; const amount = Number(row.amount); - if (amount <= 0) { - continue; - } - const entry = bucket.get(merchant) ?? { total: 0, count: 0 }; + if (amount <= 0) continue; + const entry = bucket.get(row.description) ?? { total: 0, count: 0 }; entry.total += amount; entry.count += 1; - bucket.set(merchant, entry); + bucket.set(row.description, entry); } return Array.from(bucket.entries()) .sort((a, b) => b[1].total - a[1].total) - .slice(0, limit) + .slice(0, capped) .map(([merchant, value]) => ({ merchant, total: value.total.toFixed(2), - count: value.count + count: value.count, })); } } diff --git a/write-2fa-speakeasy.mjs b/write-2fa-speakeasy.mjs new file mode 100644 index 0000000..dc9aa8f --- /dev/null +++ b/write-2fa-speakeasy.mjs @@ -0,0 +1,322 @@ +import { writeFileSync } from "fs"; + +writeFileSync("src/auth/twofa/two-factor.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common"; +import * as speakeasy from "speakeasy"; +import * as QRCode from "qrcode"; +import { PrismaService } from "../../prisma/prisma.service"; +import { EncryptionService } from "../../common/encryption.service"; + +@Injectable() +export class TwoFactorService { + constructor( + private readonly prisma: PrismaService, + private readonly encryption: EncryptionService, + ) {} + + async generateSecret(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, twoFactorEnabled: true }, + }); + if (!user) throw new BadRequestException("User not found."); + if (user.twoFactorEnabled) { + throw new BadRequestException("2FA is already enabled."); + } + + const secret = speakeasy.generateSecret({ name: \`LedgerOne:\${user.email}\`, length: 20 }); + const otpAuthUrl = secret.otpauth_url ?? ""; + const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl); + + // Store encrypted secret temporarily (not yet enabled) + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorSecret: this.encryption.encrypt(secret.base32) }, + }); + + return { qrCode: qrCodeDataUrl, otpAuthUrl }; + } + + async enableTwoFactor(userId: string, token: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecret: true, twoFactorEnabled: true }, + }); + if (!user?.twoFactorSecret) { + throw new BadRequestException("Please generate a 2FA secret first."); + } + if (user.twoFactorEnabled) { + throw new BadRequestException("2FA is already enabled."); + } + + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 }); + if (!isValid) { + throw new BadRequestException("Invalid TOTP token."); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: true }, + }); + await this.prisma.auditLog.create({ + data: { userId, action: "auth.2fa.enabled", metadata: {} }, + }); + + return { message: "2FA enabled successfully." }; + } + + async disableTwoFactor(userId: string, token: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecret: true, twoFactorEnabled: true }, + }); + if (!user?.twoFactorEnabled || !user.twoFactorSecret) { + throw new BadRequestException("2FA is not enabled."); + } + + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 }); + if (!isValid) { + throw new BadRequestException("Invalid TOTP token."); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: false, twoFactorSecret: null }, + }); + await this.prisma.auditLog.create({ + data: { userId, action: "auth.2fa.disabled", metadata: {} }, + }); + + return { message: "2FA disabled successfully." }; + } + + verifyToken(encryptedSecret: string, token: string): boolean { + const secret = this.encryption.decrypt(encryptedSecret); + return speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 }); + } +} +`); + +// Update auth.service.ts to use speakeasy instead of otplib +// (The login method needs to use speakeasy for 2FA verification) +// We need to update auth.service.ts to use speakeasy + +writeFileSync("src/auth/auth.service.ts", `import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; +import * as crypto from "crypto"; +import * as speakeasy from "speakeasy"; +import { JwtService } from "@nestjs/jwt"; +import { PrismaService } from "../prisma/prisma.service"; +import { EmailService } from "../email/email.service"; +import { EncryptionService } from "../common/encryption.service"; +import { LoginDto } from "./dto/login.dto"; +import { RegisterDto } from "./dto/register.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ForgotPasswordDto } from "./dto/forgot-password.dto"; +import { ResetPasswordDto } from "./dto/reset-password.dto"; + +const VERIFY_TOKEN_TTL_HOURS = 24; +const RESET_TOKEN_TTL_HOURS = 1; +const REFRESH_TOKEN_TTL_DAYS = 30; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly emailService: EmailService, + private readonly encryption: EncryptionService, + ) {} + + async register(payload: RegisterDto) { + const email = payload.email.toLowerCase().trim(); + const existing = await this.prisma.user.findUnique({ where: { email } }); + if (existing) throw new BadRequestException("Email already registered."); + + const passwordHash = this.hashPassword(payload.password); + const user = await this.prisma.user.create({ data: { email, passwordHash } }); + await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } }); + + const verifyToken = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000); + await this.prisma.emailVerificationToken.upsert({ + where: { userId: user.id }, + update: { token: verifyToken, expiresAt }, + create: { userId: user.id, token: verifyToken, expiresAt }, + }); + await this.emailService.sendVerificationEmail(email, verifyToken); + + const accessToken = this.signAccessToken(user.id); + const refreshToken = await this.createRefreshToken(user.id); + return { + user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, + accessToken, + refreshToken, + message: "Registration successful. Please verify your email.", + }; + } + + async login(payload: LoginDto) { + const email = payload.email.toLowerCase().trim(); + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user || !this.verifyPassword(payload.password, user.passwordHash)) { + throw new UnauthorizedException("Invalid credentials."); + } + + // ── 2FA enforcement ────────────────────────────────────────────────────── + if (user.twoFactorEnabled && user.twoFactorSecret) { + if (!payload.totpToken) { + return { requiresTwoFactor: true, accessToken: null, refreshToken: null }; + } + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token: payload.totpToken, window: 1 }); + if (!isValid) { + throw new UnauthorizedException("Invalid TOTP code."); + } + } + + await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } }); + const accessToken = this.signAccessToken(user.id); + const refreshToken = await this.createRefreshToken(user.id); + return { + user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, + accessToken, + refreshToken, + }; + } + + async verifyEmail(token: string) { + const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } }); + if (!record || record.expiresAt < new Date()) { + throw new BadRequestException("Invalid or expired verification token."); + } + await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } }); + await this.prisma.emailVerificationToken.delete({ where: { token } }); + return { message: "Email verified successfully." }; + } + + async refreshAccessToken(rawRefreshToken: string) { + const tokenHash = this.hashToken(rawRefreshToken); + const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } }); + if (!record || record.revokedAt || record.expiresAt < new Date()) { + throw new UnauthorizedException("Invalid or expired refresh token."); + } + await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } }); + const accessToken = this.signAccessToken(record.userId); + const refreshToken = await this.createRefreshToken(record.userId); + return { accessToken, refreshToken }; + } + + async logout(rawRefreshToken: string) { + const tokenHash = this.hashToken(rawRefreshToken); + await this.prisma.refreshToken.updateMany({ + where: { tokenHash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + return { message: "Logged out." }; + } + + async forgotPassword(payload: ForgotPasswordDto) { + const email = payload.email.toLowerCase().trim(); + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) return { message: "If that email exists, a reset link has been sent." }; + const token = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000); + await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } }); + await this.emailService.sendPasswordResetEmail(email, token); + return { message: "If that email exists, a reset link has been sent." }; + } + + async resetPassword(payload: ResetPasswordDto) { + const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } }); + if (!record || record.usedAt || record.expiresAt < new Date()) { + throw new BadRequestException("Invalid or expired reset token."); + } + const passwordHash = this.hashPassword(payload.password); + await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }); + await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } }); + await this.prisma.refreshToken.updateMany({ + where: { userId: record.userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + return { message: "Password reset successfully. Please log in." }; + } + + async updateProfile(userId: string, payload: UpdateProfileDto) { + const data: Record = {}; + for (const [key, value] of Object.entries(payload)) { + const trimmed = (value as string | undefined)?.trim(); + if (trimmed) data[key] = trimmed; + } + if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided."); + const user = await this.prisma.user.update({ where: { id: userId }, data }); + await this.prisma.auditLog.create({ + data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } }, + }); + return { + user: { + id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, + companyName: user.companyName, addressLine1: user.addressLine1, + addressLine2: user.addressLine2, city: user.city, state: user.state, + postalCode: user.postalCode, country: user.country, + }, + }; + } + + async getProfile(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException("User not found."); + return { + user: { + id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, + companyName: user.companyName, addressLine1: user.addressLine1, + addressLine2: user.addressLine2, city: user.city, state: user.state, + postalCode: user.postalCode, country: user.country, + emailVerified: user.emailVerified, + twoFactorEnabled: user.twoFactorEnabled, + createdAt: user.createdAt, + }, + }; + } + + verifyToken(token: string): { sub: string } { + return this.jwtService.verify<{ sub: string }>(token); + } + + private signAccessToken(userId: string): string { + return this.jwtService.sign({ sub: userId }); + } + + private async createRefreshToken(userId: string): Promise { + const raw = crypto.randomBytes(40).toString("hex"); + const tokenHash = this.hashToken(raw); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000); + await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } }); + return raw; + } + + private hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); + } + + private hashPassword(password: string): string { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); + return \`\${salt}:\${hash}\`; + } + + private verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(":"); + if (!salt || !hash) return false; + const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); + return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed)); + } +} +`); + +console.log("2FA service (speakeasy) + auth.service.ts written"); diff --git a/write-2fa.mjs b/write-2fa.mjs new file mode 100644 index 0000000..0406c6b --- /dev/null +++ b/write-2fa.mjs @@ -0,0 +1,150 @@ +import { writeFileSync, mkdirSync } from "fs"; + +mkdirSync("src/auth/twofa", { recursive: true }); + +// ─── two-factor.service.ts ─────────────────────────────────────────────────── +writeFileSync("src/auth/twofa/two-factor.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common"; +import { authenticator } from "otplib"; +import * as QRCode from "qrcode"; +import { PrismaService } from "../../prisma/prisma.service"; +import { EncryptionService } from "../../common/encryption.service"; + +@Injectable() +export class TwoFactorService { + constructor( + private readonly prisma: PrismaService, + private readonly encryption: EncryptionService, + ) {} + + async generateSecret(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, twoFactorEnabled: true }, + }); + if (!user) throw new BadRequestException("User not found."); + if (user.twoFactorEnabled) { + throw new BadRequestException("2FA is already enabled."); + } + + const secret = authenticator.generateSecret(); + const otpAuthUrl = authenticator.keyuri(user.email, "LedgerOne", secret); + const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl); + + // Store encrypted secret temporarily (not yet enabled) + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorSecret: this.encryption.encrypt(secret) }, + }); + + return { qrCode: qrCodeDataUrl, otpAuthUrl }; + } + + async enableTwoFactor(userId: string, token: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecret: true, twoFactorEnabled: true }, + }); + if (!user?.twoFactorSecret) { + throw new BadRequestException("Please generate a 2FA secret first."); + } + if (user.twoFactorEnabled) { + throw new BadRequestException("2FA is already enabled."); + } + + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = authenticator.verify({ token, secret }); + if (!isValid) { + throw new BadRequestException("Invalid TOTP token."); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: true }, + }); + await this.prisma.auditLog.create({ + data: { userId, action: "auth.2fa.enabled", metadata: {} }, + }); + + return { message: "2FA enabled successfully." }; + } + + async disableTwoFactor(userId: string, token: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecret: true, twoFactorEnabled: true }, + }); + if (!user?.twoFactorEnabled || !user.twoFactorSecret) { + throw new BadRequestException("2FA is not enabled."); + } + + const secret = this.encryption.decrypt(user.twoFactorSecret); + const isValid = authenticator.verify({ token, secret }); + if (!isValid) { + throw new BadRequestException("Invalid TOTP token."); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: false, twoFactorSecret: null }, + }); + await this.prisma.auditLog.create({ + data: { userId, action: "auth.2fa.disabled", metadata: {} }, + }); + + return { message: "2FA disabled successfully." }; + } + + verifyToken(secret: string, token: string): boolean { + return authenticator.verify({ token, secret }); + } + + decryptSecret(encryptedSecret: string): string { + return this.encryption.decrypt(encryptedSecret); + } +} +`); + +// ─── two-factor.controller.ts ──────────────────────────────────────────────── +writeFileSync("src/auth/twofa/two-factor.controller.ts", `import { Body, Controller, Delete, Post } from "@nestjs/common"; +import { ok } from "../../common/response"; +import { TwoFactorService } from "./two-factor.service"; +import { CurrentUser } from "../../common/decorators/current-user.decorator"; + +@Controller("auth/2fa") +export class TwoFactorController { + constructor(private readonly twoFactorService: TwoFactorService) {} + + @Post("generate") + async generate(@CurrentUser() userId: string) { + const data = await this.twoFactorService.generateSecret(userId); + return ok(data); + } + + @Post("enable") + async enable(@CurrentUser() userId: string, @Body("token") token: string) { + const data = await this.twoFactorService.enableTwoFactor(userId, token); + return ok(data); + } + + @Delete("disable") + async disable(@CurrentUser() userId: string, @Body("token") token: string) { + const data = await this.twoFactorService.disableTwoFactor(userId, token); + return ok(data); + } +} +`); + +// ─── two-factor.module.ts ──────────────────────────────────────────────────── +writeFileSync("src/auth/twofa/two-factor.module.ts", `import { Module } from "@nestjs/common"; +import { TwoFactorController } from "./two-factor.controller"; +import { TwoFactorService } from "./two-factor.service"; + +@Module({ + controllers: [TwoFactorController], + providers: [TwoFactorService], + exports: [TwoFactorService], +}) +export class TwoFactorModule {} +`); + +console.log("2FA files written"); diff --git a/write-files.mjs b/write-files.mjs new file mode 100644 index 0000000..c946592 --- /dev/null +++ b/write-files.mjs @@ -0,0 +1,295 @@ +import { writeFileSync } from "fs"; + +// ─── auth.service.ts ───────────────────────────────────────────────────────── +writeFileSync("src/auth/auth.service.ts", `import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; +import * as crypto from "crypto"; +import { JwtService } from "@nestjs/jwt"; +import { PrismaService } from "../prisma/prisma.service"; +import { EmailService } from "../email/email.service"; +import { LoginDto } from "./dto/login.dto"; +import { RegisterDto } from "./dto/register.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ForgotPasswordDto } from "./dto/forgot-password.dto"; +import { ResetPasswordDto } from "./dto/reset-password.dto"; + +const VERIFY_TOKEN_TTL_HOURS = 24; +const RESET_TOKEN_TTL_HOURS = 1; +const REFRESH_TOKEN_TTL_DAYS = 30; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly emailService: EmailService, + ) {} + + async register(payload: RegisterDto) { + const email = payload.email.toLowerCase().trim(); + const existing = await this.prisma.user.findUnique({ where: { email } }); + if (existing) throw new BadRequestException("Email already registered."); + + const passwordHash = this.hashPassword(payload.password); + const user = await this.prisma.user.create({ data: { email, passwordHash } }); + await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } }); + + const verifyToken = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000); + await this.prisma.emailVerificationToken.upsert({ + where: { userId: user.id }, + update: { token: verifyToken, expiresAt }, + create: { userId: user.id, token: verifyToken, expiresAt }, + }); + await this.emailService.sendVerificationEmail(email, verifyToken); + + const accessToken = this.signAccessToken(user.id); + const refreshToken = await this.createRefreshToken(user.id); + return { + user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, + accessToken, + refreshToken, + message: "Registration successful. Please verify your email.", + }; + } + + async login(payload: LoginDto) { + const email = payload.email.toLowerCase().trim(); + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user || !this.verifyPassword(payload.password, user.passwordHash)) { + throw new UnauthorizedException("Invalid credentials."); + } + await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } }); + const accessToken = this.signAccessToken(user.id); + const refreshToken = await this.createRefreshToken(user.id); + return { + user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, + accessToken, + refreshToken, + }; + } + + async verifyEmail(token: string) { + const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } }); + if (!record || record.expiresAt < new Date()) { + throw new BadRequestException("Invalid or expired verification token."); + } + await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } }); + await this.prisma.emailVerificationToken.delete({ where: { token } }); + return { message: "Email verified successfully." }; + } + + async refreshAccessToken(rawRefreshToken: string) { + const tokenHash = this.hashToken(rawRefreshToken); + const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } }); + if (!record || record.revokedAt || record.expiresAt < new Date()) { + throw new UnauthorizedException("Invalid or expired refresh token."); + } + await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } }); + const accessToken = this.signAccessToken(record.userId); + const refreshToken = await this.createRefreshToken(record.userId); + return { accessToken, refreshToken }; + } + + async logout(rawRefreshToken: string) { + const tokenHash = this.hashToken(rawRefreshToken); + await this.prisma.refreshToken.updateMany({ + where: { tokenHash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + return { message: "Logged out." }; + } + + async forgotPassword(payload: ForgotPasswordDto) { + const email = payload.email.toLowerCase().trim(); + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) return { message: "If that email exists, a reset link has been sent." }; + const token = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000); + await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } }); + await this.emailService.sendPasswordResetEmail(email, token); + return { message: "If that email exists, a reset link has been sent." }; + } + + async resetPassword(payload: ResetPasswordDto) { + const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } }); + if (!record || record.usedAt || record.expiresAt < new Date()) { + throw new BadRequestException("Invalid or expired reset token."); + } + const passwordHash = this.hashPassword(payload.password); + await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }); + await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } }); + await this.prisma.refreshToken.updateMany({ + where: { userId: record.userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + return { message: "Password reset successfully. Please log in." }; + } + + async updateProfile(userId: string, payload: UpdateProfileDto) { + const data: Record = {}; + for (const [key, value] of Object.entries(payload)) { + const trimmed = (value as string | undefined)?.trim(); + if (trimmed) data[key] = trimmed; + } + if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided."); + const user = await this.prisma.user.update({ where: { id: userId }, data }); + await this.prisma.auditLog.create({ + data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } }, + }); + return { + user: { + id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, + companyName: user.companyName, addressLine1: user.addressLine1, + addressLine2: user.addressLine2, city: user.city, state: user.state, + postalCode: user.postalCode, country: user.country, + }, + }; + } + + async getProfile(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException("User not found."); + return { + user: { + id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, + companyName: user.companyName, addressLine1: user.addressLine1, + addressLine2: user.addressLine2, city: user.city, state: user.state, + postalCode: user.postalCode, country: user.country, + emailVerified: user.emailVerified, createdAt: user.createdAt, + }, + }; + } + + verifyToken(token: string): { sub: string } { + return this.jwtService.verify<{ sub: string }>(token); + } + + private signAccessToken(userId: string): string { + return this.jwtService.sign({ sub: userId }); + } + + private async createRefreshToken(userId: string): Promise { + const raw = crypto.randomBytes(40).toString("hex"); + const tokenHash = this.hashToken(raw); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000); + await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } }); + return raw; + } + + private hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); + } + + private hashPassword(password: string): string { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); + return \`\${salt}:\${hash}\`; + } + + private verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(":"); + if (!salt || !hash) return false; + const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); + return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed)); + } +} +`); + +// ─── auth.controller.ts ────────────────────────────────────────────────────── +writeFileSync("src/auth/auth.controller.ts", `import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common"; +import { ok } from "../common/response"; +import { AuthService } from "./auth.service"; +import { LoginDto } from "./dto/login.dto"; +import { RegisterDto } from "./dto/register.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ForgotPasswordDto } from "./dto/forgot-password.dto"; +import { ResetPasswordDto } from "./dto/reset-password.dto"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { Public } from "../common/decorators/public.decorator"; + +@Controller("auth") +@UseGuards(JwtAuthGuard) +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post("register") + async register(@Body() payload: RegisterDto) { + return ok(await this.authService.register(payload)); + } + + @Public() + @Post("login") + async login(@Body() payload: LoginDto) { + return ok(await this.authService.login(payload)); + } + + @Public() + @Get("verify-email") + async verifyEmail(@Query("token") token: string) { + return ok(await this.authService.verifyEmail(token)); + } + + @Public() + @Post("refresh") + async refresh(@Body("refreshToken") refreshToken: string) { + return ok(await this.authService.refreshAccessToken(refreshToken)); + } + + @Post("logout") + async logout(@Body("refreshToken") refreshToken: string) { + return ok(await this.authService.logout(refreshToken)); + } + + @Public() + @Post("forgot-password") + async forgotPassword(@Body() payload: ForgotPasswordDto) { + return ok(await this.authService.forgotPassword(payload)); + } + + @Public() + @Post("reset-password") + async resetPassword(@Body() payload: ResetPasswordDto) { + return ok(await this.authService.resetPassword(payload)); + } + + @Get("me") + async me(@CurrentUser() userId: string) { + return ok(await this.authService.getProfile(userId)); + } + + @Patch("profile") + async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) { + return ok(await this.authService.updateProfile(userId, payload)); + } +} +`); + +// ─── auth.module.ts ────────────────────────────────────────────────────────── +writeFileSync("src/auth/auth.module.ts", `import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; + +@Module({ + imports: [ + JwtModule.register({ + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: "15m" }, + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtAuthGuard], + exports: [AuthService, JwtModule, JwtAuthGuard], +}) +export class AuthModule {} +`); + +console.log("auth files written"); diff --git a/write-rules-tax.mjs b/write-rules-tax.mjs new file mode 100644 index 0000000..92d27c5 --- /dev/null +++ b/write-rules-tax.mjs @@ -0,0 +1,414 @@ +import { writeFileSync } from "fs"; + +// ─── rules.service.ts (Prisma) ─────────────────────────────────────────────── +writeFileSync("src/rules/rules.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class RulesService { + constructor(private readonly prisma: PrismaService) {} + + async list(userId: string) { + return this.prisma.rule.findMany({ + where: { userId, isActive: true }, + orderBy: { priority: "asc" }, + }); + } + + async create( + userId: string, + payload: { + name: string; + priority?: number; + conditions: Record; + actions: Record; + isActive?: boolean; + }, + ) { + const count = await this.prisma.rule.count({ where: { userId } }); + return this.prisma.rule.create({ + data: { + userId, + name: payload.name ?? "Untitled rule", + priority: payload.priority ?? count + 1, + conditions: payload.conditions as Prisma.InputJsonValue, + actions: payload.actions as Prisma.InputJsonValue, + isActive: payload.isActive !== false, + }, + }); + } + + async update( + userId: string, + id: string, + payload: { + name?: string; + priority?: number; + conditions?: Record; + actions?: Record; + isActive?: boolean; + }, + ) { + const existing = await this.prisma.rule.findFirst({ where: { id, userId } }); + if (!existing) throw new BadRequestException("Rule not found."); + return this.prisma.rule.update({ + where: { id }, + data: { + ...(payload.name !== undefined && { name: payload.name }), + ...(payload.priority !== undefined && { priority: payload.priority }), + ...(payload.conditions !== undefined && { conditions: payload.conditions as Prisma.InputJsonValue }), + ...(payload.actions !== undefined && { actions: payload.actions as Prisma.InputJsonValue }), + ...(payload.isActive !== undefined && { isActive: payload.isActive }), + }, + }); + } + + private matchesRule( + conditions: Record, + tx: { description: string; amount: number | string }, + ): boolean { + const textContains = typeof conditions.textContains === "string" ? conditions.textContains : ""; + const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null; + const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null; + + if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) { + return false; + } + const amount = Number(tx.amount); + if (amountGt !== null && amount <= amountGt) return false; + if (amountLt !== null && amount >= amountLt) return false; + return true; + } + + async execute(userId: string, id: string) { + const rule = await this.prisma.rule.findFirst({ where: { id, userId } }); + if (!rule || !rule.isActive) return { id, status: "skipped" }; + + const conditions = rule.conditions as Record; + const actions = rule.actions as Record; + + const transactions = await this.prisma.transactionRaw.findMany({ + where: { account: { userId } }, + include: { derived: true }, + }); + + let applied = 0; + for (const tx of transactions) { + if (!this.matchesRule(conditions, { description: tx.description, amount: tx.amount })) { + continue; + } + + await this.prisma.transactionDerived.upsert({ + where: { rawTransactionId: tx.id }, + update: { + userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null, + isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false, + modifiedAt: new Date(), + modifiedBy: "rule", + }, + create: { + rawTransactionId: tx.id, + userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null, + isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false, + modifiedAt: new Date(), + modifiedBy: "rule", + }, + }); + + await this.prisma.ruleExecution.create({ + data: { + ruleId: rule.id, + transactionId: tx.id, + result: { applied: true } as Prisma.InputJsonValue, + }, + }); + + applied += 1; + } + + return { id: rule.id, status: "completed", applied }; + } + + async suggest(userId: string) { + const derived = await this.prisma.transactionDerived.findMany({ + where: { + raw: { account: { userId } }, + userCategory: { not: null }, + }, + include: { raw: { select: { description: true } } }, + take: 200, + }); + + const bucket = new Map(); + for (const item of derived) { + const key = item.raw.description.toLowerCase(); + const category = item.userCategory ?? "Uncategorized"; + const entry = bucket.get(key) ?? { category, count: 0 }; + entry.count += 1; + bucket.set(key, entry); + } + + return Array.from(bucket.entries()) + .filter(([, value]) => value.count >= 2) + .slice(0, 5) + .map(([description, value], index) => ({ + id: \`suggestion_\${index + 1}\`, + name: \`Auto: \${value.category}\`, + conditions: { textContains: description }, + actions: { setCategory: value.category }, + confidence: Math.min(0.95, 0.5 + value.count * 0.1), + })); + } +} +`); + +// ─── rules.controller.ts ───────────────────────────────────────────────────── +writeFileSync("src/rules/rules.controller.ts", `import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common"; +import { ok } from "../common/response"; +import { RulesService } from "./rules.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +@Controller("rules") +export class RulesController { + constructor(private readonly rulesService: RulesService) {} + + @Get() + async list(@CurrentUser() userId: string) { + const data = await this.rulesService.list(userId); + return ok(data); + } + + @Post() + async create( + @CurrentUser() userId: string, + @Body() + payload: { + name: string; + priority?: number; + conditions: Record; + actions: Record; + isActive?: boolean; + }, + ) { + const data = await this.rulesService.create(userId, payload); + return ok(data); + } + + @Put(":id") + async update( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() + payload: { + name?: string; + priority?: number; + conditions?: Record; + actions?: Record; + isActive?: boolean; + }, + ) { + const data = await this.rulesService.update(userId, id, payload); + return ok(data); + } + + @Post(":id/execute") + async execute(@CurrentUser() userId: string, @Param("id") id: string) { + const data = await this.rulesService.execute(userId, id); + return ok(data); + } + + @Get("suggestions") + async suggestions(@CurrentUser() userId: string) { + const data = await this.rulesService.suggest(userId); + return ok(data); + } +} +`); + +// ─── rules.module.ts ───────────────────────────────────────────────────────── +writeFileSync("src/rules/rules.module.ts", `import { Module } from "@nestjs/common"; +import { RulesController } from "./rules.controller"; +import { RulesService } from "./rules.service"; + +@Module({ + controllers: [RulesController], + providers: [RulesService], +}) +export class RulesModule {} +`); + +// ─── tax.service.ts (Prisma) ───────────────────────────────────────────────── +writeFileSync("src/tax/tax.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { CreateTaxReturnDto } from "./dto/create-return.dto"; +import { UpdateTaxReturnDto } from "./dto/update-return.dto"; + +@Injectable() +export class TaxService { + constructor(private readonly prisma: PrismaService) {} + + async listReturns(userId: string) { + return this.prisma.taxReturn.findMany({ + where: { userId }, + include: { documents: true }, + orderBy: { createdAt: "desc" }, + }); + } + + async createReturn(userId: string, payload: CreateTaxReturnDto) { + return this.prisma.taxReturn.create({ + data: { + userId, + taxYear: payload.taxYear, + filingType: payload.filingType, + jurisdictions: payload.jurisdictions as Prisma.InputJsonValue, + status: "draft", + summary: {}, + }, + }); + } + + async updateReturn(userId: string, id: string, payload: UpdateTaxReturnDto) { + const existing = await this.prisma.taxReturn.findFirst({ where: { id, userId } }); + if (!existing) throw new BadRequestException("Tax return not found."); + return this.prisma.taxReturn.update({ + where: { id }, + data: { + ...(payload.status !== undefined && { status: payload.status }), + ...(payload.summary !== undefined && { summary: payload.summary as Prisma.InputJsonValue }), + }, + }); + } + + async addDocument( + userId: string, + returnId: string, + docType: string, + metadata: Record, + ) { + const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id: returnId, userId } }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + return this.prisma.taxDocument.create({ + data: { + taxReturnId: returnId, + docType, + metadata: metadata as Prisma.InputJsonValue, + }, + }); + } + + async exportReturn(userId: string, id: string) { + const taxReturn = await this.prisma.taxReturn.findFirst({ + where: { id, userId }, + include: { documents: true }, + }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + + await this.prisma.taxReturn.update({ + where: { id }, + data: { status: "exported" }, + }); + + return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents }; + } +} +`); + +// ─── tax.controller.ts ─────────────────────────────────────────────────────── +writeFileSync("src/tax/tax.controller.ts", `import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common"; +import { ok } from "../common/response"; +import { CreateTaxReturnDto } from "./dto/create-return.dto"; +import { UpdateTaxReturnDto } from "./dto/update-return.dto"; +import { TaxService } from "./tax.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +@Controller("tax") +export class TaxController { + constructor(private readonly taxService: TaxService) {} + + @Get("returns") + async listReturns(@CurrentUser() userId: string) { + const data = await this.taxService.listReturns(userId); + return ok(data); + } + + @Post("returns") + async createReturn(@CurrentUser() userId: string, @Body() payload: CreateTaxReturnDto) { + const data = await this.taxService.createReturn(userId, payload); + return ok(data); + } + + @Patch("returns/:id") + async updateReturn( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: UpdateTaxReturnDto, + ) { + const data = await this.taxService.updateReturn(userId, id, payload); + return ok(data); + } + + @Post("returns/:id/documents") + async addDocument( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: { docType: string; metadata?: Record }, + ) { + const data = await this.taxService.addDocument(userId, id, payload.docType, payload.metadata ?? {}); + return ok(data); + } + + @Post("returns/:id/export") + async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) { + const data = await this.taxService.exportReturn(userId, id); + return ok(data); + } +} +`); + +// ─── tax.module.ts ─────────────────────────────────────────────────────────── +writeFileSync("src/tax/tax.module.ts", `import { Module } from "@nestjs/common"; +import { TaxController } from "./tax.controller"; +import { TaxService } from "./tax.service"; + +@Module({ + controllers: [TaxController], + providers: [TaxService], +}) +export class TaxModule {} +`); + +// ─── tax DTOs (updated to class-validator) ─────────────────────────────────── +writeFileSync("src/tax/dto/create-return.dto.ts", `import { IsArray, IsIn, IsInt, IsString } from "class-validator"; + +export class CreateTaxReturnDto { + @IsInt() + taxYear!: number; + + @IsString() + @IsIn(["individual", "business"]) + filingType!: "individual" | "business"; + + @IsArray() + @IsString({ each: true }) + jurisdictions!: string[]; +} +`); + +writeFileSync("src/tax/dto/update-return.dto.ts", `import { IsIn, IsObject, IsOptional, IsString } from "class-validator"; + +export class UpdateTaxReturnDto { + @IsOptional() + @IsString() + @IsIn(["draft", "ready", "exported"]) + status?: "draft" | "ready" | "exported"; + + @IsOptional() + @IsObject() + summary?: Record; +} +`); + +console.log("rules + tax files written"); diff --git a/write-stripe-sheets.mjs b/write-stripe-sheets.mjs new file mode 100644 index 0000000..694fe7a --- /dev/null +++ b/write-stripe-sheets.mjs @@ -0,0 +1,429 @@ +import { writeFileSync, mkdirSync } from "fs"; + +mkdirSync("src/stripe", { recursive: true }); + +// ─── stripe.service.ts ─────────────────────────────────────────────────────── +writeFileSync("src/stripe/stripe.service.ts", `import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import Stripe from "stripe"; +import { PrismaService } from "../prisma/prisma.service"; + +export const PLAN_LIMITS: Record = { + free: { accounts: 2, exports: 5 }, + pro: { accounts: 10, exports: 100 }, + elite: { accounts: -1, exports: -1 }, // -1 = unlimited +}; + +@Injectable() +export class StripeService { + private readonly stripe: Stripe; + private readonly logger = new Logger(StripeService.name); + + constructor(private readonly prisma: PrismaService) { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) throw new Error("STRIPE_SECRET_KEY is required."); + this.stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" }); + } + + async getOrCreateCustomer(userId: string, email: string): Promise { + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + if (sub?.stripeCustomerId) return sub.stripeCustomerId; + + const customer = await this.stripe.customers.create({ email, metadata: { userId } }); + await this.prisma.subscription.upsert({ + where: { userId }, + update: { stripeCustomerId: customer.id }, + create: { userId, plan: "free", stripeCustomerId: customer.id }, + }); + return customer.id; + } + + async createCheckoutSession(userId: string, email: string, priceId: string) { + const customerId = await this.getOrCreateCustomer(userId, email); + const session = await this.stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ["card"], + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: \`\${process.env.APP_URL}/settings/billing?success=true\`, + cancel_url: \`\${process.env.APP_URL}/settings/billing?cancelled=true\`, + metadata: { userId }, + }); + return { url: session.url }; + } + + async createPortalSession(userId: string) { + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + if (!sub?.stripeCustomerId) { + throw new BadRequestException("No Stripe customer found. Please upgrade first."); + } + const session = await this.stripe.billingPortal.sessions.create({ + customer: sub.stripeCustomerId, + return_url: \`\${process.env.APP_URL}/settings/billing\`, + }); + return { url: session.url }; + } + + async getSubscription(userId: string) { + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + return sub ?? { userId, plan: "free" }; + } + + async handleWebhook(rawBody: Buffer, signature: string) { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required."); + + let event: Stripe.Event; + try { + event = this.stripe.webhooks.constructEvent(rawBody, signature, secret); + } catch (err) { + this.logger.warn(\`Webhook signature verification failed: \${err}\`); + throw new BadRequestException("Invalid webhook signature."); + } + + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": { + const subscription = event.data.object as Stripe.Subscription; + await this.syncSubscription(subscription); + break; + } + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + const customerId = subscription.customer as string; + const sub = await this.prisma.subscription.findFirst({ + where: { stripeCustomerId: customerId }, + }); + if (sub) { + await this.prisma.subscription.update({ + where: { userId: sub.userId }, + data: { plan: "free", stripeSubId: null, currentPeriodEnd: null, cancelAtPeriodEnd: false }, + }); + } + break; + } + default: + this.logger.debug(\`Unhandled Stripe event: \${event.type}\`); + } + + return { received: true }; + } + + private async syncSubscription(subscription: Stripe.Subscription) { + const customerId = subscription.customer as string; + const sub = await this.prisma.subscription.findFirst({ + where: { stripeCustomerId: customerId }, + }); + if (!sub) return; + + const priceId = subscription.items.data[0]?.price.id; + let plan = "free"; + if (priceId === process.env.STRIPE_PRICE_PRO) plan = "pro"; + else if (priceId === process.env.STRIPE_PRICE_ELITE) plan = "elite"; + + await this.prisma.subscription.update({ + where: { userId: sub.userId }, + data: { + plan, + stripeSubId: subscription.id, + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }, + }); + } +} +`); + +// ─── stripe.controller.ts ──────────────────────────────────────────────────── +writeFileSync("src/stripe/stripe.controller.ts", `import { + Body, + Controller, + Get, + Headers, + Post, + RawBodyRequest, + Req, +} from "@nestjs/common"; +import { Request } from "express"; +import { ok } from "../common/response"; +import { StripeService } from "./stripe.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { Public } from "../common/decorators/public.decorator"; +import { PrismaService } from "../prisma/prisma.service"; + +@Controller("billing") +export class StripeController { + constructor( + private readonly stripeService: StripeService, + private readonly prisma: PrismaService, + ) {} + + @Get("subscription") + async getSubscription(@CurrentUser() userId: string) { + const data = await this.stripeService.getSubscription(userId); + return ok(data); + } + + @Post("checkout") + async checkout(@CurrentUser() userId: string, @Body("priceId") priceId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + if (!user) return ok({ error: "User not found" }); + const data = await this.stripeService.createCheckoutSession(userId, user.email, priceId); + return ok(data); + } + + @Post("portal") + async portal(@CurrentUser() userId: string) { + const data = await this.stripeService.createPortalSession(userId); + return ok(data); + } + + @Public() + @Post("webhook") + async webhook( + @Req() req: RawBodyRequest, + @Headers("stripe-signature") signature: string, + ) { + const data = await this.stripeService.handleWebhook(req.rawBody!, signature); + return data; + } +} +`); + +// ─── subscription.guard.ts ─────────────────────────────────────────────────── +writeFileSync("src/stripe/subscription.guard.ts", `import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; +import { PrismaService } from "../prisma/prisma.service"; + +export const REQUIRED_PLAN_KEY = "requiredPlan"; +export const RequiredPlan = (plan: "pro" | "elite") => + SetMetadata(REQUIRED_PLAN_KEY, plan); + +const PLAN_RANK: Record = { free: 0, pro: 1, elite: 2 }; + +@Injectable() +export class SubscriptionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const required = this.reflector.getAllAndOverride(REQUIRED_PLAN_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required) return true; + + const request = context.switchToHttp().getRequest(); + const userId = request.user?.sub; + if (!userId) throw new ForbiddenException("Authentication required."); + + const sub = await this.prisma.subscription.findUnique({ where: { userId } }); + const currentPlan = sub?.plan ?? "free"; + const currentRank = PLAN_RANK[currentPlan] ?? 0; + const requiredRank = PLAN_RANK[required] ?? 0; + + if (currentRank < requiredRank) { + throw new ForbiddenException(\`This feature requires a \${required} plan.\`); + } + return true; + } +} +`); + +// ─── stripe.module.ts ──────────────────────────────────────────────────────── +writeFileSync("src/stripe/stripe.module.ts", `import { Module } from "@nestjs/common"; +import { StripeController } from "./stripe.controller"; +import { StripeService } from "./stripe.service"; +import { SubscriptionGuard } from "./subscription.guard"; + +@Module({ + controllers: [StripeController], + providers: [StripeService, SubscriptionGuard], + exports: [StripeService, SubscriptionGuard], +}) +export class StripeModule {} +`); + +// ─── exports.service.ts (with real Google Sheets) ──────────────────────────── +writeFileSync("src/exports/exports.service.ts", `import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { google } from "googleapis"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class ExportsService { + private readonly logger = new Logger(ExportsService.name); + + constructor(private readonly prisma: PrismaService) {} + + private toCsv(rows: Array>) { + if (!rows.length) return ""; + const headers = Object.keys(rows[0]); + const escape = (value: string) => \`"\${value.replace(/"/g, '""')}"\`; + const lines = [headers.join(",")]; + for (const row of rows) { + lines.push(headers.map((key) => escape(row[key] ?? "")).join(",")); + } + return lines.join("\\n"); + } + + private async getTransactions( + userId: string, + filters: Record, + limit = 1000, + ) { + const where: Record = { account: { userId } }; + if (filters.start_date || filters.end_date) { + where.date = { + gte: filters.start_date ? new Date(filters.start_date) : undefined, + lte: filters.end_date ? new Date(filters.end_date) : undefined, + }; + } + if (filters.min_amount || filters.max_amount) { + where.amount = { + gte: filters.min_amount ? Number(filters.min_amount) : undefined, + lte: filters.max_amount ? Number(filters.max_amount) : undefined, + }; + } + if (filters.category) { + where.derived = { + is: { userCategory: { contains: filters.category, mode: "insensitive" } }, + }; + } + if (filters.source) { + where.source = { contains: filters.source, mode: "insensitive" }; + } + if (filters.include_hidden !== "true") { + where.OR = [{ derived: null }, { derived: { isHidden: false } }]; + } + return this.prisma.transactionRaw.findMany({ + where, + include: { derived: true }, + orderBy: { date: "desc" }, + take: limit, + }); + } + + private toRows(transactions: Awaited>) { + return transactions.map((tx) => ({ + id: tx.id, + date: tx.date.toISOString().slice(0, 10), + description: tx.description, + amount: Number(tx.amount).toFixed(2), + category: tx.derived?.userCategory ?? "", + notes: tx.derived?.userNotes ?? "", + hidden: tx.derived?.isHidden ? "true" : "false", + source: tx.source, + })); + } + + async exportCsv(userId: string, filters: Record = {}) { + const transactions = await this.getTransactions(userId, filters); + const rows = this.toRows(transactions); + const csv = this.toCsv(rows); + + await this.prisma.exportLog.create({ + data: { userId, filters, rowCount: rows.length }, + }); + + return { status: "ready", csv, rowCount: rows.length }; + } + + async exportSheets(userId: string, filters: Record = {}) { + // Get the user's Google connection + const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); + if (!gc || !gc.isConnected) { + throw new BadRequestException( + "Google account not connected. Please connect via /api/google/connect.", + ); + } + + const oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + ); + oauth2Client.setCredentials({ + access_token: gc.accessToken, + refresh_token: gc.refreshToken, + }); + + // Refresh the access token if needed + const { credentials } = await oauth2Client.refreshAccessToken(); + await this.prisma.googleConnection.update({ + where: { userId }, + data: { + accessToken: credentials.access_token ?? gc.accessToken, + lastSyncedAt: new Date(), + }, + }); + oauth2Client.setCredentials(credentials); + + const sheets = google.sheets({ version: "v4", auth: oauth2Client }); + const transactions = await this.getTransactions(userId, filters); + const rows = this.toRows(transactions); + + const sheetTitle = \`LedgerOne Export \${new Date().toISOString().slice(0, 10)}\`; + + let spreadsheetId = gc.spreadsheetId; + if (!spreadsheetId) { + // Create a new spreadsheet + const spreadsheet = await sheets.spreadsheets.create({ + requestBody: { properties: { title: "LedgerOne" } }, + }); + spreadsheetId = spreadsheet.data.spreadsheetId!; + await this.prisma.googleConnection.update({ + where: { userId }, + data: { spreadsheetId }, + }); + } + + // Add a new sheet tab + await sheets.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { + requests: [{ addSheet: { properties: { title: sheetTitle } } }], + }, + }); + + // Build values: header + data rows + const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"]; + const values = [ + headers, + ...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")), + ]; + + await sheets.spreadsheets.values.update({ + spreadsheetId, + range: \`'\${sheetTitle}'!A1\`, + valueInputOption: "RAW", + requestBody: { values }, + }); + + await this.prisma.exportLog.create({ + data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length }, + }); + + this.logger.log(\`Exported \${rows.length} rows to Google Sheets for user \${userId}\`); + + return { + status: "exported", + rowCount: rows.length, + spreadsheetId, + sheetTitle, + url: \`https://docs.google.com/spreadsheets/d/\${spreadsheetId}\`, + }; + } +} +`); + +console.log("stripe + sheets files written"); diff --git a/write-transactions.mjs b/write-transactions.mjs new file mode 100644 index 0000000..2d06f4a --- /dev/null +++ b/write-transactions.mjs @@ -0,0 +1,546 @@ +import { writeFileSync } from "fs"; + +// ─── transactions.service.ts ───────────────────────────────────────────────── +writeFileSync("src/transactions/transactions.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common"; +import * as crypto from "crypto"; +import { parse } from "csv-parse/sync"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { PlaidService } from "../plaid/plaid.service"; +import { UpdateDerivedDto } from "./dto/update-derived.dto"; +import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; + +const MAX_PAGE_SIZE = 100; + +// ─── Bank CSV format auto-detection ────────────────────────────────────────── +type ParsedRow = { date: string; description: string; amount: number }; + +function detectAndParse(buffer: Buffer): ParsedRow[] { + const text = buffer.toString("utf8").trim(); + const rows: Record[] = parse(text, { + columns: true, + skip_empty_lines: true, + trim: true, + bom: true, + }); + + if (!rows.length) return []; + const headers = Object.keys(rows[0]).map((h) => h.toLowerCase()); + + // Chase format: Transaction Date, Description, Amount + if (headers.includes("transaction date") && headers.includes("description") && headers.includes("amount")) { + return rows.map((r) => ({ + date: r["Transaction Date"] ?? r["transaction date"], + description: r["Description"] ?? r["description"], + amount: parseFloat(r["Amount"] ?? r["amount"] ?? "0"), + })).filter((r) => r.date && r.description); + } + + // Bank of America format: Date, Description, Amount, Running Bal. + if (headers.includes("date") && headers.includes("description") && headers.includes("amount") && headers.some((h) => h.includes("running"))) { + return rows.map((r) => ({ + date: r["Date"] ?? r["date"], + description: r["Description"] ?? r["description"], + amount: parseFloat((r["Amount"] ?? r["amount"] ?? "0").replace(/,/g, "")), + })).filter((r) => r.date && r.description); + } + + // Wells Fargo format: 5 unnamed columns — Date, Amount, *, *, Description + if (headers.length >= 5 && (headers[0] === "" || /^[0-9]/.test(rows[0][Object.keys(rows[0])[0]] ?? ""))) { + const keys = Object.keys(rows[0]); + return rows.map((r) => ({ + date: r[keys[0]], + description: r[keys[4]] ?? r[keys[3]], + amount: parseFloat((r[keys[1]] ?? "0").replace(/,/g, "")), + })).filter((r) => r.date && r.description); + } + + // Generic: look for date, amount, description columns + const dateKey = Object.keys(rows[0]).find((k) => /date/i.test(k)); + const amountKey = Object.keys(rows[0]).find((k) => /amount/i.test(k)); + const descKey = Object.keys(rows[0]).find((k) => /desc|memo|narr|payee/i.test(k)); + + if (dateKey && amountKey && descKey) { + return rows.map((r) => ({ + date: r[dateKey], + description: r[descKey], + amount: parseFloat((r[amountKey] ?? "0").replace(/[^0-9.-]/g, "")), + })).filter((r) => r.date && r.description); + } + + throw new BadRequestException("Unrecognized CSV format. Supported: Chase, Bank of America, Wells Fargo, or generic (date/amount/description columns)."); +} + +@Injectable() +export class TransactionsService { + constructor( + private readonly prisma: PrismaService, + private readonly plaidService: PlaidService, + ) {} + + async list( + userId: string, + filters: { + startDate?: string; + endDate?: string; + accountId?: string; + minAmount?: string; + maxAmount?: string; + category?: string; + source?: string; + search?: string; + includeHidden?: string; + page?: number; + limit?: number; + }, + ) { + const end = filters.endDate ? new Date(filters.endDate) : new Date(); + const start = filters.startDate + ? new Date(filters.startDate) + : new Date(new Date().setDate(end.getDate() - 30)); + + const where: Prisma.TransactionRawWhereInput = { + account: { userId }, + date: { gte: start, lte: end }, + }; + + if (filters.minAmount || filters.maxAmount) { + const min = filters.minAmount ? parseFloat(filters.minAmount) : undefined; + const max = filters.maxAmount ? parseFloat(filters.maxAmount) : undefined; + where.amount = { gte: min, lte: max }; + } + + if (filters.category) { + where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } } }; + } + + if (filters.source) { + where.source = { contains: filters.source, mode: "insensitive" }; + } + + if (filters.search) { + where.description = { contains: filters.search, mode: "insensitive" }; + } + + if (filters.accountId) { + where.accountId = filters.accountId; + } + + if (filters.includeHidden !== "true") { + where.OR = [{ derived: null }, { derived: { isHidden: false } }]; + } + + const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE); + const skip = ((filters.page ?? 1) - 1) * take; + + const [rows, total] = await Promise.all([ + this.prisma.transactionRaw.findMany({ + where, + include: { derived: true }, + orderBy: { date: "desc" }, + take, + skip, + }), + this.prisma.transactionRaw.count({ where }), + ]); + + const transactions = rows.map((row) => ({ + id: row.id, + name: row.description, + amount: Number(row.amount).toFixed(2), + category: row.derived?.userCategory ?? "Uncategorized", + note: row.derived?.userNotes ?? "", + status: row.derived?.modifiedBy ?? "raw", + hidden: row.derived?.isHidden ?? false, + date: row.date.toISOString().slice(0, 10), + source: row.source, + accountId: row.accountId, + })); + + return { transactions, total, page: filters.page ?? 1, limit: take }; + } + + async importCsv(userId: string, file: Express.Multer.File) { + if (!file?.buffer) { + throw new BadRequestException("No file uploaded."); + } + if (!file.originalname.toLowerCase().endsWith(".csv")) { + throw new BadRequestException("File must be a CSV."); + } + + const rows = detectAndParse(file.buffer); + if (!rows.length) { + throw new BadRequestException("CSV file is empty or could not be parsed."); + } + + // Find or create a manual import account for this user + let account = await this.prisma.account.findFirst({ + where: { userId, institutionName: "CSV Import", plaidAccessToken: null }, + }); + if (!account) { + account = await this.prisma.account.create({ + data: { + userId, + institutionName: "CSV Import", + accountType: "checking", + isActive: true, + }, + }); + } + + let imported = 0; + let skipped = 0; + + for (const row of rows) { + const dateObj = new Date(row.date); + if (isNaN(dateObj.getTime())) { + skipped++; + continue; + } + const bankTransactionId = \`csv_\${crypto.createHash("sha256") + .update(\`\${userId}:\${row.date}:\${row.description}:\${row.amount}\`) + .digest("hex") + .slice(0, 16)}\`; + + try { + await this.prisma.transactionRaw.upsert({ + where: { bankTransactionId }, + update: {}, + create: { + accountId: account.id, + bankTransactionId, + date: dateObj, + amount: row.amount, + description: row.description, + rawPayload: row as unknown as Prisma.InputJsonValue, + ingestedAt: new Date(), + source: "csv", + }, + }); + imported++; + } catch { + skipped++; + } + } + + return { imported, skipped, total: rows.length }; + } + + async createManualTransaction(userId: string, payload: CreateManualTransactionDto) { + const account = payload.accountId + ? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } }) + : await this.prisma.account.findFirst({ where: { userId } }); + + if (!account) { + throw new BadRequestException("No account found for user."); + } + + const id = crypto.randomUUID(); + const raw = await this.prisma.transactionRaw.create({ + data: { + accountId: account.id, + bankTransactionId: \`manual_\${id}\`, + date: new Date(payload.date), + amount: payload.amount, + description: payload.description, + rawPayload: payload as unknown as Prisma.InputJsonValue, + ingestedAt: new Date(), + source: "manual", + }, + }); + + if (payload.category || payload.note || payload.hidden) { + await this.prisma.transactionDerived.create({ + data: { + rawTransactionId: raw.id, + userCategory: payload.category ?? null, + userNotes: payload.note ?? null, + isHidden: payload.hidden ?? false, + modifiedAt: new Date(), + modifiedBy: "user", + }, + }); + } + + return { id: raw.id }; + } + + async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) { + // Ensure the transaction belongs to the user + const tx = await this.prisma.transactionRaw.findFirst({ + where: { id, account: { userId } }, + }); + if (!tx) throw new BadRequestException("Transaction not found."); + + return this.prisma.transactionDerived.upsert({ + where: { rawTransactionId: id }, + update: { + userCategory: payload.userCategory, + userNotes: payload.userNotes, + isHidden: payload.isHidden ?? false, + modifiedAt: new Date(), + modifiedBy: "user", + }, + create: { + rawTransactionId: id, + userCategory: payload.userCategory, + userNotes: payload.userNotes, + isHidden: payload.isHidden ?? false, + modifiedAt: new Date(), + modifiedBy: "user", + }, + }); + } + + async sync(userId: string, startDate: string, endDate: string) { + return this.plaidService.syncTransactionsForUser(userId, startDate, endDate); + } + + async summary(userId: string, startDate: string, endDate: string) { + const rows = await this.prisma.transactionRaw.findMany({ + where: { + account: { userId }, + date: { gte: new Date(startDate), lte: new Date(endDate) }, + }, + }); + + const total = rows.reduce((sum, row) => sum + Number(row.amount), 0); + const income = rows.reduce( + (sum, row) => sum + (Number(row.amount) < 0 ? Math.abs(Number(row.amount)) : 0), + 0, + ); + const expense = rows.reduce( + (sum, row) => sum + (Number(row.amount) > 0 ? Number(row.amount) : 0), + 0, + ); + return { + total: total.toFixed(2), + count: rows.length, + income: income.toFixed(2), + expense: expense.toFixed(2), + net: (income - expense).toFixed(2), + }; + } + + async cashflow(userId: string, months = 6) { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1); + const rows = await this.prisma.transactionRaw.findMany({ + where: { account: { userId }, date: { gte: start, lte: now } }, + }); + + const buckets = new Map(); + for (let i = 0; i < months; i += 1) { + const date = new Date(now.getFullYear(), now.getMonth() - (months - 1) + i, 1); + const key = \`\${date.getFullYear()}-\${String(date.getMonth() + 1).padStart(2, "0")}\`; + buckets.set(key, { income: 0, expense: 0 }); + } + + for (const row of rows) { + const key = \`\${row.date.getFullYear()}-\${String(row.date.getMonth() + 1).padStart(2, "0")}\`; + const bucket = buckets.get(key); + if (!bucket) continue; + const amount = Number(row.amount); + if (amount < 0) bucket.income += Math.abs(amount); + else bucket.expense += amount; + } + + return Array.from(buckets.entries()).map(([month, value]) => ({ + month, + income: value.income.toFixed(2), + expense: value.expense.toFixed(2), + net: (value.income - value.expense).toFixed(2), + })); + } + + async merchantInsights(userId: string, limit = 6) { + const capped = Math.min(limit, MAX_PAGE_SIZE); + const rows = await this.prisma.transactionRaw.findMany({ + where: { account: { userId } }, + select: { description: true, amount: true }, + }); + const bucket = new Map(); + for (const row of rows) { + const amount = Number(row.amount); + if (amount <= 0) continue; + const entry = bucket.get(row.description) ?? { total: 0, count: 0 }; + entry.total += amount; + entry.count += 1; + bucket.set(row.description, entry); + } + + return Array.from(bucket.entries()) + .sort((a, b) => b[1].total - a[1].total) + .slice(0, capped) + .map(([merchant, value]) => ({ + merchant, + total: value.total.toFixed(2), + count: value.count, + })); + } +} +`); + +// ─── transactions.controller.ts ────────────────────────────────────────────── +writeFileSync("src/transactions/transactions.controller.ts", `import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { ok } from "../common/response"; +import { UpdateDerivedDto } from "./dto/update-derived.dto"; +import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; +import { TransactionsService } from "./transactions.service"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +@Controller("transactions") +export class TransactionsController { + constructor(private readonly transactionsService: TransactionsService) {} + + @Get() + async list( + @CurrentUser() userId: string, + @Query("start_date") startDate?: string, + @Query("end_date") endDate?: string, + @Query("account_id") accountId?: string, + @Query("min_amount") minAmount?: string, + @Query("max_amount") maxAmount?: string, + @Query("category") category?: string, + @Query("source") source?: string, + @Query("search") search?: string, + @Query("include_hidden") includeHidden?: string, + @Query("page") page = 1, + @Query("limit") limit = 50, + ) { + const data = await this.transactionsService.list(userId, { + startDate, + endDate, + accountId, + minAmount, + maxAmount, + category, + source, + search, + includeHidden, + page: +page, + limit: +limit, + }); + return ok(data); + } + + @Post("import") + @UseInterceptors(FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } })) + async importCsv( + @CurrentUser() userId: string, + @UploadedFile() file: Express.Multer.File, + ) { + const data = await this.transactionsService.importCsv(userId, file); + return ok(data); + } + + @Post("sync") + async sync( + @CurrentUser() userId: string, + @Body("startDate") startDate?: string, + @Body("endDate") endDate?: string, + ) { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10); + const data = await this.transactionsService.sync(userId, start, end); + return ok(data); + } + + @Post("manual") + async manual( + @CurrentUser() userId: string, + @Body() payload: CreateManualTransactionDto, + ) { + const data = await this.transactionsService.createManualTransaction(userId, payload); + return ok(data); + } + + @Get("summary") + async summary( + @CurrentUser() userId: string, + @Query("start_date") startDate?: string, + @Query("end_date") endDate?: string, + ) { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10); + const data = await this.transactionsService.summary(userId, start, end); + return ok(data); + } + + @Get("cashflow") + async cashflow( + @CurrentUser() userId: string, + @Query("months") months = 6, + ) { + const data = await this.transactionsService.cashflow(userId, +months); + return ok(data); + } + + @Get("merchants") + async merchants( + @CurrentUser() userId: string, + @Query("limit") limit = 6, + ) { + const data = await this.transactionsService.merchantInsights(userId, +limit); + return ok(data); + } + + @Patch(":id/derived") + async updateDerived( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: UpdateDerivedDto, + ) { + const data = await this.transactionsService.updateDerived(userId, id, payload); + return ok(data); + } +} +`); + +// ─── create-manual-transaction.dto.ts ──────────────────────────────────────── +writeFileSync("src/transactions/dto/create-manual-transaction.dto.ts", `import { IsBoolean, IsNumber, IsOptional, IsString, IsDateString } from "class-validator"; + +export class CreateManualTransactionDto { + @IsOptional() + @IsString() + accountId?: string; + + @IsDateString() + date!: string; + + @IsString() + description!: string; + + @IsNumber() + amount!: number; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsBoolean() + hidden?: boolean; +} +`); + +console.log("transactions files written");