diff --git a/lint_results_manual.txt b/lint_results_manual.txt new file mode 100644 index 0000000..99c6fb7 Binary files /dev/null and b/lint_results_manual.txt differ diff --git a/package.json b/package.json index e626150..290bfa2 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "cors": "^2.8.6", "dotenv": "^16.6.1", "express": "^5.2.1", + "firebase-admin": "^13.8.0", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15912ea..cbe96e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + firebase-admin: + specifier: ^13.8.0 + version: 13.8.0 helmet: specifier: ^8.1.0 version: 8.1.0 @@ -92,7 +95,7 @@ importers: version: 4.1.8 '@vitest/coverage-v8': specifier: 4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0)) eslint: specifier: ^10.0.2 version: 10.0.3(jiti@2.6.1) @@ -119,7 +122,7 @@ importers: version: 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) + version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) packages: @@ -403,6 +406,75 @@ packages: resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@firebase/app-check-interop-types@0.3.3': + resolution: {integrity: sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==} + + '@firebase/app-types@0.9.4': + resolution: {integrity: sha512-crX9TA5SVYZwLPG7/R16IsH8FLlgkPXjJUVhsVpHVDSqJiq3D/NuFTM5ctxGTExXAOeIn//69tQw47CPerM8MQ==} + + '@firebase/auth-interop-types@0.2.4': + resolution: {integrity: sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==} + + '@firebase/component@0.7.2': + resolution: {integrity: sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==} + engines: {node: '>=20.0.0'} + + '@firebase/database-compat@2.1.3': + resolution: {integrity: sha512-GMyfWjD8mehjg/QpNkY/tl9G/MoeugPeg91n9D0atggxbWuKF/2KhVPHZDH+XmoP0EKYqMWYTtKxBsaBaNKLYQ==} + engines: {node: '>=20.0.0'} + + '@firebase/database-types@1.0.19': + resolution: {integrity: sha512-FqewjUZmV9LqFfuEnmgdcUpiOUz7qwLXxnm/H8BcMFEzQXtd1yyUDm8ex5VRad2nuTE+ahOuCjUAM/cyDncO+g==} + + '@firebase/database@1.1.2': + resolution: {integrity: sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==} + engines: {node: '>=20.0.0'} + + '@firebase/logger@0.5.0': + resolution: {integrity: sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==} + engines: {node: '>=20.0.0'} + + '@firebase/util@1.15.0': + resolution: {integrity: sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==} + engines: {node: '>=20.0.0'} + + '@google-cloud/firestore@7.11.6': + resolution: {integrity: sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==} + engines: {node: '>=14.0.0'} + + '@google-cloud/paginator@5.0.2': + resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} + engines: {node: '>=14.0.0'} + + '@google-cloud/projectify@4.0.0': + resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/promisify@4.0.0': + resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} + engines: {node: '>=14'} + + '@google-cloud/storage@7.19.0': + resolution: {integrity: sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==} + engines: {node: '>=14'} + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -438,6 +510,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} @@ -453,6 +528,13 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -514,6 +596,36 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -660,6 +772,10 @@ packages: engines: {node: '>=20.0.0'} hasBin: true + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -678,6 +794,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -717,6 +836,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -747,6 +869,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -768,6 +893,9 @@ packages: '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -871,6 +999,10 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -889,9 +1021,25 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -902,6 +1050,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -912,6 +1064,9 @@ packages: ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -1033,10 +1188,21 @@ packages: citty@0.2.1: resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + color-convert@3.1.3: resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} engines: {node: '>=14.6'} + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} engines: {node: '>=12.20'} @@ -1111,6 +1277,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1186,6 +1356,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -1195,6 +1368,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -1206,6 +1382,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1230,6 +1409,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1290,6 +1473,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventsource@2.0.2: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} engines: {node: '>=12.0.0'} @@ -1305,6 +1492,13 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + farmhash-modern@1.1.0: + resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} + engines: {node: '>=18.0.0'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -1321,9 +1515,20 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-xml-builder@1.1.5: + resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1339,6 +1544,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1355,6 +1564,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + firebase-admin@13.8.0: + resolution: {integrity: sha512-iawoQkmZbsA+2DY5UEuB8f6jSlskzzySoye0D2F6e3zlDZX9DUcXf0HhZqLUn/P6WhLGvTf6ZtCmshZvhAgTYg==} + engines: {node: '>=18'} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -1382,6 +1595,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@2.5.5: + resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} + engines: {node: '>= 0.12'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -1390,6 +1607,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -1413,9 +1634,32 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1449,6 +1693,26 @@ packages: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-gax@4.6.1: + resolution: {integrity: sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1462,6 +1726,10 @@ packages: graphmatch@1.1.1: resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -1493,6 +1761,9 @@ packages: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1500,9 +1771,24 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-status-codes@2.3.0: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1548,6 +1834,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1596,6 +1886,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1603,6 +1896,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1619,6 +1915,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -1636,10 +1936,19 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. @@ -1685,6 +1994,13 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -1865,6 +2181,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -1902,9 +2223,31 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + nodemon@3.1.14: resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} engines: {node: '>=10'} @@ -1923,6 +2266,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1974,6 +2321,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2091,6 +2442,14 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proto3-json-serializer@2.0.2: + resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} + engines: {node: '>=14.0.0'} + + protobufjs@7.5.5: + resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2153,13 +2512,25 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry-request@7.0.2: + resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} + engines: {node: '>=14'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2276,9 +2647,29 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -2313,6 +2704,10 @@ packages: peerDependencies: express: '>=4.0.0 || >=5.0.0-beta' + teeny-request@9.0.0: + resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} + engines: {node: '>=14'} + text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -2350,6 +2745,9 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -2374,6 +2772,9 @@ packages: '@swc/wasm': optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -2434,6 +2835,18 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2527,6 +2940,24 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -2553,6 +2984,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2560,10 +2995,25 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.0.0-1: resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==} engines: {node: '>= 6'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -2796,6 +3246,121 @@ snapshots: '@eslint/core': 1.1.1 levn: 0.4.1 + '@fastify/busboy@3.2.0': {} + + '@firebase/app-check-interop-types@0.3.3': {} + + '@firebase/app-types@0.9.4': + dependencies: + '@firebase/logger': 0.5.0 + + '@firebase/auth-interop-types@0.2.4': {} + + '@firebase/component@0.7.2': + dependencies: + '@firebase/util': 1.15.0 + tslib: 2.8.1 + + '@firebase/database-compat@2.1.3': + dependencies: + '@firebase/component': 0.7.2 + '@firebase/database': 1.1.2 + '@firebase/database-types': 1.0.19 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.15.0 + tslib: 2.8.1 + + '@firebase/database-types@1.0.19': + dependencies: + '@firebase/app-types': 0.9.4 + '@firebase/util': 1.15.0 + + '@firebase/database@1.1.2': + dependencies: + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.7.2 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.15.0 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/logger@0.5.0': + dependencies: + tslib: 2.8.1 + + '@firebase/util@1.15.0': + dependencies: + tslib: 2.8.1 + + '@google-cloud/firestore@7.11.6': + dependencies: + '@opentelemetry/api': 1.9.1 + fast-deep-equal: 3.1.3 + functional-red-black-tree: 1.0.1 + google-gax: 4.6.1 + protobufjs: 7.5.5 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/paginator@5.0.2': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + optional: true + + '@google-cloud/projectify@4.0.0': + optional: true + + '@google-cloud/promisify@4.0.0': + optional: true + + '@google-cloud/storage@7.19.0': + dependencies: + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + abort-controller: 3.0.0 + async-retry: 1.3.3 + duplexify: 4.1.3 + fast-xml-parser: 5.7.2 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + mime: 3.0.0 + p-limit: 3.1.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + uuid: 8.3.2 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + optional: true + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.5 + yargs: 17.7.2 + optional: true + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.5 + yargs: 17.7.2 + optional: true + '@hono/node-server@1.19.9(hono@4.11.4)': dependencies: hono: 4.11.4 @@ -2825,6 +3390,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': + optional: true + '@jsdevtools/ono@7.1.3': {} '@mrleebo/prisma-ast@0.13.1': @@ -2838,6 +3406,12 @@ snapshots: '@noble/hashes@1.8.0': {} + '@nodable/entities@2.1.0': + optional: true + + '@opentelemetry/api@1.9.1': + optional: true + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -2929,6 +3503,39 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@protobufjs/aspromise@1.1.2': + optional: true + + '@protobufjs/base64@1.1.2': + optional: true + + '@protobufjs/codegen@2.0.4': + optional: true + + '@protobufjs/eventemitter@1.1.0': + optional: true + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + optional: true + + '@protobufjs/float@1.0.2': + optional: true + + '@protobufjs/inquire@1.1.0': + optional: true + + '@protobufjs/path@1.1.2': + optional: true + + '@protobufjs/pool@1.1.0': + optional: true + + '@protobufjs/utf8@1.1.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -3038,6 +3645,9 @@ snapshots: transitivePeerDependencies: - debug + '@tootallnate/once@2.0.0': + optional: true + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -3055,6 +3665,9 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.15 + '@types/caseless@0.12.5': + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3103,6 +3716,9 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 22.19.15 + '@types/long@4.0.2': + optional: true + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -3135,6 +3751,14 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 22.19.15 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.5 + optional: true + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -3169,6 +3793,9 @@ snapshots: '@types/express': 4.17.25 '@types/serve-static': 1.15.10 + '@types/tough-cookie@4.0.5': + optional: true + '@types/triple-beam@1.3.5': {} '@types/unist@3.0.3': {} @@ -3264,7 +3891,7 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -3276,7 +3903,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) + vitest: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) '@vitest/expect@4.0.18': dependencies: @@ -3317,6 +3944,11 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + optional: true + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -3332,6 +3964,15 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + optional: true + + agent-base@7.1.4: {} + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -3339,6 +3980,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: + optional: true + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + optional: true + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -3348,6 +3997,9 @@ snapshots: argparse@2.0.1: {} + arrify@2.0.1: + optional: true + asap@2.0.6: {} assertion-error@2.0.1: {} @@ -3358,6 +4010,11 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + optional: true + async@3.2.6: {} asynckit@0.4.0: {} @@ -3501,10 +4158,25 @@ snapshots: citty@0.2.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + optional: true + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + optional: true + color-convert@3.1.3: dependencies: color-name: 2.1.0 + color-name@1.1.4: + optional: true + color-name@2.1.0: {} color-string@2.1.4: @@ -3560,6 +4232,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -3619,6 +4293,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + optional: true + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -3630,12 +4312,20 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + emoji-regex@8.0.0: + optional: true + empathic@2.0.0: {} enabled@2.0.0: {} encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -3682,6 +4372,9 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: + optional: true + escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} @@ -3760,6 +4453,9 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: + optional: true + eventsource@2.0.2: {} expect-type@1.3.0: {} @@ -3799,6 +4495,10 @@ snapshots: exsolve@1.0.8: {} + extend@3.0.2: {} + + farmhash-modern@1.1.0: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -3811,10 +4511,27 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-xml-builder@1.1.5: + dependencies: + path-expression-matcher: 1.5.0 + optional: true + + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.5 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + optional: true + fault@2.0.1: dependencies: format: 0.2.2 + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -3825,6 +4542,11 @@ snapshots: fecha@4.2.3: {} + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3849,6 +4571,25 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + firebase-admin@13.8.0: + dependencies: + '@fastify/busboy': 3.2.0 + '@firebase/database-compat': 2.1.3 + '@firebase/database-types': 1.0.19 + farmhash-modern: 1.1.0 + fast-deep-equal: 3.1.3 + google-auth-library: 10.6.2 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + node-forge: 1.4.0 + uuid: 11.1.0 + optionalDependencies: + '@google-cloud/firestore': 7.11.6 + '@google-cloud/storage': 7.19.0 + transitivePeerDependencies: + - encoding + - supports-color + flat-cache@4.0.1: dependencies: flatted: 3.3.4 @@ -3869,6 +4610,16 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@2.5.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + optional: true + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -3879,6 +4630,10 @@ snapshots: format@0.2.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -3896,10 +4651,54 @@ snapshots: function-bind@1.1.2: {} + functional-red-black-tree@1.0.1: + optional: true + + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generate-function@2.3.1: dependencies: is-property: 1.0.2 + get-caller-file@2.0.5: + optional: true + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3952,6 +4751,54 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-gax@4.6.1: + dependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.7.15 + '@types/long': 4.0.2 + abort-controller: 3.0.0 + duplexify: 4.1.3 + google-auth-library: 9.15.1 + node-fetch: 2.7.0 + object-hash: 3.0.0 + proto3-json-serializer: 2.0.2 + protobufjs: 7.5.5 + retry-request: 7.0.2 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-logging-utils@0.0.2: + optional: true + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -3960,6 +4807,15 @@ snapshots: graphmatch@1.1.1: {} + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -3982,6 +4838,9 @@ snapshots: hono@4.11.4: {} + html-entities@2.6.0: + optional: true + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -3992,8 +4851,34 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-parser-js@0.5.10: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + optional: true + http-status-codes@2.3.0: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + optional: true + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -4025,6 +4910,9 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: + optional: true + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -4062,12 +4950,18 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + js-tokens@10.0.0: {} js-yaml@4.1.1: dependencies: argparse: 2.0.1 + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -4093,6 +4987,16 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@3.2.2: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3(supports-color@5.5.0) + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -4111,10 +5015,17 @@ snapshots: lilconfig@2.1.0: {} + limiter@1.1.5: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: + optional: true + + lodash.clonedeep@4.5.0: {} + lodash.get@4.4.2: {} lodash.includes@4.3.0: {} @@ -4150,6 +5061,15 @@ snapshots: longest-streak@3.1.0: {} + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + lru.min@1.1.4: {} magic-string@0.30.21: @@ -4503,6 +5423,9 @@ snapshots: mime@2.6.0: {} + mime@3.0.0: + optional: true + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -4547,8 +5470,23 @@ snapshots: negotiator@1.0.0: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + optional: true + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-forge@1.4.0: {} + nodemon@3.1.14: dependencies: chokidar: 3.6.0 @@ -4572,6 +5510,9 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: + optional: true + object-inspect@1.13.4: {} obug@2.1.1: {} @@ -4619,6 +5560,9 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: + optional: true + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -4722,6 +5666,27 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + proto3-json-serializer@2.0.2: + dependencies: + protobufjs: 7.5.5 + optional: true + + protobufjs@7.5.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.15 + long: 5.3.2 + optional: true + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -4780,10 +5745,26 @@ snapshots: remeda@2.33.4: {} + require-directory@2.1.1: + optional: true + resolve-pkg-maps@1.0.0: {} + retry-request@7.0.2: + dependencies: + '@types/request': 2.48.13 + extend: 3.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + retry@0.12.0: {} + retry@0.13.1: + optional: true + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -4939,10 +5920,36 @@ snapshots: std-env@3.10.0: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + optional: true + + stream-shift@1.0.3: + optional: true + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + optional: true + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + optional: true + + strnum@2.2.3: + optional: true + + stubs@3.0.0: + optional: true + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -4999,6 +6006,18 @@ snapshots: express: 5.2.1 swagger-ui-dist: 5.32.0 + teeny-request@9.0.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + stream-events: 1.0.5 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + text-hex@1.0.0: {} tinybench@2.9.0: {} @@ -5028,6 +6047,9 @@ snapshots: touch@3.1.1: {} + tr46@0.0.3: + optional: true + triple-beam@1.4.1: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -5052,6 +6074,8 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tslib@2.8.1: {} + tsx@4.21.0: dependencies: esbuild: 0.27.3 @@ -5121,6 +6145,14 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + + uuid@8.3.2: + optional: true + + uuid@9.0.1: + optional: true + v8-compile-cache-lib@3.0.1: {} valibot@1.2.0(typescript@5.9.3): @@ -5145,7 +6177,7 @@ snapshots: jiti: 2.6.1 tsx: 4.21.0 - vitest@4.0.18(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0): + vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0)) @@ -5168,6 +6200,7 @@ snapshots: vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 22.19.15 transitivePeerDependencies: - jiti @@ -5182,6 +6215,25 @@ snapshots: - tsx - yaml + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: + optional: true + + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + optional: true + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -5223,12 +6275,38 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + optional: true + wrappy@1.0.2: {} xtend@4.0.2: {} + y18n@5.0.8: + optional: true + + yallist@4.0.0: {} + yaml@2.0.0-1: {} + yargs-parser@21.1.1: + optional: true + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + optional: true + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aeed37d..2cb42cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,9 @@ model User { completions Completion[] credentials Credential[] transactions Transaction[] + deviceTokens DeviceToken[] + notificationPref NotificationPreference? + notifications NotificationLog[] syncEvents SyncEvent[] referralCode ReferralCode? referrals Referral[] @relation("Referrer") @@ -161,3 +164,40 @@ model WebhookDelivery { lastAttemptAt DateTime? createdAt DateTime @default(now()) } + +model DeviceToken { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + platform String // "ios", "android", "web" + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model NotificationPreference { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + rewardReceipt Boolean @default(true) + quizPassFail Boolean @default(true) + streakReminders Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model NotificationLog { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // "reward", "quiz", "streak" + title String + body String + status String @default("pending") // pending, sent, failed, dead-letter + error String? + attemptCount Int @default(0) + maxAttempts Int @default(5) + nextAttemptAt DateTime? @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/config/database.ts b/src/config/database.ts index 46685a3..9ec2033 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -1,19 +1,37 @@ import { PrismaClient } from '@prisma/client' +import { PrismaPg } from '@prisma/adapter-pg' +import { Pool } from 'pg' + +const connectionString = + process.env.DATABASE_URL ?? + 'postgresql://user:password@localhost:5432/learnault' const globalForPrisma = globalThis as unknown as { - prisma: PrismaClient | undefined + prisma: PrismaClient | undefined + pool: Pool | undefined +} + +function createPrismaClient(): PrismaClient { + const pool = + globalForPrisma.pool ?? + new Pool({ + connectionString, + max: 10, + }) + + if (process.env.NODE_ENV !== 'production') { + globalForPrisma.pool = pool + } + + const adapter = new PrismaPg(pool) + + return new PrismaClient({ adapter }) } -const prisma = globalForPrisma.prisma ?? new PrismaClient({ - datasources: { - db: { - url: process.env.DATABASE_URL, - }, - }, -}) +const prisma = globalForPrisma.prisma ?? createPrismaClient() if (process.env.NODE_ENV !== 'production') { - globalForPrisma.prisma = prisma + globalForPrisma.prisma = prisma } export default prisma diff --git a/src/controllers/credential.controller.ts b/src/controllers/credential.controller.ts index 91f2ca3..34394d2 100644 --- a/src/controllers/credential.controller.ts +++ b/src/controllers/credential.controller.ts @@ -96,7 +96,7 @@ export class CredentialController { user: { select: { id: true, - name: true, + username: true, email: true, }, }, @@ -178,7 +178,7 @@ export class CredentialController { user: { select: { id: true, - name: true, + username: true, email: true, }, }, @@ -209,7 +209,7 @@ export class CredentialController { data: { id: credential.id, userId: credential.userId, - holderName: credential.user.name, + holderName: credential.user.username, moduleId: credential.moduleId, moduleName: credential.module.title, moduleDescription: credential.module.description, @@ -260,7 +260,7 @@ export class CredentialController { user: { select: { id: true, - name: true, + username: true, }, }, module: { @@ -282,7 +282,7 @@ export class CredentialController { user: { select: { id: true, - name: true, + username: true, }, }, module: { @@ -307,7 +307,7 @@ export class CredentialController { valid: true, credential: { id: credential.id, - holderName: credential.user.name, + holderName: credential.user.username, moduleName: credential.module.title, moduleCategory: credential.module.category, moduleDifficulty: credential.module.difficulty, diff --git a/src/controllers/employer.controller.ts b/src/controllers/employer.controller.ts index d22830e..edd41b4 100644 --- a/src/controllers/employer.controller.ts +++ b/src/controllers/employer.controller.ts @@ -70,7 +70,7 @@ function locationForCandidate(email: string) { type CandidateRecord = { id: string email: string - name: string + username: string createdAt: Date completions: Array<{ score: number @@ -123,7 +123,7 @@ function profileFromCandidate(candidate: CandidateRecord) { return { id: candidate.id, - name: candidate.name, + name: candidate.username, location: locationForCandidate(candidate.email), joinedAt: candidate.createdAt, skills: derivedSkills(candidate), @@ -178,7 +178,7 @@ export const searchTalent = async (req: Request, res: Response) => { ...(search ? { OR: [ - { name: { contains: search, mode: 'insensitive' } }, + { username: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, ], } @@ -358,7 +358,7 @@ export const contactCandidate = async (req: Request, res: Response) => { select: { id: true, email: true, - name: true, + username: true, }, }) diff --git a/src/controllers/module.controller.ts b/src/controllers/module.controller.ts index e994877..8987bb5 100644 --- a/src/controllers/module.controller.ts +++ b/src/controllers/module.controller.ts @@ -1,449 +1,466 @@ -import { Request, Response } from 'express' -import { z } from 'zod' -import { prisma } from '../config/database' - -// Query parameter schemas for validation -const listModulesSchema = z.object({ - page: z.string().optional().transform(val => val ? parseInt(val) : 1), - limit: z.string().optional().transform(val => val ? parseInt(val) : 10), - category: z.string().optional(), - difficulty: z.string().optional(), - search: z.string().optional(), -}) - - -const completeModuleSchema = z.object({ - quizAnswers: z.array(z.object({ - questionId: z.string(), - answer: z.string(), - })), -}) - -/** - * @openapi - * /modules: - * get: - * summary: List modules with filters and pagination - * tags: [Modules] - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * default: 1 - * - in: query - * name: limit - * schema: - * type: integer - * default: 10 - * - in: query - * name: category - * schema: - * type: string - * - in: query - * name: difficulty - * schema: - * type: string - * - in: query - * name: search - * schema: - * type: string - * responses: - * 200: - * description: List of modules retrieved successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ModuleList' - * 400: - * description: Invalid query parameters - */ -export const listModules = async (req: Request, res: Response) => { - try { - const queryValidation = listModulesSchema.safeParse(req.query) - if (!queryValidation.success) { - return res.status(400).json({ - message: 'Invalid query parameters', - errors: queryValidation.error.errors - }) - } - - const { page, limit, category, difficulty, search } = queryValidation.data - const skip = (page - 1) * limit - - // Build where clause for filters - const where: any = {} - - if (category) { - where.category = category - } - - if (difficulty) { - where.difficulty = difficulty - } - - if (search) { - where.OR = [ - { title: { contains: search, mode: 'insensitive' } }, - { description: { contains: search, mode: 'insensitive' } } - ] - } - - // Get total count for pagination - const total = await prisma.module.count({ where }) - - // Get modules with pagination - const modules = await prisma.module.findMany({ - where, - skip, - take: limit, - orderBy: { createdAt: 'desc' }, - include: { - _count: { - select: { - completions: true - } - } - } - }) - - // If user is authenticated, include their progress - const userProgress: any = {} - if (req.user) { - const userCompletions = await prisma.completion.findMany({ - where: { userId: req.user.id }, - select: { moduleId: true, score: true, completedAt: true } - }) - - userCompletions.forEach((completion: any) => { - userProgress[completion.moduleId] = { - completed: true, - score: completion.score, - completedAt: completion.completedAt - } - }) - } - - // Transform response - const transformedModules = modules.map((module: any) => ({ - id: module.id, - title: module.title, - description: module.description, - category: module.category, - difficulty: module.difficulty, - reward: module.reward, - createdAt: module.createdAt, - updatedAt: module.updatedAt, - completionCount: module._count.completions, - userProgress: userProgress[module.id] || null - })) - - res.json({ - modules: transformedModules, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - hasNext: page * limit < total, - hasPrev: page > 1 - } - }) - - } catch (error) { - console.error('Error listing modules:', error) - res.status(500).json({ message: 'Internal server error' }) - } -} - -/** - * @openapi - * /modules/{id}: - * get: - * summary: Get module details - * tags: [Modules] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Module details retrieved successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Module' - * 404: - * description: Module not found - */ -export const getModuleById = async (req: Request, res: Response) => { - try { - const { id } = req.params - - const module = await prisma.module.findUnique({ - where: { id }, - include: { - _count: { - select: { - completions: true - } - } - } - }) - - if (!module) { - return res.status(404).json({ message: 'Module not found' }) - } - - // Get user's progress if authenticated - let userProgress = null - if (req.user) { - const completion = await prisma.completion.findUnique({ - where: { - userId_moduleId: { - userId: req.user.id, - moduleId: id - } - } - }) - - if (completion) { - userProgress = { - completed: true, - score: completion.score, - completedAt: completion.completedAt - } - } - } - - const response = { - id: module.id, - title: module.title, - description: module.description, - category: module.category, - difficulty: module.difficulty, - reward: module.reward, - createdAt: module.createdAt, - updatedAt: module.updatedAt, - completionCount: module._count.completions, - userProgress - } - - res.json(response) - - } catch (error) { - console.error('Error getting module:', error) - res.status(500).json({ message: 'Internal server error' }) - } -} - -/** - * @openapi - * /modules/{id}/start: - * post: - * summary: Start a module - * tags: [Modules] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 201: - * description: Module started successfully - * 400: - * description: Module already started or completed - * 401: - * description: Unauthorized - * 404: - * description: Module not found - */ -export const startModule = async (req: Request, res: Response) => { - try { - if (!req.user) { - return res.status(401).json({ message: 'Authentication required' }) - } - - const { id } = req.params - - // Check if module exists - const module = await prisma.module.findUnique({ - where: { id } - }) - - if (!module) { - return res.status(404).json({ message: 'Module not found' }) - } - - // Check if user already has a completion record - const existingCompletion = await prisma.completion.findUnique({ - where: { - userId_moduleId: { - userId: req.user.id, - moduleId: id - } - } - }) - - if (existingCompletion) { - return res.status(400).json({ - message: 'Module already started or completed', - status: existingCompletion.score !== null ? 'completed' : 'in_progress' - }) - } - - // Create completion record with null score (in progress) - const completion = await prisma.completion.create({ - data: { - userId: req.user.id, - moduleId: id, - score: null // null indicates in progress - } - }) - - res.status(201).json({ - message: 'Module started successfully', - completionId: completion.id, - startedAt: completion.createdAt - }) - - } catch (error) { - console.error('Error starting module:', error) - res.status(500).json({ message: 'Internal server error' }) - } -} - -/** - * @openapi - * /modules/{id}/complete: - * post: - * summary: Complete a module with quiz answers - * tags: [Modules] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/CompleteModuleInput' - * responses: - * 200: - * description: Module completed successfully - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ModuleCompletionResponse' - * 400: - * description: Invalid request or module already completed - * 401: - * description: Unauthorized - * 404: - * description: Module not found - */ -export const completeModule = async (req: Request, res: Response) => { - try { - if (!req.user) { - return res.status(401).json({ message: 'Authentication required' }) - } - - const { id } = req.params - const bodyValidation = completeModuleSchema.safeParse(req.body) - - if (!bodyValidation.success) { - return res.status(400).json({ - message: 'Invalid request body', - errors: bodyValidation.error.errors - }) - } - - const { quizAnswers } = bodyValidation.data - - // Check if module exists - const module = await prisma.module.findUnique({ - where: { id } - }) - - if (!module) { - return res.status(404).json({ message: 'Module not found' }) - } - - // Check if user has started this module - const completion = await prisma.completion.findUnique({ - where: { - userId_moduleId: { - userId: req.user.id, - moduleId: id - } - } - }) - - if (!completion) { - return res.status(400).json({ message: 'Module must be started before completion' }) - } - - if (completion.score !== null) { - return res.status(400).json({ message: 'Module already completed' }) - } - - // Calculate score (simplified - in real implementation, this would validate against actual quiz questions) - // For now, we'll simulate a scoring mechanism - const correctAnswers = quizAnswers.length // Simplified: assume all answers are correct - const totalQuestions = quizAnswers.length || 1 // Avoid division by zero - const score = Math.round((correctAnswers / totalQuestions) * 100) - - // Update completion record - const updatedCompletion = await prisma.completion.update({ - where: { - userId_moduleId: { - userId: req.user.id, - moduleId: id - } - }, - data: { - score, - completedAt: new Date() - } - }) - - // Check reward eligibility (score >= 70%) - const isEligibleForReward = score >= 70 - let rewardTransaction = null - - if (isEligibleForReward) { - // Create reward transaction - rewardTransaction = await prisma.transaction.create({ - data: { - userId: req.user.id, - amount: module.reward, - type: 'reward', - status: 'pending' - } - }) - } - - res.json({ - message: 'Module completed successfully', - score, - isEligibleForReward, - reward: isEligibleForReward ? module.reward : 0, - rewardTransaction: rewardTransaction?.id, - completedAt: updatedCompletion.completedAt - }) - - } catch (error) { - console.error('Error completing module:', error) - res.status(500).json({ message: 'Internal server error' }) - } -} \ No newline at end of file +import { Request, Response } from 'express' + +/** Sentinel score while a module is started but not yet completed (schema uses non-null Float). */ +const COMPLETION_IN_PROGRESS_SCORE = -1 +import { z } from 'zod' +import { prisma } from '../config/database' +import { NotificationService } from '../services/notification.service' + +const notificationService = new NotificationService() + +// Query parameter schemas for validation +const listModulesSchema = z.object({ + page: z.string().optional().transform(val => val ? parseInt(val) : 1), + limit: z.string().optional().transform(val => val ? parseInt(val) : 10), + category: z.string().optional(), + difficulty: z.string().optional(), + search: z.string().optional(), +}) + + +const completeModuleSchema = z.object({ + quizAnswers: z.array(z.object({ + questionId: z.string(), + answer: z.string(), + })), +}) + +/** + * @openapi + * /modules: + * get: + * summary: List modules with filters and pagination + * tags: [Modules] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * - in: query + * name: category + * schema: + * type: string + * - in: query + * name: difficulty + * schema: + * type: string + * - in: query + * name: search + * schema: + * type: string + * responses: + * 200: + * description: List of modules retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ModuleList' + * 400: + * description: Invalid query parameters + */ +export const listModules = async (req: Request, res: Response) => { + try { + const queryValidation = listModulesSchema.safeParse(req.query) + if (!queryValidation.success) { + return res.status(400).json({ + message: 'Invalid query parameters', + errors: queryValidation.error.errors + }) + } + + const { page, limit, category, difficulty, search } = queryValidation.data + const skip = (page - 1) * limit + + // Build where clause for filters + const where: any = {} + + if (category) { + where.category = category + } + + if (difficulty) { + where.difficulty = difficulty + } + + if (search) { + where.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } } + ] + } + + // Get total count for pagination + const total = await prisma.module.count({ where }) + + // Get modules with pagination + const modules = await prisma.module.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + _count: { + select: { + completions: true + } + } + } + }) + + // If user is authenticated, include their progress + const userProgress: any = {} + if (req.user) { + const userCompletions = await prisma.completion.findMany({ + where: { userId: req.user.id }, + select: { moduleId: true, score: true, completedAt: true } + }) + + userCompletions.forEach((completion: any) => { + userProgress[completion.moduleId] = { + completed: true, + score: completion.score, + completedAt: completion.completedAt + } + }) + } + + // Transform response + const transformedModules = modules.map((module: any) => ({ + id: module.id, + title: module.title, + description: module.description, + category: module.category, + difficulty: module.difficulty, + reward: module.reward, + createdAt: module.createdAt, + updatedAt: module.updatedAt, + completionCount: module._count.completions, + userProgress: userProgress[module.id] || null + })) + + res.json({ + modules: transformedModules, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasNext: page * limit < total, + hasPrev: page > 1 + } + }) + + } catch (error) { + console.error('Error listing modules:', error) + res.status(500).json({ message: 'Internal server error' }) + } +} + +/** + * @openapi + * /modules/{id}: + * get: + * summary: Get module details + * tags: [Modules] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Module details retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Module' + * 404: + * description: Module not found + */ +export const getModuleById = async (req: Request, res: Response) => { + try { + const { id } = req.params + + const module = await prisma.module.findUnique({ + where: { id }, + include: { + _count: { + select: { + completions: true + } + } + } + }) + + if (!module) { + return res.status(404).json({ message: 'Module not found' }) + } + + // Get user's progress if authenticated + let userProgress = null + if (req.user) { + const completion = await prisma.completion.findUnique({ + where: { + userId_moduleId: { + userId: req.user.id, + moduleId: id + } + } + }) + + if (completion) { + userProgress = { + completed: true, + score: completion.score, + completedAt: completion.completedAt + } + } + } + + const response = { + id: module.id, + title: module.title, + description: module.description, + category: module.category, + difficulty: module.difficulty, + reward: module.reward, + createdAt: module.createdAt, + updatedAt: module.updatedAt, + completionCount: module._count.completions, + userProgress + } + + res.json(response) + + } catch (error) { + console.error('Error getting module:', error) + res.status(500).json({ message: 'Internal server error' }) + } +} + +/** + * @openapi + * /modules/{id}/start: + * post: + * summary: Start a module + * tags: [Modules] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 201: + * description: Module started successfully + * 400: + * description: Module already started or completed + * 401: + * description: Unauthorized + * 404: + * description: Module not found + */ +export const startModule = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }) + } + + const { id } = req.params + + // Check if module exists + const module = await prisma.module.findUnique({ + where: { id } + }) + + if (!module) { + return res.status(404).json({ message: 'Module not found' }) + } + + // Check if user already has a completion record + const existingCompletion = await prisma.completion.findUnique({ + where: { + userId_moduleId: { + userId: req.user.id, + moduleId: id + } + } + }) + + if (existingCompletion) { + return res.status(400).json({ + message: 'Module already started or completed', + status: + existingCompletion.score >= 0 ? 'completed' : 'in_progress', + }) + } + + // Create completion record with sentinel score until quiz is submitted + const completion = await prisma.completion.create({ + data: { + userId: req.user.id, + moduleId: id, + score: COMPLETION_IN_PROGRESS_SCORE, + }, + }) + + res.status(201).json({ + message: 'Module started successfully', + completionId: completion.id, + startedAt: completion.createdAt + }) + + } catch (error) { + console.error('Error starting module:', error) + res.status(500).json({ message: 'Internal server error' }) + } +} + +/** + * @openapi + * /modules/{id}/complete: + * post: + * summary: Complete a module with quiz answers + * tags: [Modules] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CompleteModuleInput' + * responses: + * 200: + * description: Module completed successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ModuleCompletionResponse' + * 400: + * description: Invalid request or module already completed + * 401: + * description: Unauthorized + * 404: + * description: Module not found + */ +export const completeModule = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }) + } + + const { id } = req.params + const bodyValidation = completeModuleSchema.safeParse(req.body) + + if (!bodyValidation.success) { + return res.status(400).json({ + message: 'Invalid request body', + errors: bodyValidation.error.errors + }) + } + + const { quizAnswers } = bodyValidation.data + + // Check if module exists + const module = await prisma.module.findUnique({ + where: { id } + }) + + if (!module) { + return res.status(404).json({ message: 'Module not found' }) + } + + // Check if user has started this module + const completion = await prisma.completion.findUnique({ + where: { + userId_moduleId: { + userId: req.user.id, + moduleId: id + } + } + }) + + if (!completion) { + return res.status(400).json({ message: 'Module must be started before completion' }) + } + + if (completion.score >= 0) { + return res.status(400).json({ message: 'Module already completed' }) + } + + // Calculate score (simplified - in real implementation, this would validate against actual quiz questions) + // For now, we'll simulate a scoring mechanism + const correctAnswers = quizAnswers.length // Simplified: assume all answers are correct + const totalQuestions = quizAnswers.length || 1 // Avoid division by zero + const score = Math.round((correctAnswers / totalQuestions) * 100) + + // Update completion record + const updatedCompletion = await prisma.completion.update({ + where: { + userId_moduleId: { + userId: req.user.id, + moduleId: id + } + }, + data: { + score, + completedAt: new Date() + } + }) + + // Check reward eligibility (score >= 70%) + const isEligibleForReward = score >= 70 + let rewardTransaction = null + + if (isEligibleForReward) { + // Create reward transaction + rewardTransaction = await prisma.transaction.create({ + data: { + userId: req.user.id, + amount: module.reward, + type: 'reward', + status: 'pending' + } + }) + } + + // Fire push notification for quiz pass/fail (non-blocking) + notificationService.queueNotification( + req.user.id, + 'quizPassFail', + isEligibleForReward ? 'Quiz Passed!' : 'Quiz Completed', + isEligibleForReward + ? `Great job! You scored ${score}% on "${module.title}" and earned ${module.reward} XLM.` + : `You scored ${score}% on "${module.title}". Keep practicing to earn rewards!` + ).catch(err => console.error('[Notifications] Quiz notification error:', err)) + + res.json({ + message: 'Module completed successfully', + score, + isEligibleForReward, + reward: isEligibleForReward ? module.reward : 0, + rewardTransaction: rewardTransaction?.id, + completedAt: updatedCompletion.completedAt + }) + + } catch (error) { + console.error('Error completing module:', error) + res.status(500).json({ message: 'Internal server error' }) + } +} diff --git a/src/controllers/notification.controller.ts b/src/controllers/notification.controller.ts new file mode 100644 index 0000000..f631c18 --- /dev/null +++ b/src/controllers/notification.controller.ts @@ -0,0 +1,213 @@ +import { Request, Response } from 'express' +import { z } from 'zod' +import { NotificationService } from '../services/notification.service' +import prisma from '../config/database' + +const notificationService = new NotificationService() + +const registerDeviceSchema = z.object({ + token: z.string().min(1, 'Device token is required'), + platform: z.enum(['ios', 'android', 'web'], { + errorMap: () => ({ message: 'Platform must be "ios", "android", or "web"' }) + }) +}) + +const updatePreferencesSchema = z + .object({ + rewardReceipt: z.boolean().optional(), + quizPassFail: z.boolean().optional(), + streakReminders: z.boolean().optional() + }) + .refine(data => Object.keys(data).length > 0, { message: 'At least one preference field is required' }) + +export class NotificationController { + /** + * @openapi + * /notifications/devices: + * post: + * summary: Register a device token for push notifications + * tags: [Notifications] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - token + * - platform + * properties: + * token: + * type: string + * description: Firebase device token + * platform: + * type: string + * enum: [ios, android, web] + * responses: + * 201: + * description: Device token registered successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + */ + async registerDevice(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = registerDeviceSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format() + }) + + return + } + + const { token, platform } = validation.data + const deviceToken = await notificationService.registerDeviceToken(userId, token, platform) + + res.status(201).json({ + message: 'Device token registered successfully', + data: deviceToken + }) + } catch (error) { + console.error('Register device token error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /notifications/preferences: + * patch: + * summary: Update notification preferences + * tags: [Notifications] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * rewardReceipt: + * type: boolean + * quizPassFail: + * type: boolean + * streakReminders: + * type: boolean + * responses: + * 200: + * description: Preferences updated successfully + * 400: + * description: Validation failed + * 401: + * description: Unauthorized + */ + async updatePreferences(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const validation = updatePreferencesSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format() + }) + + return + } + + const prefs = await notificationService.updateUserPreferences(userId, validation.data) + + res.status(200).json({ + message: 'Preferences updated successfully', + data: prefs + }) + } catch (error) { + console.error('Update notification preferences error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /notifications/delivery-status: + * get: + * summary: Get notification delivery status logs for the authenticated user + * tags: [Notifications] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * - in: query + * name: status + * schema: + * type: string + * enum: [pending, success, failed, dead-letter] + * responses: + * 200: + * description: Delivery logs retrieved successfully + * 401: + * description: Unauthorized + */ + async getDeliveryStatus(req: Request, res: Response): Promise { + try { + const userId = req.user?.id + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }) + + return + } + + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100) + const status = req.query.status as string | undefined + + const logs = await prisma.notificationLog.findMany({ + where: { + userId, + ...(status ? { status } : {}) + }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + type: true, + title: true, + body: true, + status: true, + error: true, + attemptCount: true, + createdAt: true + } + }) + + res.status(200).json({ + data: logs, + count: logs.length + }) + } catch (error) { + console.error('Get delivery status error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } +} diff --git a/src/controllers/referral.controller.ts b/src/controllers/referral.controller.ts index 89e6791..c21083f 100644 --- a/src/controllers/referral.controller.ts +++ b/src/controllers/referral.controller.ts @@ -143,10 +143,17 @@ export class ReferralController { }, }) + type ReferralRow = (typeof referrals)[number] + const totalReferrals = referrals.length - const activeReferrals = referrals.filter((r) => r.referree.completions.length > 0).length - const paidBonuses = referrals.filter((r) => r.bonusPaid) - const earnedBonuses = paidBonuses.reduce((sum, r) => sum + (r.bonusAmount ?? 0), 0) + const activeReferrals = referrals.filter( + (r: ReferralRow) => r.referree.completions.length > 0, + ).length + const paidBonuses = referrals.filter((r: ReferralRow) => r.bonusPaid) + const earnedBonuses = paidBonuses.reduce( + (sum: number, r: ReferralRow) => sum + (r.bonusAmount ?? 0), + 0, + ) res.status(200).json({ success: true, diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 892f625..b813b11 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -28,13 +28,13 @@ export class UserController { const userId = (req as any).user?.id if (!userId) { res.status(401).json({ error: 'Unauthorized' }) - + return } const user = await this.findUserById(userId) if (!user) { res.status(404).json({ error: 'User not found' }) - + return } res.json({ @@ -50,7 +50,7 @@ export class UserController { createdAt: user.createdAt, updatedAt: user.updatedAt, }) - } catch { + } catch { res.status(500).json({ error: 'Internal server error' }) } } @@ -87,7 +87,7 @@ export class UserController { const userId = (req as any).user?.id if (!userId) { res.status(401).json({ error: 'Unauthorized' }) - + return } const data = req.body as UpdateUserData @@ -105,7 +105,7 @@ export class UserController { createdAt: user.createdAt, updatedAt: user.updatedAt, }) - } catch { + } catch { res.status(500).json({ error: 'Internal server error' }) } } @@ -147,7 +147,7 @@ export class UserController { if (!user) { res.status(404).json({ error: 'User not found' }) - + return } @@ -155,14 +155,14 @@ export class UserController { if (!isCurrentPasswordValid) { res.status(400).json({ error: 'Current password is incorrect' }) - + return } await this.updateUserPassword(userId, newPassword) res.json({ message: 'Password updated successfully' }) - } catch { + } catch (error: unknown) { console.error('Error changing password:', error) res.status(500).json({ error: 'Internal server error' }) } @@ -208,13 +208,13 @@ export class UserController { const userId = (req as any).user?.id if (!userId) { res.status(401).json({ error: 'Unauthorized' }) - + return } const { walletAddress } = req.body as { walletAddress: string } if (!this.isValidStellarAddress(walletAddress)) { res.status(400).json({ error: 'Invalid Stellar wallet address' }) - + return } const user = await this.updateUserWallet(userId, walletAddress) @@ -231,7 +231,7 @@ export class UserController { createdAt: user.createdAt, updatedAt: user.updatedAt, }) - } catch { + } catch { res.status(500).json({ error: 'Internal server error' }) } } diff --git a/src/routes/index.ts b/src/routes/index.ts index cb4e0bd..97def71 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -5,6 +5,7 @@ import moduleRoutes from './v1/modules.routes' import credentialRoutes from './v1/credentials.routes' import rewardRoutes from './v1/rewards.routes' import userRoutes from './v1/users.routes' +import notificationRoutes from './v1/notifications.routes' import syncRoutes from './v1/sync.routes' import referralRoutes from './v1/referrals.routes' @@ -20,6 +21,7 @@ router.use('/v1/modules', moduleRoutes) router.use('/v1/credentials', credentialRoutes) router.use('/v1/rewards', rewardRoutes) router.use('/v1/employer', employerRoutes) +router.use('/v1/notifications', notificationRoutes) router.use('/v1/sync', syncRoutes) router.use('/v1/referrals', referralRoutes) diff --git a/src/routes/v1/credentials.routes.ts b/src/routes/v1/credentials.routes.ts index 586c62c..2c8b486 100644 --- a/src/routes/v1/credentials.routes.ts +++ b/src/routes/v1/credentials.routes.ts @@ -4,7 +4,7 @@ import { CredentialController } from '../../controllers/credential.controller' import { validate, commonSchemas } from '../../middleware/validation.middleware' import { z } from 'zod' -const router = Router() +const router: Router = Router() const credentialController = new CredentialController() // Validation schemas diff --git a/src/routes/v1/employer.routes.ts b/src/routes/v1/employer.routes.ts index ca71bc3..ed5eb11 100644 --- a/src/routes/v1/employer.routes.ts +++ b/src/routes/v1/employer.routes.ts @@ -3,7 +3,7 @@ import { contactCandidate, getCandidateProfile, searchTalent } from '../../contr import { authenticate, authorize } from '../../middleware/auth.middleware' import { employerLimiter } from '../../middleware/rate-limit.middleware' -const router = Router() +const router: Router = Router() router.use(authenticate, authorize('employer'), employerLimiter) diff --git a/src/routes/v1/modules.routes.ts b/src/routes/v1/modules.routes.ts index c9eef15..7000cff 100644 --- a/src/routes/v1/modules.routes.ts +++ b/src/routes/v1/modules.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express' import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware' import { listModules, getModuleById, startModule, completeModule } from '../../controllers/module.controller' -const router = Router() +const router: Router = Router() // GET /modules - List modules with filters and pagination // Optional authentication - includes user progress if authenticated diff --git a/src/routes/v1/notifications.routes.ts b/src/routes/v1/notifications.routes.ts new file mode 100644 index 0000000..670fe1b --- /dev/null +++ b/src/routes/v1/notifications.routes.ts @@ -0,0 +1,29 @@ +import { Router } from 'express' +import { NotificationController } from '../../controllers/notification.controller' +import { authenticate } from '../../middleware/auth.middleware' + +const router: Router = Router() +const notificationController = new NotificationController() + +/** + * @route POST /api/v1/notifications/devices + * @desc Register a device token for push notifications + * @access Private + */ +router.post('/devices', authenticate, notificationController.registerDevice.bind(notificationController)) + +/** + * @route PATCH /api/v1/notifications/preferences + * @desc Update notification preferences + * @access Private + */ +router.patch('/preferences', authenticate, notificationController.updatePreferences.bind(notificationController)) + +/** + * @route GET /api/v1/notifications/delivery-status + * @desc Get delivery status logs for the authenticated user + * @access Private + */ +router.get('/delivery-status', authenticate, notificationController.getDeliveryStatus.bind(notificationController)) + +export default router diff --git a/src/routes/v1/referrals.routes.ts b/src/routes/v1/referrals.routes.ts index a3c8e8d..8b2f5cb 100644 --- a/src/routes/v1/referrals.routes.ts +++ b/src/routes/v1/referrals.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express' import { ReferralController } from '../../controllers/referral.controller' import { authenticate } from '../../middleware/auth.middleware' -const router = Router() +const router: Router = Router() const referralController = new ReferralController() /** diff --git a/src/routes/v1/rewards.routes.ts b/src/routes/v1/rewards.routes.ts index 5791416..8e7e293 100644 --- a/src/routes/v1/rewards.routes.ts +++ b/src/routes/v1/rewards.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express' import { RewardController } from '../../controllers/reward.controller' import { authenticate } from '../../middleware/auth.middleware' -const router = Router() +const router: Router = Router() const rewardController = new RewardController() /** diff --git a/src/routes/v1/sync.routes.ts b/src/routes/v1/sync.routes.ts index 43ffb87..971552d 100644 --- a/src/routes/v1/sync.routes.ts +++ b/src/routes/v1/sync.routes.ts @@ -3,7 +3,7 @@ import { SyncController } from '../../controllers/sync.controller' import { authenticate } from '../../middleware/auth.middleware' import { authenticatedLimiter } from '../../middleware/rate-limit.middleware' -const router = Router() +const router: Router = Router() const syncController = new SyncController() /** diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts new file mode 100644 index 0000000..a706bce --- /dev/null +++ b/src/services/notification.service.ts @@ -0,0 +1,196 @@ +import prisma from '../config/database' +import * as admin from 'firebase-admin' + +// Local type definition to avoid @prisma/client import at test time +interface NotificationLog { + id: string + userId: string + type: string + title: string + body: string + status: string + error: string | null + attemptCount: number + maxAttempts: number + nextAttemptAt: Date | null + createdAt: Date + updatedAt: Date +} + +// Initialize Firebase Admin lazily/safely +let firebaseInitialized = false +const initFirebase = () => { + if (firebaseInitialized || admin.apps.length > 0) { + firebaseInitialized = true + + return + } + try { + const serviceAccount = process.env.FIREBASE_SERVICE_ACCOUNT_KEY + if (serviceAccount) { + admin.initializeApp({ + credential: admin.credential.cert(JSON.parse(serviceAccount)) + }) + console.log('[NotificationService] Firebase Admin initialized.') + } else { + console.warn('[NotificationService] FIREBASE_SERVICE_ACCOUNT_KEY not set. Push notifications will be simulated.') + } + firebaseInitialized = true + } catch (e) { + console.error('[NotificationService] Failed to initialize Firebase:', e) + } +} + +export class NotificationService { + /** + * Register or update a device token for push notifications. + */ + async registerDeviceToken(userId: string, token: string, platform: string) { + return prisma.deviceToken.upsert({ + where: { token }, + update: { userId, platform }, + create: { userId, token, platform } + }) + } + + /** + * Upsert notification preferences for a user. + */ + async updateUserPreferences( + userId: string, + preferences: { + rewardReceipt?: boolean + quizPassFail?: boolean + streakReminders?: boolean + } + ) { + return prisma.notificationPreference.upsert({ + where: { userId }, + update: preferences, + create: { userId, ...preferences } + }) + } + + /** + * Queue a push notification if the user has not opted out. + * Returns null if the user opted out of that notification type. + */ + async queueNotification( + userId: string, + type: 'rewardReceipt' | 'quizPassFail' | 'streakReminders', + title: string, + body: string + ): Promise { + const prefs = await prisma.notificationPreference.findUnique({ where: { userId } }) + + // Default to enabled when no preference row exists + const isEnabled = prefs ? Boolean(prefs[type as keyof typeof prefs]) : true + + if (!isEnabled) { + return null + } + + const log = await prisma.notificationLog.create({ + data: { userId, type, title, body, status: 'pending', nextAttemptAt: new Date() } + }) + + // Process asynchronously – same pattern as webhook service + this.processQueue().catch(err => + console.error('[NotificationService] Queue processing error:', err) + ) + + return log as unknown as NotificationLog + } + + /** + * Process all pending notification logs whose nextAttemptAt is due. + */ + async processQueue(): Promise { + initFirebase() + + const pendingLogs = await prisma.notificationLog.findMany({ + where: { + status: 'pending', + nextAttemptAt: { lte: new Date() }, + attemptCount: { lt: 5 } + }, + include: { + user: { include: { deviceTokens: true } } + } + }) + + for (const log of pendingLogs) { + await this.sendPush(log) + } + } + + private async sendPush(log: any): Promise { + // Increment attempt counter first + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { attemptCount: { increment: 1 } } + }) + + const tokens: string[] = (log.user?.deviceTokens ?? []).map((dt: any) => dt.token) + + if (tokens.length === 0) { + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { status: 'failed', error: 'No device tokens found for user' } + }) + + return + } + + try { + if (admin.apps.length > 0) { + const response = await admin.messaging().sendEachForMulticast({ + notification: { title: log.title, body: log.body }, + tokens + }) + + if (response.failureCount > 0) { + const errorMsg = response.responses + .filter((r: any) => !r.success) + .map((r: any) => r.error?.message) + .join(', ') + await this.handleFailure(log, errorMsg) + } else { + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { status: 'success' } + }) + } + } else { + // Simulated success when Firebase is not configured (development mode) + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { status: 'success' } + }) + } + } catch (error: any) { + await this.handleFailure(log, error.message ?? 'Push provider error') + } + } + + private async handleFailure(log: NotificationLog, error: string): Promise { + const nextAttemptCount = log.attemptCount + 1 + + if (nextAttemptCount >= log.maxAttempts) { + // Dead-letter: exhausted all retries + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { status: 'dead-letter', error } + }) + } else { + // Exponential backoff: 1min, 5min, 25min… + const backoffMinutes = Math.pow(5, nextAttemptCount - 1) + const nextAttemptAt = new Date(Date.now() + backoffMinutes * 60_000) + + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { error, nextAttemptAt } + }) + } + } +} diff --git a/src/services/reward.service.ts b/src/services/reward.service.ts index e5e080c..ed3b496 100644 --- a/src/services/reward.service.ts +++ b/src/services/reward.service.ts @@ -1,4 +1,5 @@ import { StellarService } from './stellar.service' +import { NotificationService } from './notification.service' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -106,9 +107,11 @@ const pendingWithdrawals = new Map() export class RewardService { private stellarService: StellarService + private notificationService: NotificationService constructor(stellarService?: StellarService) { this.stellarService = stellarService ?? new StellarService() + this.notificationService = new NotificationService() } // ── Public API ────────────────────────────────────────────────────────────── @@ -178,6 +181,14 @@ export class RewardService { await this.payReferralBonus(referrerId, claim.moduleId, stellarTxHash) } + // 8. Send push notification for reward receipt (non-blocking) + this.notificationService.queueNotification( + claim.userId, + 'rewardReceipt', + 'Reward Received!', + `You earned ${totalAmount.toFixed(2)} XLM for completing module ${module.title}.` + ).catch(err => console.error('[Notifications] Reward notification error:', err)) + return { transactionId, userId: claim.userId, diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts index cd437d6..fdfadf8 100644 --- a/src/services/webhook.service.ts +++ b/src/services/webhook.service.ts @@ -1,10 +1,9 @@ -import { PrismaClient, WebhookDelivery, WebhookEndpoint } from '@prisma/client' +import type { WebhookDelivery, WebhookEndpoint } from '@prisma/client' +import prisma from '../config/database' import { WebhookEndpointCreate, WebhookEventType, WebhookPayload } from '../types/webhook.types' import crypto from 'crypto' -const prisma = new PrismaClient() - export class WebhookService { /** * Register a new webhook endpoint. @@ -173,7 +172,7 @@ export class WebhookService { take: 10, }) - const failureCount = recentDeliveries.filter(d => d.status === 'failed').length + const failureCount = recentDeliveries.filter((d: WebhookDelivery) => d.status === 'failed').length if (failureCount >= 10) { await prisma.webhookEndpoint.update({ diff --git a/tests/notification.controller.test.ts b/tests/notification.controller.test.ts new file mode 100644 index 0000000..dcf8464 --- /dev/null +++ b/tests/notification.controller.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NotificationController } from '../src/controllers/notification.controller' + +// Use vi.hoisted to ensure these are available for vi.mock +const { + mockRegisterDeviceToken, + mockUpdateUserPreferences, + mockQueueNotification, + mockProcessQueue, + mockPrisma +} = vi.hoisted(() => ({ + mockRegisterDeviceToken: vi.fn(), + mockUpdateUserPreferences: vi.fn(), + mockQueueNotification: vi.fn(), + mockProcessQueue: vi.fn(), + mockPrisma: { + notificationLog: { + findMany: vi.fn() + } + } +})) + +vi.mock('../src/services/notification.service', () => ({ + NotificationService: class { + registerDeviceToken = mockRegisterDeviceToken + updateUserPreferences = mockUpdateUserPreferences + queueNotification = mockQueueNotification + processQueue = mockProcessQueue + } +})) + +vi.mock('../src/config/database', () => ({ + default: mockPrisma +})) + +describe('NotificationController', () => { + let controller: NotificationController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new NotificationController() + req = { + user: { id: 'user1' }, + body: {}, + query: {} + } + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis() + } + }) + + describe('registerDevice', () => { + it('should return 201 on success', async () => { + req.body = { token: 't1', platform: 'ios' } + mockRegisterDeviceToken.mockResolvedValue({ id: 'dt1' }) + + await controller.registerDevice(req, res) + + expect(res.status).toHaveBeenCalledWith(201) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: { id: 'dt1' } })) + }) + + it('should return 400 on invalid body', async () => { + req.body = { token: '' } // missing platform + + await controller.registerDevice(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + }) + + describe('updatePreferences', () => { + it('should return 200 on success', async () => { + req.body = { rewardReceipt: false } + mockUpdateUserPreferences.mockResolvedValue({ id: 'p1' }) + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('should return 400 on empty body', async () => { + req.body = {} + + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + }) + }) + + describe('getDeliveryStatus', () => { + it('should return logs for user', async () => { + mockPrisma.notificationLog.findMany.mockResolvedValue([{ id: 'l1' }]) + + await controller.getDeliveryStatus(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ count: 1 })) + }) + }) +}) diff --git a/tests/notification.service.test.ts b/tests/notification.service.test.ts new file mode 100644 index 0000000..fb03b14 --- /dev/null +++ b/tests/notification.service.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { NotificationService } from '../src/services/notification.service' + +// Use vi.hoisted to mock dependencies before they are imported by the service +const { mockSendEachForMulticast } = vi.hoisted(() => ({ + mockSendEachForMulticast: vi.fn().mockResolvedValue({ failureCount: 0, responses: [] }) +})) + +vi.mock('firebase-admin', () => ({ + apps: [{ name: 'mock-app' }], + initializeApp: vi.fn(), + credential: { + cert: vi.fn().mockReturnValue({}) + }, + messaging: vi.fn().mockReturnValue({ + sendEachForMulticast: mockSendEachForMulticast + }) +})) + +// Use vi.hoisted to ensure these are available for vi.mock +const { mockPrisma } = vi.hoisted(() => ({ + mockPrisma: { + deviceToken: { + upsert: vi.fn() + }, + notificationPreference: { + upsert: vi.fn(), + findUnique: vi.fn() + }, + notificationLog: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn() + } + } +})) + +vi.mock('../src/config/database', () => ({ + default: mockPrisma +})) + +describe('NotificationService', () => { + let service: NotificationService + + beforeEach(() => { + vi.clearAllMocks() + service = new NotificationService() + process.env.FIREBASE_SERVICE_ACCOUNT_KEY = JSON.stringify({ project_id: 'test' }) + }) + + afterEach(() => { + delete process.env.FIREBASE_SERVICE_ACCOUNT_KEY + }) + + describe('registerDeviceToken', () => { + it('should upsert device token', async () => { + await service.registerDeviceToken('user1', 'token1', 'ios') + expect(mockPrisma.deviceToken.upsert).toHaveBeenCalledWith({ + where: { token: 'token1' }, + update: { userId: 'user1', platform: 'ios' }, + create: { userId: 'user1', token: 'token1', platform: 'ios' } + }) + }) + }) + + describe('updateUserPreferences', () => { + it('should upsert preferences', async () => { + await service.updateUserPreferences('user1', { rewardReceipt: true }) + expect(mockPrisma.notificationPreference.upsert).toHaveBeenCalledWith({ + where: { userId: 'user1' }, + update: { rewardReceipt: true }, + create: { userId: 'user1', rewardReceipt: true } + }) + }) + }) + + describe('queueNotification', () => { + it('should create a pending log if enabled', async () => { + mockPrisma.notificationPreference.findUnique.mockResolvedValue({ rewardReceipt: true }) + mockPrisma.notificationLog.create.mockResolvedValue({ id: 'log1' }) + + const result = await service.queueNotification('user1', 'rewardReceipt', 'Title', 'Body') + + expect(mockPrisma.notificationLog.create).toHaveBeenCalled() + expect(result).toBeDefined() + }) + + it('should return null if disabled', async () => { + mockPrisma.notificationPreference.findUnique.mockResolvedValue({ rewardReceipt: false }) + + const result = await service.queueNotification('user1', 'rewardReceipt', 'Title', 'Body') + + expect(mockPrisma.notificationLog.create).not.toHaveBeenCalled() + expect(result).toBeNull() + }) + }) + + describe('processQueue', () => { + it('should attempt to send pending notifications', async () => { + const mockLog = { + id: 'log1', + title: 'T', + body: 'B', + user: { deviceTokens: [{ token: 't1' }] } + } + mockPrisma.notificationLog.findMany.mockResolvedValue([mockLog]) + + await service.processQueue() + + expect(mockSendEachForMulticast).toHaveBeenCalled() + expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'log1' }, + data: { status: 'success' } + }) + ) + }) + + it('should handle missing tokens', async () => { + const mockLog = { + id: 'log1', + title: 'T', + body: 'B', + user: { deviceTokens: [] } + } + mockPrisma.notificationLog.findMany.mockResolvedValue([mockLog]) + + await service.processQueue() + + expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }) + }) + ) + }) + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts index e98dec3..0e52ad1 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,3 +1,6 @@ import { config } from 'dotenv' -config({ path: '.env.test' }) \ No newline at end of file +config({ path: '.env.test' }) + +process.env.DATABASE_URL ??= + 'postgresql://user:password@localhost:5432/learnault' \ No newline at end of file diff --git a/tests/unit/credential.controller.test.ts b/tests/unit/credential.controller.test.ts index 7b44b5f..4b7f5ee 100644 --- a/tests/unit/credential.controller.test.ts +++ b/tests/unit/credential.controller.test.ts @@ -56,7 +56,7 @@ describe('CredentialController', () => { issuedAt: new Date('2024-01-01'), user: { id: 'user-1', - name: 'John Doe', + username: 'John Doe', email: 'john@example.com', }, module: { @@ -224,7 +224,7 @@ describe('CredentialController', () => { issuedAt: new Date('2024-01-01'), user: { id: 'user-1', - name: 'John Doe', + username: 'John Doe', email: 'john@example.com', }, module: { @@ -288,7 +288,7 @@ describe('CredentialController', () => { moduleId: 'module-1', onChainId: 'chain-1', issuedAt: new Date(), - user: { id: 'user-2', name: 'Jane Doe', email: 'jane@example.com' }, + user: { id: 'user-2', username: 'Jane Doe', email: 'jane@example.com' }, module: { id: 'module-1', title: 'Test', @@ -343,7 +343,7 @@ describe('CredentialController', () => { issuedAt: new Date('2024-01-01'), user: { id: 'user-1', - name: 'John Doe', + username: 'John Doe', }, module: { id: 'module-1', @@ -390,7 +390,7 @@ describe('CredentialController', () => { issuedAt: new Date('2024-01-01'), user: { id: 'user-1', - name: 'John Doe', + username: 'John Doe', }, module: { id: 'module-1', @@ -452,7 +452,7 @@ describe('CredentialController', () => { moduleId: 'module-1', onChainId: 'chain-1', issuedAt: new Date(), - user: { id: 'user-1', name: 'John Doe' }, + user: { id: 'user-1', username: 'John Doe' }, module: { id: 'module-1', title: 'Test', diff --git a/tests/unit/employer.controller.test.ts b/tests/unit/employer.controller.test.ts index 7ee680f..dc2097a 100644 --- a/tests/unit/employer.controller.test.ts +++ b/tests/unit/employer.controller.test.ts @@ -39,7 +39,7 @@ describe('EmployerController', () => { { id: 'cand-1', email: 'alice.learner+seed@learnault.dev', - name: 'Alice Learner', + username: 'Alice Learner', createdAt: new Date('2026-01-01T00:00:00Z'), completions: [ { @@ -70,7 +70,7 @@ describe('EmployerController', () => { { id: 'cand-2', email: 'bob.learner+seed@learnault.dev', - name: 'Bob Learner', + username: 'Bob Learner', createdAt: new Date('2026-01-01T00:00:00Z'), completions: [ { @@ -119,7 +119,7 @@ describe('EmployerController', () => { ;(prisma.user.findUnique as any).mockResolvedValue({ id: 'cand-1', email: 'alice.learner+seed@learnault.dev', - name: 'Alice Learner', + username: 'Alice Learner', createdAt: new Date('2026-01-01T00:00:00Z'), completions: [ { @@ -196,7 +196,7 @@ describe('EmployerController', () => { ;(prisma.user.findUnique as any).mockResolvedValue({ id: 'cand-1', email: 'alice.learner+seed@learnault.dev', - name: 'Alice Learner', + username: 'Alice Learner', }) ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ id: 'system-employer-outreach-log' }) ;(prisma.webhookDelivery.create as any).mockResolvedValue({