From f73947bd9983873b7d737a09f561412a018485d2 Mon Sep 17 00:00:00 2001 From: Temiloluwa Omosuwa Date: Sun, 26 Apr 2026 16:12:00 +0100 Subject: [PATCH 1/3] feat: add push notification API for learning events (issue #53) --- package.json | 1 + pnpm-lock.yaml | 1088 +++++++++++++++++++- prisma/schema.prisma | 40 + src/controllers/module.controller.ts | 13 + src/controllers/notification.controller.ts | 209 ++++ src/routes/index.ts | 2 + src/routes/v1/notifications.routes.ts | 29 + src/services/notification.service.ts | 192 ++++ src/services/reward.service.ts | 11 + tests/notification.controller.test.ts | 188 ++++ tests/notification.service.test.ts | 169 +++ 11 files changed, 1937 insertions(+), 5 deletions(-) create mode 100644 src/controllers/notification.controller.ts create mode 100644 src/routes/v1/notifications.routes.ts create mode 100644 src/services/notification.service.ts create mode 100644 tests/notification.controller.test.ts create mode 100644 tests/notification.service.test.ts 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 b2e0274..3119dae 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[] @@map("users") } @@ -111,3 +114,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/controllers/module.controller.ts b/src/controllers/module.controller.ts index e994877..ab8a6ec 100644 --- a/src/controllers/module.controller.ts +++ b/src/controllers/module.controller.ts @@ -1,6 +1,9 @@ import { Request, Response } from 'express' 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({ @@ -433,6 +436,16 @@ export const completeModule = async (req: Request, res: Response) => { }) } + // 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, diff --git a/src/controllers/notification.controller.ts b/src/controllers/notification.controller.ts new file mode 100644 index 0000000..884b107 --- /dev/null +++ b/src/controllers/notification.controller.ts @@ -0,0 +1,209 @@ +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/routes/index.ts b/src/routes/index.ts index c643ff4..c724661 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' const router: Router = Router() @@ -18,5 +19,6 @@ 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) export default router 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/services/notification.service.ts b/src/services/notification.service.ts new file mode 100644 index 0000000..78d65df --- /dev/null +++ b/src/services/notification.service.ts @@ -0,0 +1,192 @@ +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 + } + + /** + * 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 as any) + } + } + + 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/tests/notification.controller.test.ts b/tests/notification.controller.test.ts new file mode 100644 index 0000000..6f71976 --- /dev/null +++ b/tests/notification.controller.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ── Mocks (vi.hoisted ensures the variables exist when vi.mock runs) ──── + +const { prismaMock, mockRegisterDeviceToken, mockUpdateUserPreferences } = vi.hoisted(() => { + return { + prismaMock: { + notificationLog: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'log-1', + type: 'rewardReceipt', + title: 'Reward Received!', + body: 'You earned 5 XLM.', + status: 'success', + error: null, + attemptCount: 1, + createdAt: new Date('2026-01-01') + } + ]) + } + }, + mockRegisterDeviceToken: vi.fn(), + mockUpdateUserPreferences: vi.fn() + } +}) + +vi.mock('../src/config/database', () => ({ + default: prismaMock, + prisma: prismaMock +})) + +vi.mock('firebase-admin', () => ({ + apps: [], + initializeApp: vi.fn(), + credential: { cert: vi.fn() }, + messaging: vi.fn() +})) + +vi.mock('../src/services/notification.service', () => { + const MockNotificationService = function(this: any) { + this.registerDeviceToken = mockRegisterDeviceToken + this.updateUserPreferences = mockUpdateUserPreferences + this.queueNotification = vi.fn() + this.processQueue = vi.fn() + } + return { NotificationService: MockNotificationService } +}) + +import { Request, Response } from 'express' +import { NotificationController } from '../src/controllers/notification.controller' + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeReq = (overrides: Partial = {}): Request => + ({ + body: {}, + query: {}, + params: {}, + headers: {}, + user: { id: 'user-123', email: 'test@test.com', role: 'learner' as any }, + ...overrides + } as Request) + +const makeRes = (): Response => { + const res: Partial = {} + res.status = vi.fn().mockReturnValue(res) + res.json = vi.fn().mockReturnValue(res) + return res as Response +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('NotificationController', () => { + let controller: NotificationController + + beforeEach(() => { + vi.clearAllMocks() + controller = new NotificationController() + }) + + describe('registerDevice', () => { + it('returns 401 when user is not authenticated', async () => { + const req = makeReq({ user: undefined }) + const res = makeRes() + await controller.registerDevice(req, res) + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }) + }) + + it('returns 400 when token is missing', async () => { + const req = makeReq({ body: { platform: 'ios' } }) + const res = makeRes() + await controller.registerDevice(req, res) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Validation failed' }) + ) + }) + + it('returns 400 when platform is invalid', async () => { + const req = makeReq({ body: { token: 'abc123', platform: 'windows' } }) + const res = makeRes() + await controller.registerDevice(req, res) + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 201 with a valid token and platform', async () => { + const fakeToken = { id: 'dt-1', userId: 'user-123', token: 'fcm-token', platform: 'android' } + mockRegisterDeviceToken.mockResolvedValue(fakeToken) + + const req = makeReq({ body: { token: 'fcm-token', platform: 'android' } }) + const res = makeRes() + await controller.registerDevice(req, res) + + expect(res.status).toHaveBeenCalledWith(201) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Device token registered successfully', data: fakeToken }) + ) + }) + + it('returns 500 on unexpected service error', async () => { + mockRegisterDeviceToken.mockRejectedValue(new Error('DB error')) + + const req = makeReq({ body: { token: 'tok', platform: 'web' } }) + const res = makeRes() + await controller.registerDevice(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + }) + }) + + describe('updatePreferences', () => { + it('returns 401 when user is not authenticated', async () => { + const req = makeReq({ user: undefined }) + const res = makeRes() + await controller.updatePreferences(req, res) + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 400 when no preference fields are provided', async () => { + const req = makeReq({ body: {} }) + const res = makeRes() + await controller.updatePreferences(req, res) + expect(res.status).toHaveBeenCalledWith(400) + }) + + it('returns 200 with valid preferences', async () => { + const fakePrefs = { + id: 'pref-1', userId: 'user-123', + rewardReceipt: false, quizPassFail: true, streakReminders: true + } + mockUpdateUserPreferences.mockResolvedValue(fakePrefs) + + const req = makeReq({ body: { rewardReceipt: false } }) + const res = makeRes() + await controller.updatePreferences(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Preferences updated successfully', + data: fakePrefs + }) + ) + }) + }) + + describe('getDeliveryStatus', () => { + it('returns 401 when user is not authenticated', async () => { + const req = makeReq({ user: undefined }) + const res = makeRes() + await controller.getDeliveryStatus(req, res) + expect(res.status).toHaveBeenCalledWith(401) + }) + + it('returns 200 with delivery logs for authenticated user', async () => { + const req = makeReq({ query: { limit: '5' } }) + const res = makeRes() + 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..0a50aed --- /dev/null +++ b/tests/notification.service.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ── Mocks (vi.hoisted ensures variables exist when vi.mock factories run) ── + +const { prismaMock } = vi.hoisted(() => { + return { + prismaMock: { + deviceToken: { upsert: vi.fn() }, + notificationPreference: { findUnique: vi.fn(), upsert: vi.fn() }, + notificationLog: { create: vi.fn(), findMany: vi.fn(), update: vi.fn() } + } + } +}) + +vi.mock('../src/config/database', () => ({ + default: prismaMock, + prisma: prismaMock +})) + +vi.mock('firebase-admin', () => ({ + apps: [], + initializeApp: vi.fn(), + credential: { cert: vi.fn() }, + messaging: vi.fn().mockReturnValue({ + sendEachForMulticast: vi.fn() + }) +})) + +import { NotificationService } from '../src/services/notification.service' + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('NotificationService', () => { + let service: NotificationService + + beforeEach(() => { + vi.clearAllMocks() + service = new NotificationService() + }) + + describe('registerDeviceToken', () => { + it('calls prisma.deviceToken.upsert with correct args', async () => { + const fakeToken = { id: 'dt-1', userId: 'u1', token: 'tok', platform: 'ios' } + prismaMock.deviceToken.upsert.mockResolvedValue(fakeToken) + + const result = await service.registerDeviceToken('u1', 'tok', 'ios') + + expect(prismaMock.deviceToken.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { token: 'tok' }, + create: expect.objectContaining({ userId: 'u1', token: 'tok', platform: 'ios' }) + }) + ) + expect(result).toEqual(fakeToken) + }) + }) + + describe('updateUserPreferences', () => { + it('calls prisma.notificationPreference.upsert', async () => { + const fakePrefs = { id: 'p1', userId: 'u1', rewardReceipt: false, quizPassFail: true, streakReminders: true } + prismaMock.notificationPreference.upsert.mockResolvedValue(fakePrefs) + + const result = await service.updateUserPreferences('u1', { rewardReceipt: false }) + + expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'u1' }, + update: { rewardReceipt: false } + }) + ) + expect(result).toEqual(fakePrefs) + }) + }) + + describe('queueNotification', () => { + it('returns null when user has opted out of the notification type', async () => { + prismaMock.notificationPreference.findUnique.mockResolvedValue({ + rewardReceipt: false, + quizPassFail: true, + streakReminders: true + }) + + const result = await service.queueNotification('u1', 'rewardReceipt', 'Hi', 'Body') + + expect(result).toBeNull() + expect(prismaMock.notificationLog.create).not.toHaveBeenCalled() + }) + + it('creates a log entry when user has opted in', async () => { + prismaMock.notificationPreference.findUnique.mockResolvedValue({ + rewardReceipt: true, + quizPassFail: true, + streakReminders: true + }) + const fakeLog = { id: 'log-1', status: 'pending' } + prismaMock.notificationLog.create.mockResolvedValue(fakeLog) + prismaMock.notificationLog.findMany.mockResolvedValue([]) + + const result = await service.queueNotification('u1', 'rewardReceipt', 'Reward!', 'You earned 5 XLM.') + + expect(prismaMock.notificationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 'u1', type: 'rewardReceipt', title: 'Reward!', status: 'pending' }) + }) + ) + expect(result).toEqual(fakeLog) + }) + + it('defaults to enabled when no preference row exists', async () => { + prismaMock.notificationPreference.findUnique.mockResolvedValue(null) + const fakeLog = { id: 'log-2', status: 'pending' } + prismaMock.notificationLog.create.mockResolvedValue(fakeLog) + prismaMock.notificationLog.findMany.mockResolvedValue([]) + + const result = await service.queueNotification('u2', 'quizPassFail', 'Quiz', 'Passed!') + + expect(result).toBeDefined() + expect(result?.id).toBe('log-2') + }) + }) + + describe('processQueue', () => { + it('does nothing when there are no pending logs', async () => { + prismaMock.notificationLog.findMany.mockResolvedValue([]) + + await expect(service.processQueue()).resolves.not.toThrow() + expect(prismaMock.notificationLog.update).not.toHaveBeenCalled() + }) + + it('marks log as failed when user has no device tokens', async () => { + const pendingLog = { + id: 'log-1', + title: 'Test', + body: 'Test body', + attemptCount: 0, + maxAttempts: 5, + user: { deviceTokens: [] } + } + prismaMock.notificationLog.findMany.mockResolvedValue([pendingLog]) + prismaMock.notificationLog.update.mockResolvedValue({}) + + await service.processQueue() + + expect(prismaMock.notificationLog.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }) + }) + ) + }) + }) + + describe('event type routing', () => { + it('queues with the correct event type for streakReminders', async () => { + prismaMock.notificationPreference.findUnique.mockResolvedValue(null) + const fakeLog = { id: 'log-3', status: 'pending' } + prismaMock.notificationLog.create.mockResolvedValue(fakeLog) + prismaMock.notificationLog.findMany.mockResolvedValue([]) + + const result = await service.queueNotification('u3', 'streakReminders', 'Streak!', 'Keep it up') + + expect(prismaMock.notificationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: 'streakReminders' }) + }) + ) + expect(result?.id).toBe('log-3') + }) + }) +}) From fa52e2f814488415548b292b226aad820ded4444 Mon Sep 17 00:00:00 2001 From: Temiloluwa Omosuwa Date: Sun, 26 Apr 2026 18:28:31 +0100 Subject: [PATCH 2/3] chore: fix style issues and Vitest hoisting in notification tests --- lint_results_manual.txt | Bin 0 -> 3214 bytes src/controllers/notification.controller.ts | 378 +++++++++++---------- src/services/notification.service.ts | 326 +++++++++--------- tests/notification.controller.test.ts | 246 +++++--------- tests/notification.service.test.ts | 255 ++++++-------- 5 files changed, 549 insertions(+), 656 deletions(-) create mode 100644 lint_results_manual.txt diff --git a/lint_results_manual.txt b/lint_results_manual.txt new file mode 100644 index 0000000000000000000000000000000000000000..99c6fb74d511930e1e7be4ebbb9f915488293618 GIT binary patch literal 3214 zcmds(+iuf95QgU(iFXJ$ChDQg!e>Ns* zi2@Qu(8^lx?(EF$%s&VF+tL9 z-Uo2xMB)L`ht8cK-Je=rv(ISBplGF=GSCrk%j#~abnGGd#wAL(AhK0rsj>Eh=Lq|S z)653hI(ADH`4OuLIgr>9BV<7eRhcD}Ys97o!ejPbm%5IXyk4%c0Vc|`6s$s|Lxd7` z17z#q)w0;$b8mvTBi3zu1xEYe8#Aj0UT_l7jad(|W{h+|NfJFj7pAfMqe{uRA>{lD zs~q{*{pum78W8M#S}JRCkF-L2M?(KwYY!wmL+z@0t9ZwJIm$T=_M|!tI68na{okQNASeq)q+# zcGSf3=n1)hQxhAu;n7=Ni2DJRcLv_-W>o}3t=$FG!?$}`ms{M4u2ScBRi}yQG+RVe zbJQ3UeYulc+aJ^uZswa=A-YaZs#mZ75>M5*vh%KFqcTx={!TWYxaU{@YAOUH@C*I2 H`iuAum^3s0 literal 0 HcmV?d00001 diff --git a/src/controllers/notification.controller.ts b/src/controllers/notification.controller.ts index 884b107..f631c18 100644 --- a/src/controllers/notification.controller.ts +++ b/src/controllers/notification.controller.ts @@ -6,204 +6,208 @@ 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"' }) - }) + 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({ +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' } -) + }) + .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/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/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' }) + /** + * @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/services/notification.service.ts b/src/services/notification.service.ts index 78d65df..a706bce 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -3,190 +3,194 @@ 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 + 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) + 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 } - }) + /** + * 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 } }) - /** - * 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) - ) + // Default to enabled when no preference row exists + const isEnabled = prefs ? Boolean(prefs[type as keyof typeof prefs]) : true - return log + if (!isEnabled) { + return null } - /** - * 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 as any) - } + 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 } } - }) + 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) + 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 - } + if (tokens.length === 0) { + await prisma.notificationLog.update({ + where: { id: log.id }, + data: { status: 'failed', error: 'No device tokens found for user' } + }) - 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') - } + return } - private async handleFailure(log: NotificationLog, error: string): Promise { - const nextAttemptCount = log.attemptCount + 1 + try { + if (admin.apps.length > 0) { + const response = await admin.messaging().sendEachForMulticast({ + notification: { title: log.title, body: log.body }, + tokens + }) - if (nextAttemptCount >= log.maxAttempts) { - // Dead-letter: exhausted all retries - await prisma.notificationLog.update({ - where: { id: log.id }, - data: { status: 'dead-letter', error } - }) + 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 { - // 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 } - }) + 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/tests/notification.controller.test.ts b/tests/notification.controller.test.ts index 6f71976..dcf8464 100644 --- a/tests/notification.controller.test.ts +++ b/tests/notification.controller.test.ts @@ -1,188 +1,104 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NotificationController } from '../src/controllers/notification.controller' -// ── Mocks (vi.hoisted ensures the variables exist when vi.mock runs) ──── - -const { prismaMock, mockRegisterDeviceToken, mockUpdateUserPreferences } = vi.hoisted(() => { - return { - prismaMock: { - notificationLog: { - findMany: vi.fn().mockResolvedValue([ - { - id: 'log-1', - type: 'rewardReceipt', - title: 'Reward Received!', - body: 'You earned 5 XLM.', - status: 'success', - error: null, - attemptCount: 1, - createdAt: new Date('2026-01-01') - } - ]) - } - }, - mockRegisterDeviceToken: vi.fn(), - mockUpdateUserPreferences: vi.fn() +// 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/config/database', () => ({ - default: prismaMock, - prisma: prismaMock +vi.mock('../src/services/notification.service', () => ({ + NotificationService: class { + registerDeviceToken = mockRegisterDeviceToken + updateUserPreferences = mockUpdateUserPreferences + queueNotification = mockQueueNotification + processQueue = mockProcessQueue + } })) -vi.mock('firebase-admin', () => ({ - apps: [], - initializeApp: vi.fn(), - credential: { cert: vi.fn() }, - messaging: vi.fn() +vi.mock('../src/config/database', () => ({ + default: mockPrisma })) -vi.mock('../src/services/notification.service', () => { - const MockNotificationService = function(this: any) { - this.registerDeviceToken = mockRegisterDeviceToken - this.updateUserPreferences = mockUpdateUserPreferences - this.queueNotification = vi.fn() - this.processQueue = vi.fn() +describe('NotificationController', () => { + let controller: NotificationController + let req: any + let res: any + + beforeEach(() => { + vi.clearAllMocks() + controller = new NotificationController() + req = { + user: { id: 'user1' }, + body: {}, + query: {} } - return { NotificationService: MockNotificationService } -}) - -import { Request, Response } from 'express' -import { NotificationController } from '../src/controllers/notification.controller' + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis() + } + }) -// ── Helpers ────────────────────────────────────────────────────────────────── + describe('registerDevice', () => { + it('should return 201 on success', async () => { + req.body = { token: 't1', platform: 'ios' } + mockRegisterDeviceToken.mockResolvedValue({ id: 'dt1' }) -const makeReq = (overrides: Partial = {}): Request => - ({ - body: {}, - query: {}, - params: {}, - headers: {}, - user: { id: 'user-123', email: 'test@test.com', role: 'learner' as any }, - ...overrides - } as Request) + await controller.registerDevice(req, res) -const makeRes = (): Response => { - const res: Partial = {} - res.status = vi.fn().mockReturnValue(res) - res.json = vi.fn().mockReturnValue(res) - return res as Response -} + expect(res.status).toHaveBeenCalledWith(201) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: { id: 'dt1' } })) + }) -// ── Tests ───────────────────────────────────────────────────────────────────── + it('should return 400 on invalid body', async () => { + req.body = { token: '' } // missing platform -describe('NotificationController', () => { - let controller: NotificationController + await controller.registerDevice(req, res) - beforeEach(() => { - vi.clearAllMocks() - controller = new NotificationController() + 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) - describe('registerDevice', () => { - it('returns 401 when user is not authenticated', async () => { - const req = makeReq({ user: undefined }) - const res = makeRes() - await controller.registerDevice(req, res) - expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }) - }) - - it('returns 400 when token is missing', async () => { - const req = makeReq({ body: { platform: 'ios' } }) - const res = makeRes() - await controller.registerDevice(req, res) - expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'Validation failed' }) - ) - }) - - it('returns 400 when platform is invalid', async () => { - const req = makeReq({ body: { token: 'abc123', platform: 'windows' } }) - const res = makeRes() - await controller.registerDevice(req, res) - expect(res.status).toHaveBeenCalledWith(400) - }) - - it('returns 201 with a valid token and platform', async () => { - const fakeToken = { id: 'dt-1', userId: 'user-123', token: 'fcm-token', platform: 'android' } - mockRegisterDeviceToken.mockResolvedValue(fakeToken) - - const req = makeReq({ body: { token: 'fcm-token', platform: 'android' } }) - const res = makeRes() - await controller.registerDevice(req, res) - - expect(res.status).toHaveBeenCalledWith(201) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Device token registered successfully', data: fakeToken }) - ) - }) - - it('returns 500 on unexpected service error', async () => { - mockRegisterDeviceToken.mockRejectedValue(new Error('DB error')) - - const req = makeReq({ body: { token: 'tok', platform: 'web' } }) - const res = makeRes() - await controller.registerDevice(req, res) - - expect(res.status).toHaveBeenCalledWith(500) - }) + expect(res.status).toHaveBeenCalledWith(200) }) - describe('updatePreferences', () => { - it('returns 401 when user is not authenticated', async () => { - const req = makeReq({ user: undefined }) - const res = makeRes() - await controller.updatePreferences(req, res) - expect(res.status).toHaveBeenCalledWith(401) - }) - - it('returns 400 when no preference fields are provided', async () => { - const req = makeReq({ body: {} }) - const res = makeRes() - await controller.updatePreferences(req, res) - expect(res.status).toHaveBeenCalledWith(400) - }) - - it('returns 200 with valid preferences', async () => { - const fakePrefs = { - id: 'pref-1', userId: 'user-123', - rewardReceipt: false, quizPassFail: true, streakReminders: true - } - mockUpdateUserPreferences.mockResolvedValue(fakePrefs) - - const req = makeReq({ body: { rewardReceipt: false } }) - const res = makeRes() - await controller.updatePreferences(req, res) - - expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Preferences updated successfully', - data: fakePrefs - }) - ) - }) + 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) - describe('getDeliveryStatus', () => { - it('returns 401 when user is not authenticated', async () => { - const req = makeReq({ user: undefined }) - const res = makeRes() - await controller.getDeliveryStatus(req, res) - expect(res.status).toHaveBeenCalledWith(401) - }) - - it('returns 200 with delivery logs for authenticated user', async () => { - const req = makeReq({ query: { limit: '5' } }) - const res = makeRes() - await controller.getDeliveryStatus(req, res) - - expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ count: 1 }) - ) - }) + 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 index 0a50aed..0bb82e0 100644 --- a/tests/notification.service.test.ts +++ b/tests/notification.service.test.ts @@ -1,169 +1,138 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// ── Mocks (vi.hoisted ensures variables exist when vi.mock factories run) ── - -const { prismaMock } = vi.hoisted(() => { - return { - prismaMock: { - deviceToken: { upsert: vi.fn() }, - notificationPreference: { findUnique: vi.fn(), upsert: vi.fn() }, - notificationLog: { create: vi.fn(), findMany: vi.fn(), update: vi.fn() } - } - } -}) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { NotificationService } from '../src/services/notification.service' +import * as admin from 'firebase-admin' -vi.mock('../src/config/database', () => ({ - default: prismaMock, - prisma: prismaMock +// 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: [], - initializeApp: vi.fn(), - credential: { cert: vi.fn() }, - messaging: vi.fn().mockReturnValue({ - sendEachForMulticast: vi.fn() - }) + apps: [{ name: 'mock-app' }], + initializeApp: vi.fn(), + credential: { + cert: vi.fn().mockReturnValue({}) + }, + messaging: vi.fn().mockReturnValue({ + sendEachForMulticast: mockSendEachForMulticast + }) })) -import { NotificationService } from '../src/services/notification.service' +// 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() + } + } +})) -// ── Tests ─────────────────────────────────────────────────────────────────── +vi.mock('../src/config/database', () => ({ + default: mockPrisma +})) describe('NotificationService', () => { - let service: NotificationService - - beforeEach(() => { - vi.clearAllMocks() - service = new 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('registerDeviceToken', () => { - it('calls prisma.deviceToken.upsert with correct args', async () => { - const fakeToken = { id: 'dt-1', userId: 'u1', token: 'tok', platform: 'ios' } - prismaMock.deviceToken.upsert.mockResolvedValue(fakeToken) - - const result = await service.registerDeviceToken('u1', 'tok', 'ios') - - expect(prismaMock.deviceToken.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { token: 'tok' }, - create: expect.objectContaining({ userId: 'u1', token: 'tok', platform: 'ios' }) - }) - ) - expect(result).toEqual(fakeToken) - }) + }) + + 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('updateUserPreferences', () => { - it('calls prisma.notificationPreference.upsert', async () => { - const fakePrefs = { id: 'p1', userId: 'u1', rewardReceipt: false, quizPassFail: true, streakReminders: true } - prismaMock.notificationPreference.upsert.mockResolvedValue(fakePrefs) + 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.updateUserPreferences('u1', { rewardReceipt: false }) + const result = await service.queueNotification('user1', 'rewardReceipt', 'Title', 'Body') - expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { userId: 'u1' }, - update: { rewardReceipt: false } - }) - ) - expect(result).toEqual(fakePrefs) - }) + expect(mockPrisma.notificationLog.create).toHaveBeenCalled() + expect(result).toBeDefined() }) - describe('queueNotification', () => { - it('returns null when user has opted out of the notification type', async () => { - prismaMock.notificationPreference.findUnique.mockResolvedValue({ - rewardReceipt: false, - quizPassFail: true, - streakReminders: true - }) - - const result = await service.queueNotification('u1', 'rewardReceipt', 'Hi', 'Body') - - expect(result).toBeNull() - expect(prismaMock.notificationLog.create).not.toHaveBeenCalled() - }) - - it('creates a log entry when user has opted in', async () => { - prismaMock.notificationPreference.findUnique.mockResolvedValue({ - rewardReceipt: true, - quizPassFail: true, - streakReminders: true - }) - const fakeLog = { id: 'log-1', status: 'pending' } - prismaMock.notificationLog.create.mockResolvedValue(fakeLog) - prismaMock.notificationLog.findMany.mockResolvedValue([]) - - const result = await service.queueNotification('u1', 'rewardReceipt', 'Reward!', 'You earned 5 XLM.') - - expect(prismaMock.notificationLog.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ userId: 'u1', type: 'rewardReceipt', title: 'Reward!', status: 'pending' }) - }) - ) - expect(result).toEqual(fakeLog) - }) - - it('defaults to enabled when no preference row exists', async () => { - prismaMock.notificationPreference.findUnique.mockResolvedValue(null) - const fakeLog = { id: 'log-2', status: 'pending' } - prismaMock.notificationLog.create.mockResolvedValue(fakeLog) - prismaMock.notificationLog.findMany.mockResolvedValue([]) + it('should return null if disabled', async () => { + mockPrisma.notificationPreference.findUnique.mockResolvedValue({ rewardReceipt: false }) - const result = await service.queueNotification('u2', 'quizPassFail', 'Quiz', 'Passed!') + const result = await service.queueNotification('user1', 'rewardReceipt', 'Title', 'Body') - expect(result).toBeDefined() - expect(result?.id).toBe('log-2') + 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' } }) + ) }) - describe('processQueue', () => { - it('does nothing when there are no pending logs', async () => { - prismaMock.notificationLog.findMany.mockResolvedValue([]) + it('should handle missing tokens', async () => { + const mockLog = { + id: 'log1', + title: 'T', + body: 'B', + user: { deviceTokens: [] } + } + mockPrisma.notificationLog.findMany.mockResolvedValue([mockLog]) - await expect(service.processQueue()).resolves.not.toThrow() - expect(prismaMock.notificationLog.update).not.toHaveBeenCalled() - }) - - it('marks log as failed when user has no device tokens', async () => { - const pendingLog = { - id: 'log-1', - title: 'Test', - body: 'Test body', - attemptCount: 0, - maxAttempts: 5, - user: { deviceTokens: [] } - } - prismaMock.notificationLog.findMany.mockResolvedValue([pendingLog]) - prismaMock.notificationLog.update.mockResolvedValue({}) - - await service.processQueue() - - expect(prismaMock.notificationLog.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ status: 'failed' }) - }) - ) - }) - }) + await service.processQueue() - describe('event type routing', () => { - it('queues with the correct event type for streakReminders', async () => { - prismaMock.notificationPreference.findUnique.mockResolvedValue(null) - const fakeLog = { id: 'log-3', status: 'pending' } - prismaMock.notificationLog.create.mockResolvedValue(fakeLog) - prismaMock.notificationLog.findMany.mockResolvedValue([]) - - const result = await service.queueNotification('u3', 'streakReminders', 'Streak!', 'Keep it up') - - expect(prismaMock.notificationLog.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ type: 'streakReminders' }) - }) - ) - expect(result?.id).toBe('log-3') + expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }) }) + ) }) + }) }) From 79e71f64558ba5323bccc4d09deea55f5dde9305 Mon Sep 17 00:00:00 2001 From: abayomicornelius Date: Fri, 1 May 2026 12:35:51 +0100 Subject: [PATCH 3/3] fix: satisfy lint newline-before-return rule Add the required blank line before the return statement in the Prisma client factory so the CI lint step passes. Made-with: Cursor --- src/config/database.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/database.ts b/src/config/database.ts index d28b938..9ec2033 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -24,6 +24,7 @@ function createPrismaClient(): PrismaClient { } const adapter = new PrismaPg(pool) + return new PrismaClient({ adapter }) }