From 7c4efcd0cc99820dcccf63abdd887f7ad1e3fcfb Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:49:38 +0100 Subject: [PATCH 1/9] feat: add REST API with tRPC-to-OpenAPI --- README.md | 11 + package.json | 2 + pnpm-lock.yaml | 273 ++++++++ prisma/schema.prisma | 14 + public/locales/en/common.json | 17 + src/components/Account/ApiKeyManager.tsx | 152 +++++ src/pages/account.tsx | 9 + src/pages/api/docs.ts | 32 + src/pages/api/openapi.json.ts | 24 + src/pages/api/v1/[...trpc].ts | 13 + src/server/api/root.ts | 2 + src/server/api/routers/openapi.ts | 762 +++++++++++++++++++++++ src/server/api/routers/user.ts | 64 ++ src/server/api/trpc.ts | 81 ++- src/tests/openapi.test.ts | 273 ++++++++ 15 files changed, 1716 insertions(+), 13 deletions(-) create mode 100644 src/components/Account/ApiKeyManager.tsx create mode 100644 src/pages/api/docs.ts create mode 100644 src/pages/api/openapi.json.ts create mode 100644 src/pages/api/v1/[...trpc].ts create mode 100644 src/server/api/routers/openapi.ts create mode 100644 src/tests/openapi.test.ts diff --git a/README.md b/README.md index 8e82ce58..051847a5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,17 @@ Recurring expenses require a PostgreSQL database with the `pg_cron` extension. W Bank integration allows you to load transactions from providers like Plaid and convert them into expenses. This feature was provided by @alexanderwassbjer, who is currently maintaining related issues. See [docs/BANK_TRANSACTIONS.md](docs/BANK_TRANSACTIONS.md). +### 10) REST API + +SplitPro exposes a REST API for integrations with external tools. Generate an API key in Account Settings, then authenticate with the `X-API-Key` header. The OpenAPI spec is available at `/api/openapi.json` and interactive docs at `/api/docs`. + +```bash +# Example: list expenses +curl -H "X-API-Key: sp_YOUR_KEY" https://your-instance.com/api/v1/expenses +``` + +Endpoints cover users, friends, groups, balances, and full expense CRUD. + ## Limitations and notes - SplitPro computes balances from expenses on the fly using database views. Expenses are the source of truth, which keeps balances consistent and trustworthy. For self hosted deployments the efficiency of database aggregations is entirely sufficient, but please do report any performance issues. diff --git a/package.json b/package.json index 8ef2a1e1..a84d17ce 100644 --- a/package.json +++ b/package.json @@ -72,9 +72,11 @@ "sharp": "^0.34.5", "sonner": "^1.4.0", "superjson": "^2.2.1", + "trpc-to-openapi": "2.4.0", "vaul": "^1.1.2", "web-push": "^3.6.7", "zod": "^3.22.4", + "zod-openapi": "4.2.4", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8692877..a1557168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: superjson: specifier: ^2.2.1 version: 2.2.6 + trpc-to-openapi: + specifier: 2.4.0 + version: 2.4.0(@trpc/server@11.8.0(typescript@5.7.3))(zod-openapi@4.2.4(zod@3.25.76))(zod@3.25.76) vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -169,6 +172,9 @@ importers: zod: specifier: ^3.22.4 version: 3.25.76 + zod-openapi: + specifier: 4.2.4 + version: 4.2.4(zod@3.25.76) zustand: specifier: ^4.5.0 version: 4.5.7(@types/react@19.2.1)(react@19.2.1) @@ -664,6 +670,9 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@hapi/bourne@3.0.0': + resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -1947,6 +1956,11 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rollup/rollup-linux-x64-gnu@4.6.1': + resolution: {integrity: sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA==} + cpu: [x64] + os: [linux] + '@serwist/build@9.2.3': resolution: {integrity: sha512-UU38GDsTerzoCRDIT5v62W/CcTMLfGZm/tAa+u8XLBU0y0f2aJ2GCfsHnI1eXhEuWvt4Y7Imx/uG2ww2KWIBWQ==} engines: {node: '>=18.0.0'} @@ -2518,6 +2532,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2530,6 +2548,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2603,6 +2625,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + co-body@6.2.0: + resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} + engines: {node: '>=8.0.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2642,6 +2668,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2671,6 +2700,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -2726,6 +2758,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2967,6 +3003,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + h3@1.15.1: + resolution: {integrity: sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2996,6 +3035,10 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3034,6 +3077,10 @@ packages: typescript: optional: true + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3050,6 +3097,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflation@2.1.0: + resolution: {integrity: sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==} + engines: {node: '>= 0.8.0'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -3063,6 +3114,9 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -3455,6 +3509,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3578,6 +3636,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3618,6 +3679,10 @@ packages: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -3636,6 +3701,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openapi3-ts@4.4.0: + resolution: {integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==} + openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} @@ -3847,6 +3915,10 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -3860,6 +3932,13 @@ packages: '@types/react-dom': optional: true + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -4017,6 +4096,9 @@ packages: typescript: optional: true + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4029,6 +4111,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4073,6 +4171,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -4181,6 +4283,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -4195,6 +4301,13 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trpc-to-openapi@2.4.0: + resolution: {integrity: sha512-B6xrwOC3Ab0q1BWD/QbJzK4OUpCLoT02hAzshSUXEuIZGcJZkMG/OJ4/3gd20dyr8aI+CrFirpWKRIo7JmHbMQ==} + peerDependencies: + '@trpc/server': ^11.1.0 + zod: ^3.23.8 + zod-openapi: 4.2.4 + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -4225,14 +4338,28 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + typescript@5.7.3: resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -4407,6 +4534,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-openapi@4.2.4: + resolution: {integrity: sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.21.4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -4779,6 +4912,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hapi/bourne@3.0.0': {} + '@heroicons/react@2.2.0(react@19.2.1)': dependencies: react: 19.2.1 @@ -6098,6 +6233,9 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rollup/rollup-linux-x64-gnu@4.6.1': + optional: true + '@serwist/build@9.2.3(typescript@5.7.3)': dependencies: common-tags: 1.8.2 @@ -6612,6 +6750,8 @@ snapshots: buffer-from@1.1.2: {} + bytes@3.1.2: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -6632,6 +6772,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelcase@5.3.1: {} @@ -6698,6 +6843,14 @@ snapshots: - '@types/react' - '@types/react-dom' + co-body@6.2.0: + dependencies: + '@hapi/bourne': 3.0.0 + inflation: 2.1.0 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + co@4.6.0: {} collect-v8-coverage@1.0.2: {} @@ -6724,6 +6877,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@1.2.3: {} + cookie@0.7.2: {} copy-anything@4.0.5: @@ -6752,6 +6907,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -6786,6 +6945,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -7046,6 +7207,18 @@ snapshots: graceful-fs@4.2.11: {} + h3@1.15.1: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -7072,6 +7245,14 @@ snapshots: dependencies: void-elements: 3.1.0 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -7110,6 +7291,10 @@ snapshots: optionalDependencies: typescript: 5.7.3 + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -7123,6 +7308,8 @@ snapshots: imurmurhash@0.1.4: {} + inflation@2.1.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -7135,6 +7322,8 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + iron-webcrypto@1.2.1: {} + is-arrayish@0.2.1: {} is-fullwidth-code-point@3.0.0: {} @@ -7726,6 +7915,8 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + merge-stream@2.0.0: {} micromatch@4.0.8: @@ -7828,6 +8019,8 @@ snapshots: node-int64@0.4.0: {} + node-mock-http@1.0.4: {} + node-releases@2.0.27: {} nodemailer@9.0.1: {} @@ -7863,6 +8056,8 @@ snapshots: object-hash@2.2.0: {} + object-inspect@1.13.4: {} + ohash@2.0.11: {} oidc-token-hash@5.1.0: {} @@ -7879,6 +8074,10 @@ snapshots: dependencies: mimic-function: 5.0.1 + openapi3-ts@4.4.0: + dependencies: + yaml: 2.8.3 + openid-client@5.7.1: dependencies: jose: 4.15.9 @@ -8046,6 +8245,11 @@ snapshots: pure-rand@7.0.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + radix-ui@1.4.3(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@radix-ui/primitive': 1.1.3 @@ -8109,6 +8313,15 @@ snapshots: '@types/react': 19.2.1 '@types/react-dom': 19.2.1(@types/react@19.2.1) + radix3@1.1.2: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -8238,6 +8451,8 @@ snapshots: optionalDependencies: typescript: 5.7.3 + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -8275,6 +8490,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -8310,6 +8553,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + statuses@2.0.2: {} + string-argv@0.3.2: {} string-length@4.0.2: @@ -8405,6 +8650,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -8419,6 +8666,17 @@ snapshots: dependencies: punycode: 2.3.1 + trpc-to-openapi@2.4.0(@trpc/server@11.8.0(typescript@5.7.3))(zod-openapi@4.2.4(zod@3.25.76))(zod@3.25.76): + dependencies: + '@trpc/server': 11.8.0(typescript@5.7.3) + co-body: 6.2.0 + h3: 1.15.1 + openapi3-ts: 4.4.0 + zod: 3.25.76 + zod-openapi: 4.2.4(zod@3.25.76) + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.6.1 + ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -8450,10 +8708,21 @@ snapshots: type-fest@0.21.3: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typescript@5.7.3: {} + ufo@1.6.4: {} + + uncrypto@0.1.3: {} + undici-types@6.21.0: {} + unpipe@1.0.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.3 @@ -8631,6 +8900,10 @@ snapshots: yocto-queue@0.1.0: {} + zod-openapi@4.2.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} zod@4.1.12: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 576ece26..26b1d743 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,7 @@ model User { friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB") sessions Session[] hiddenFriendIds Int[] @default([]) + apiKeys ApiKey[] @@schema("public") } @@ -270,6 +271,19 @@ model PushNotification { @@schema("public") } +model ApiKey { + id String @id @default(cuid()) + userId Int + name String + keyHash String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@schema("public") +} + model AppMetadata { key String @id value String diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 590275bc..7a010a59 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -62,6 +62,23 @@ "support_us": "Sponsor us", "title": "Account", "write_review": "Write a review", + "api_keys": "API Keys", + "api_key": { + "title": "API Keys", + "description": "Generate API keys to access the SplitPro REST API. Use the X-API-Key header to authenticate.", + "generate": "Generate", + "name_placeholder": "Key name (e.g., Personal, Zapier)", + "name_required": "Please enter a key name", + "new_key": "New API Key", + "copy_warning": "Copy this now — you won't be able to see it again.", + "created": "Created", + "last_used": "Last used", + "never_used": "Never used", + "deleted": "API key deleted", + "delete_error": "Failed to delete API key", + "create_error": "Failed to create API key", + "no_keys": "No API keys yet" + }, "debug_info": "Debug info", "debug_info_details": { "title": "Debug Information", diff --git a/src/components/Account/ApiKeyManager.tsx b/src/components/Account/ApiKeyManager.tsx new file mode 100644 index 00000000..5ec1aa29 --- /dev/null +++ b/src/components/Account/ApiKeyManager.tsx @@ -0,0 +1,152 @@ +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { api } from '~/utils/api'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '../ui/alert-dialog'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { toast } from 'sonner'; +import { Copy, Trash2 } from 'lucide-react'; + +export const ApiKeyManager: React.FC = ({ children }) => { + const { t } = useTranslation(); + const utils = api.useUtils(); + const [keyName, setKeyName] = useState(''); + const [newKey, setNewKey] = useState(null); + + const getApiKeys = api.user.getApiKeys.useQuery(); + const createApiKey = api.user.createApiKey.useMutation({ + onSuccess: async (data) => { + setNewKey(data.key); + await utils.user.getApiKeys.refetch(); + }, + }); + const deleteApiKey = api.user.deleteApiKey.useMutation({ + onSuccess: async () => { + await utils.user.getApiKeys.refetch(); + }, + }); + + const onCreate = useCallback(async () => { + if (!keyName.trim()) { + toast.error(t('account.api_key.name_required')); + return; + } + try { + await createApiKey.mutateAsync({ name: keyName.trim() }); + setKeyName(''); + } catch { + toast.error(t('account.api_key.create_error')); + } + }, [createApiKey, keyName, t]); + + const onDelete = useCallback( + async (id: string) => { + try { + await deleteApiKey.mutateAsync({ id }); + toast.success(t('account.api_key.deleted')); + } catch { + toast.error(t('account.api_key.delete_error')); + } + }, + [deleteApiKey, t], + ); + + const copyToClipboard = useCallback((text: string) => { + navigator.clipboard.writeText(text).catch(console.error); + toast.success(t('group_details.copied')); + }, [t]); + + return ( + + {children} + + + {t('account.api_key.title')} + {t('account.api_key.description')} + + +
+ {newKey && ( +
+

{t('account.api_key.new_key')}:

+
+ + {newKey} + + +
+

{t('account.api_key.copy_warning')}

+
+ )} + +
+ setKeyName(e.target.value)} + onKeyDown={(e) => { + if ('Enter' === e.key) { + void onCreate(); + } + }} + /> + +
+ +
+ {getApiKeys.data?.map((key) => ( +
+
+ {key.name} + + {t('account.api_key.created')}: {new Date(key.createdAt).toLocaleDateString()} + {key.lastUsedAt + ? ` · ${t('account.api_key.last_used')}: ${new Date(key.lastUsedAt).toLocaleDateString()}` + : ` · ${t('account.api_key.never_used')}`} + +
+ +
+ ))} + {getApiKeys.data && 0 === getApiKeys.data.length && ( +

{t('account.api_key.no_keys')}

+ )} +
+
+ + + { + setNewKey(null); + setKeyName(''); + }} + > + {t('actions.close')} + + +
+
+ ); +}; diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 3f37ad26..2ff9e416 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -6,6 +6,7 @@ import { DownloadCloud, FileDown, HeartHandshakeIcon, + Key, Languages, Star, } from 'lucide-react'; @@ -17,6 +18,7 @@ import { useRouter } from 'next/router'; import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { AccountButton } from '~/components/Account/AccountButton'; +import { ApiKeyManager } from '~/components/Account/ApiKeyManager'; import { DownloadAppDrawer } from '~/components/Account/DownloadAppDrawer'; import { LanguagePicker } from '~/components/Account/LanguagePicker'; import { SubmitFeedback } from '~/components/Account/SubmitFeedback'; @@ -155,6 +157,13 @@ const AccountPage: NextPageWithUser<{ {t('account.write_review')} + + + + {t('account.api_keys')} + + + diff --git a/src/pages/api/docs.ts b/src/pages/api/docs.ts new file mode 100644 index 00000000..169d90b5 --- /dev/null +++ b/src/pages/api/docs.ts @@ -0,0 +1,32 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const html = ` + + + + + SplitPro API Documentation + + + + +
+ + + +`; + + res.setHeader('Content-Type', 'text/html'); + res.status(200).send(html); +} diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts new file mode 100644 index 00000000..0f3b097f --- /dev/null +++ b/src/pages/api/openapi.json.ts @@ -0,0 +1,24 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; +import { generateOpenApiDocument } from 'trpc-to-openapi'; + +import { appRouter } from '~/server/api/root'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const openApiDocument = generateOpenApiDocument(appRouter, { + title: 'SplitPro REST API', + description: 'Official REST API for SplitPro. Authenticate with X-API-Key header.', + version: '1.0.0', + baseUrl: `${process.env.NEXTAUTH_URL ?? 'http://localhost:3000'}/api/v1`, + tags: ['User', 'Group', 'Expense'], + securitySchemes: { + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + }, + }, + }); + + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(JSON.stringify(openApiDocument, null, 2)); +} diff --git a/src/pages/api/v1/[...trpc].ts b/src/pages/api/v1/[...trpc].ts new file mode 100644 index 00000000..12462909 --- /dev/null +++ b/src/pages/api/v1/[...trpc].ts @@ -0,0 +1,13 @@ +import { createOpenApiNextHandler } from 'trpc-to-openapi'; +import { appRouter } from '~/server/api/root'; +import { createOpenApiContext } from '~/server/api/trpc'; + +export default createOpenApiNextHandler({ + router: appRouter, + createContext: createOpenApiContext, + onError: ({ path, error }) => { + if ('development' === process.env.NODE_ENV) { + console.error(`❌ OpenAPI failed on ${path ?? ''}: ${error.message}`); + } + }, +}); diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 951725e6..031bcb33 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -4,6 +4,7 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { userRouter } from './routers/user'; import { bankTransactionsRouter } from './routers/bankTransactions'; import { expenseRouter } from './routers/expense'; +import { openApiRouter } from './routers/openapi'; /** * This is the primary router for your server. @@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({ user: userRouter, bankTransactions: bankTransactionsRouter, expense: expenseRouter, + openApi: openApiRouter, }); // export type definition of API diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts new file mode 100644 index 00000000..822d1b7c --- /dev/null +++ b/src/server/api/routers/openapi.ts @@ -0,0 +1,762 @@ +import { SplitType } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { simplifyDebts } from '~/lib/simplify'; +import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; +import { db } from '~/server/db'; +import { createExpense, deleteExpense, editExpense } from '../services/splitService'; + +import type { CreateExpense as CreateExpenseType } from '~/types/expense.types'; + +const userOutputSchema = z.object({ + id: z.number(), + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + image: z.string().nullable().optional(), + currency: z.string(), +}); + +const MAX_API_KEY_FRIENDS_PER_PAGE = 50; + +const validateGroupMembership = async (groupId: number, userId: number, participants: number[]) => { + const userIds = [userId, ...participants].filter(Boolean); + const groupUsers = await db.groupUser.findMany({ + where: { + groupId, + userId: { in: userIds }, + }, + select: { userId: true }, + }); + + const memberIds = new Set(groupUsers.map((gu) => gu.userId)); + + if (!memberIds.has(userId)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this group' }); + } + + for (const participantId of participants) { + if (!memberIds.has(participantId)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `User ${participantId} is not a member of this group`, + }); + } + } +}; + +const validateEditExpensePermission = async (expenseId: string, userId: number) => { + const [expenseParticipant, addedBy] = await Promise.all([ + db.expenseParticipant.findUnique({ + where: { + expenseId_userId: { + expenseId, + userId, + }, + }, + }), + db.expense.findUnique({ where: { id: expenseId }, select: { addedBy: true } }), + ]); + + if (!expenseParticipant && addedBy?.addedBy !== userId) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You are not a participant of this expense', + }); + } +}; + +const mapRestToServiceInput = ( + input: { + expenseName: string; + amount: number; + currency: string; + expenseDate?: string; + category: string; + groupId: number | null; + paidById: number; + splitMethod: string; + participants: { userId: number; share?: number }[]; + }, + expenseId?: string, +): CreateExpenseType & { expenseId?: string } => { + const participantCount = input.participants.length; + const equalShare = + input.splitMethod === SplitType.EQUAL && participantCount > 0 + ? BigInt(input.amount) / BigInt(participantCount) + : null; + + return { + ...(expenseId ? { expenseId } : {}), + name: input.expenseName, + amount: BigInt(input.amount), + currency: input.currency, + category: input.category, + groupId: input.groupId, + paidBy: input.paidById, + splitType: input.splitMethod as SplitType, + expenseDate: input.expenseDate ? new Date(input.expenseDate) : new Date(), + participants: input.participants.map((p) => ({ + userId: p.userId, + amount: + null !== equalShare + ? equalShare + : p.share !== undefined + ? BigInt(p.share) + : 0n, + })), + }; +}; + +export const openApiRouter = createTRPCRouter({ + me: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/me', + summary: 'Get current authenticated user', + tags: ['User'], + protect: true, + }, + }) + .input(z.object({})) + .output(userOutputSchema) + .query(({ ctx }) => ctx.session.user), + + getFriends: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/friends', + summary: 'List friends with balances', + tags: ['User'], + protect: true, + }, + }) + .input(z.object({})) + .output( + z.array( + z.object({ + id: z.number(), + name: z.string().nullable(), + email: z.string().nullable(), + image: z.string().nullable(), + balance: z.number(), + currency: z.string(), + }), + ), + ) + .query(async ({ ctx }) => { + const rawBalances = await db.balanceView.findMany({ + where: { + userId: ctx.session.user.id, + friendId: { notIn: ctx.session.user.hiddenFriendIds }, + }, + include: { + group: { + select: { + simplifyDebts: true, + }, + }, + }, + }); + + const processedBalances = await Promise.all( + rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => { + if (!group?.simplifyDebts || null === groupId) { + return { friendId, currency, amount }; + } + + const allGroupBalances = await db.balanceView.findMany({ + where: { groupId, currency }, + }); + + const simplified = simplifyDebts(allGroupBalances); + const simplifiedBalance = simplified.find( + (b) => + b.userId === ctx.session.user.id && + b.friendId === friendId && + b.currency === currency, + ); + + return { friendId, currency, amount: simplifiedBalance?.amount ?? 0n }; + }), + ); + + const aggregated = processedBalances.reduce>>( + (acc, { friendId, currency, amount }) => { + if (!acc.has(friendId)) { + acc.set(friendId, new Map()); + } + const friendMap = acc.get(friendId)!; + friendMap.set(currency, (friendMap.get(currency) ?? 0n) + amount); + return acc; + }, + new Map(), + ); + + const friendIds = [...aggregated.keys()]; + const users = await db.user.findMany({ + where: { id: { in: friendIds } }, + }); + const userMap = Object.fromEntries(users.map((u) => [u.id, u])); + + const result = []; + for (const [friendId, currencies] of aggregated) { + const user = userMap[friendId]; + if (!user) continue; + + for (const [currency, amount] of currencies) { + if (0n !== amount) { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: Number(amount), + currency, + }); + } + } + } + + return result; + }), + + getAllGroups: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups', + summary: 'List all groups', + tags: ['Group'], + protect: true, + }, + }) + .input(z.object({})) + .output( + z.array( + z.object({ + id: z.number(), + name: z.string(), + currency: z.string(), + memberCount: z.number(), + simplifyDebt: z.boolean(), + }), + ), + ) + .query(async ({ ctx }) => { + const groups = await db.group.findMany({ + where: { + groupUsers: { + some: { + userId: ctx.session.user.id, + }, + }, + }, + include: { + _count: { + select: { groupUsers: true }, + }, + }, + }); + + return groups.map((g) => ({ + id: g.id, + name: g.name, + currency: g.defaultCurrency ?? '', + memberCount: g._count.groupUsers, + simplifyDebt: g.simplifyDebts, + })); + }), + + getGroupDetails: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups/{id}', + summary: 'Get group with members', + tags: ['Group'], + protect: true, + }, + }) + .input(z.object({ id: z.number() })) + .output( + z.object({ + id: z.number(), + name: z.string(), + currency: z.string(), + simplifyDebt: z.boolean(), + members: z.array( + z.object({ + id: z.number(), + name: z.string().nullable(), + email: z.string().nullable(), + }), + ), + }), + ) + .query(async ({ input }) => { + const group = await db.group.findUnique({ + where: { id: input.id }, + include: { + groupUsers: { + include: { + user: true, + }, + }, + }, + }); + + if (!group) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' }); + } + + return { + id: group.id, + name: group.name, + currency: group.defaultCurrency ?? '', + simplifyDebt: group.simplifyDebts, + members: group.groupUsers.map((gu) => ({ + id: gu.user.id, + name: gu.user.name, + email: gu.user.email, + })), + }; + }), + + getGroupBalances: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups/{id}/balances', + summary: 'Get group balances', + tags: ['Group'], + protect: true, + }, + }) + .input(z.object({ id: z.number() })) + .output( + z.object({ + groupId: z.number(), + balances: z.array( + z.object({ + userId: z.number(), + userName: z.string().nullable(), + netBalance: z.number(), + }), + ), + }), + ) + .query(async ({ input }) => { + const [rawBalances, group] = await Promise.all([ + db.balanceView.findMany({ + where: { groupId: input.id }, + include: { user: true }, + }), + db.group.findUnique({ + where: { id: input.id }, + select: { simplifyDebts: true }, + }), + ]); + + const simplified = group?.simplifyDebts + ? simplifyDebts(rawBalances) + : [...rawBalances]; + + const userIds = [...new Set(simplified.map((b) => b.friendId ?? b.userId))]; + + const aggregated = userIds.map((userId) => { + const user = rawBalances.find((b) => b.userId === userId)?.user; + const balance = simplified + .filter((b) => b.userId === userId && b.friendId !== null) + .reduce((sum, b) => sum + b.amount, 0n); + + return { + userId, + userName: user?.name ?? null, + netBalance: Number(balance), + }; + }); + + return { groupId: input.id, balances: aggregated }; + }), + + getGroupExpenses: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups/{id}/expenses', + summary: 'List group expenses', + tags: ['Expense'], + protect: true, + }, + }) + .input( + z.object({ + id: z.number(), + since: z.string().datetime().optional(), + limit: z.number().int().min(1).max(MAX_API_KEY_FRIENDS_PER_PAGE).default(20), + offset: z.number().int().min(0).default(0), + }), + ) + .output( + z.object({ + expenses: expenseOutputSchemaArray(), + total: z.number(), + }), + ) + .query(async ({ input }) => { + const { id, since, limit, offset } = input; + + const where = { + groupId: id, + deletedBy: null, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + const [expenses, total] = await Promise.all([ + db.expense.findMany({ + where, + include: { + expenseParticipants: true, + paidByUser: true, + group: true, + deletedByUser: true, + }, + orderBy: { expenseDate: 'desc' }, + skip: offset, + take: limit, + }), + db.expense.count({ where }), + ]); + + return { expenses: expenses.map(normalizeExpense), total }; + }), + + getExpenses: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/expenses', + summary: 'List expenses across groups', + tags: ['Expense'], + protect: true, + }, + }) + .input( + z.object({ + groupId: z.number().optional(), + since: z.string().datetime().optional(), + limit: z.number().int().min(1).max(MAX_API_KEY_FRIENDS_PER_PAGE).default(20), + offset: z.number().int().min(0).default(0), + }), + ) + .output( + z.object({ + expenses: expenseOutputSchemaArray(), + total: z.number(), + }), + ) + .query(async ({ input, ctx }) => { + const { groupId, since, limit, offset } = input; + + const expenseWhere = { + deletedBy: null, + ...(groupId + ? { groupId, group: { groupUsers: { some: { userId: ctx.session.user.id } } } } + : { + expenseParticipants: { + some: { userId: ctx.session.user.id }, + }, + }), + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + const [expenseParticipants, total] = await Promise.all([ + db.expenseParticipant.findMany({ + where: { expense: expenseWhere }, + orderBy: { expense: { expenseDate: 'desc' } }, + skip: offset, + take: limit, + include: { + expense: { + include: { + expenseParticipants: true, + paidByUser: true, + group: true, + deletedByUser: true, + }, + }, + }, + }), + db.expense.count({ where: expenseWhere }), + ]); + + return { + expenses: expenseParticipants.map((ep) => normalizeExpense(ep.expense)), + total, + }; + }), + + getExpenseDetails: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/expenses/{id}', + summary: 'Get single expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(z.object({ id: z.string() })) + .output(expenseDetailSchema()) + .query(async ({ input, ctx }) => { + await validateEditExpensePermission(input.id, ctx.session.user.id); + + const expense = await db.expense.findUnique({ + where: { id: input.id }, + include: { + expenseParticipants: { + include: { + user: true, + }, + }, + expenseNotes: true, + addedByUser: true, + paidByUser: true, + group: true, + }, + }); + + if (!expense) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Expense not found' }); + } + + return normalizeExpenseDetail(expense); + }), + + createExpense: protectedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/expenses', + summary: 'Create expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(expenseInputSchema()) + .output(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + if (null !== input.groupId) { + const allParticipantIds = input.participants.map((p) => p.userId); + await validateGroupMembership(input.groupId, ctx.session.user.id, [ + input.paidById, + ...allParticipantIds, + ]); + } + + const serviceInput = mapRestToServiceInput(input); + const { notes } = input; + + const expense = await createExpense(serviceInput, ctx.session.user.id); + + if (notes) { + await db.expenseNote.create({ + data: { + note: notes, + createdById: ctx.session.user.id, + expenseId: expense.id, + }, + }); + } + + return { id: expense.id }; + }), + + updateExpense: protectedProcedure + .meta({ + openapi: { + method: 'PUT', + path: '/expenses/{id}', + summary: 'Update expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(z.object({ id: z.string() }).merge(expenseInputSchema())) + .output(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + const { id, ...data } = input; + + await validateEditExpensePermission(id, ctx.session.user.id); + + if (null !== data.groupId) { + const allParticipantIds = data.participants.map((p) => p.userId); + await validateGroupMembership(data.groupId, ctx.session.user.id, [ + data.paidById, + ...allParticipantIds, + ]); + } + + const serviceInput = mapRestToServiceInput(data, id); + const { notes } = data; + + await editExpense(serviceInput, ctx.session.user.id); + + if (notes) { + await db.expenseNote.deleteMany({ where: { expenseId: id } }); + await db.expenseNote.create({ + data: { + note: notes, + createdById: ctx.session.user.id, + expenseId: id, + }, + }); + } + + return { id }; + }), + + deleteExpense: protectedProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/expenses/{id}', + summary: 'Delete expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(z.object({ id: z.string() })) + .output(z.object({})) + .mutation(async ({ input, ctx }) => { + await validateEditExpensePermission(input.id, ctx.session.user.id); + await deleteExpense(input.id, ctx.session.user.id); + }), +}); + +function expenseOutputSchemaArray() { + return z.array(expenseOutputSchema()); +} + +function expenseOutputSchema() { + return z.object({ + id: z.string(), + expenseName: z.string(), + amount: z.number(), + currency: z.string(), + expenseDate: z.string().datetime(), + category: z.string(), + paidBy: z.object({ + id: z.number(), + name: z.string().nullable(), + }), + splitMethod: z.string(), + participants: z.array( + z.object({ + userId: z.number(), + share: z.number().nullable(), + }), + ), + groupId: z.number().nullable(), + isReimbursement: z.boolean(), + }); +} + +function expenseDetailSchema() { + return expenseOutputSchema().extend({ + notes: z.string().nullable(), + addedBy: z.object({ + id: z.number(), + name: z.string().nullable(), + }), + }); +} + +function expenseInputSchema() { + return z.object({ + expenseName: z.string(), + amount: z.number().int(), + currency: z.string(), + expenseDate: z.string().datetime().optional(), + category: z.string(), + notes: z.string().optional(), + groupId: z.number().nullable(), + paidById: z.number(), + splitMethod: z.enum([ + SplitType.ADJUSTMENT, + SplitType.EQUAL, + SplitType.PERCENTAGE, + SplitType.SHARE, + SplitType.EXACT, + SplitType.SETTLEMENT, + ]), + participants: z.array( + z.object({ + userId: z.number(), + share: z.number().int().optional(), + }), + ), + }); +} + +function normalizeExpense(expense: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; +}) { + return { + id: expense.id, + expenseName: expense.name, + amount: Number(expense.amount), + currency: expense.currency, + expenseDate: expense.expenseDate.toISOString(), + category: expense.category, + paidBy: { + id: expense.paidByUser?.id ?? 0, + name: expense.paidByUser?.name ?? null, + }, + splitMethod: expense.splitType, + participants: expense.expenseParticipants.map((ep) => ({ + userId: ep.userId, + share: Number(ep.amount), + })), + groupId: expense.groupId, + isReimbursement: SplitType.SETTLEMENT === expense.splitType, + }; +} + +function normalizeExpenseDetail(expense: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + expenseNotes: { note: string }[]; + paidByUser: { id: number; name: string | null } | null; + addedByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; +}) { + return { + ...normalizeExpense(expense), + notes: expense.expenseNotes[0]?.note ?? null, + addedBy: { + id: expense.addedByUser?.id ?? 0, + name: expense.addedByUser?.name ?? null, + }, + }; +} + +export type OpenApiRouter = typeof openApiRouter; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 1b7e9a87..c700de3c 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,3 +1,5 @@ +import { createHash } from 'crypto'; + import { TRPCError } from '@trpc/server'; import { type User } from 'next-auth'; import { z } from 'zod'; @@ -419,6 +421,68 @@ export const userRouter = createTRPCRouter({ }), getWebPushPublicKey: protectedProcedure.query(() => env.WEB_PUSH_PUBLIC_KEY ?? ''), + + getApiKeys: protectedProcedure.query(async ({ ctx }) => { + const keys = await db.apiKey.findMany({ + where: { userId: ctx.session.user.id }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + createdAt: true, + lastUsedAt: true, + }, + }); + + return keys; + }), + + createApiKey: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + const existingCount = await db.apiKey.count({ + where: { userId: ctx.session.user.id }, + }); + + if (existingCount >= 10) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Maximum of 10 API keys per user', + }); + } + + const rawKey = `sp_${crypto.randomUUID().replace(/-/g, '')}`; + const keyHash = createHash('sha256').update(rawKey).digest('hex'); + + await db.apiKey.create({ + data: { + userId: ctx.session.user.id, + name: input.name, + keyHash, + }, + }); + + return { key: rawKey }; + }), + + deleteApiKey: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + const key = await db.apiKey.findFirst({ + where: { + id: input.id, + userId: ctx.session.user.id, + }, + }); + + if (!key) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); + } + + await db.apiKey.delete({ where: { id: input.id } }); + + return true; + }), }); export const getUserMap = async (userIds: number[]) => { diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index fa6fdbc1..64de34b5 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -12,6 +12,8 @@ import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; import { type Session } from 'next-auth'; import superjson from 'superjson'; import { ZodError, z } from 'zod'; +import { OpenApiMeta } from 'trpc-to-openapi'; +import { createHash } from 'crypto'; import { getServerAuthSession } from '~/server/auth'; import { db } from '~/server/db'; @@ -38,7 +40,7 @@ interface CreateContextOptions { * * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts */ -const createInnerTRPCContext = (opts: CreateContextOptions) => ({ +export const createInnerTRPCContext = (opts: CreateContextOptions) => ({ session: opts.session, db, }); @@ -60,6 +62,55 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { }); }; +/** + * Creates context for OpenAPI REST requests. Authenticates via X-API-Key header. + */ +export const createOpenApiContext = async (opts: CreateNextContextOptions & { info?: unknown }) => { + const { req, res } = opts; + + const apiKey = req.headers['x-api-key']; + if (apiKey && 'string' === typeof apiKey) { + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + const keyRecord = await db.apiKey.findUnique({ + where: { keyHash }, + include: { user: true }, + }); + + if (keyRecord) { + const shouldUpdateLastUsed = + !keyRecord.lastUsedAt || + new Date().getTime() - keyRecord.lastUsedAt.getTime() > 5 * 60 * 1000; + + if (shouldUpdateLastUsed) { + await db.apiKey.update({ + where: { id: keyRecord.id }, + data: { lastUsedAt: new Date() }, + }); + } + + return createInnerTRPCContext({ + session: { + user: { + id: keyRecord.user.id, + name: keyRecord.user.name, + email: keyRecord.user.email, + image: keyRecord.user.image, + currency: keyRecord.user.currency, + defaultCurrency: keyRecord.user.defaultCurrency, + obapiProviderId: keyRecord.user.obapiProviderId, + bankingId: keyRecord.user.bankingId, + preferredLanguage: keyRecord.user.preferredLanguage, + hiddenFriendIds: keyRecord.user.hiddenFriendIds, + }, + expires: new Date(Date.now() + 86400000).toISOString(), + } as Session, + }); + } + } + + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid or missing API key' }); +}; + /** * 2. INITIALIZATION * @@ -68,18 +119,21 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { * errors on the backend. */ -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }; - }, -}); +const t = initTRPC + .context() + .meta() + .create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, + }); /** * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) @@ -94,6 +148,7 @@ const t = initTRPC.context().create({ * @see https://trpc.io/docs/router */ export const createTRPCRouter = t.router; +export const createCallerFactory = t.createCallerFactory; /** * Public (unauthenticated) procedure diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts new file mode 100644 index 00000000..83cf5490 --- /dev/null +++ b/src/tests/openapi.test.ts @@ -0,0 +1,273 @@ +import { createHash } from 'crypto'; +import { SplitType } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; + +import { simplifyDebts } from '~/lib/simplify'; + +jest.mock('~/server/db', () => ({ + db: { + apiKey: { + findUnique: jest.fn(), + update: jest.fn(), + }, + expenseParticipant: { + findUnique: jest.fn(), + }, + expense: { + findUnique: jest.fn(), + }, + groupUser: { + findMany: jest.fn(), + }, + }, +})); + +jest.mock('~/server/api/trpc', () => ({ + createTRPCRouter: jest.fn(), + protectedProcedure: { use: jest.fn() }, + publicProcedure: { use: jest.fn() }, + createCallerFactory: jest.fn(), + createInnerTRPCContext: jest.fn(), + createTRPCContext: jest.fn(), + createOpenApiContext: jest.fn(), + groupProcedure: { input: jest.fn(), use: jest.fn() }, +})); + +describe('API key hash', () => { + it('should produce consistent SHA-256 hashes', () => { + const key = 'sp_test_key_123'; + const hash1 = createHash('sha256').update(key).digest('hex'); + const hash2 = createHash('sha256').update(key).digest('hex'); + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); + }); + + it('should produce different hashes for different keys', () => { + const hash1 = createHash('sha256').update('sp_key_one').digest('hex'); + const hash2 = createHash('sha256').update('sp_key_two').digest('hex'); + expect(hash1).not.toBe(hash2); + }); +}); + +describe('simplifyDebts', () => { + it('should simplify group balances to zero in a 3-way cycle', () => { + const balances = [ + { userId: 1, friendId: 2, groupId: 1, currency: 'USD', amount: 100n, createdAt: new Date(), updatedAt: new Date() }, + { userId: 2, friendId: 3, groupId: 1, currency: 'USD', amount: 100n, createdAt: new Date(), updatedAt: new Date() }, + { userId: 3, friendId: 1, groupId: 1, currency: 'USD', amount: 100n, createdAt: new Date(), updatedAt: new Date() }, + ]; + + const simplified = simplifyDebts(balances); + const total = simplified.reduce((sum, b) => sum + b.amount, 0n); + expect(total).toBe(0n); + }); + + it('should preserve a simple direct debt', () => { + const balances = [ + { userId: 1, friendId: 2, groupId: null, currency: 'USD', amount: 5000n, createdAt: new Date(), updatedAt: new Date() }, + ]; + + const simplified = simplifyDebts(balances); + expect(simplified.length).toBeGreaterThanOrEqual(1); + expect(simplified.some(b => b.userId === 1 && b.friendId === 2 && b.amount === 5000n)).toBe(true); + }); +}); + +describe('normalizeExpense', () => { + const expense: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + } = { + id: 'exp-1', + name: 'Test Expense', + amount: 1234n, + currency: 'USD', + expenseDate: new Date('2025-01-15'), + category: 'Food', + splitType: SplitType.EQUAL, + groupId: null as number | null, + paidByUser: { id: 1, name: 'Alice' } as { id: number; name: string | null }, + expenseParticipants: [ + { userId: 1, amount: 617n }, + { userId: 2, amount: 617n }, + ], + }; + + const normalize = (e: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + }) => ({ + id: e.id, + expenseName: e.name, + amount: Number(e.amount), + currency: e.currency, + expenseDate: e.expenseDate.toISOString(), + category: e.category, + paidBy: { + id: e.paidByUser?.id ?? 0, + name: e.paidByUser?.name ?? null, + }, + splitMethod: e.splitType, + participants: e.expenseParticipants.map((ep) => ({ + userId: ep.userId, + share: Number(ep.amount), + })), + groupId: e.groupId, + isReimbursement: SplitType.SETTLEMENT === e.splitType, + }); + + it('should convert bigint amounts to numbers', () => { + const result = normalize(expense); + expect(result.amount).toBe(1234); + expect(result.participants[0]?.share).toBe(617); + }); + + it('should format dates as ISO 8601 strings', () => { + const result = normalize(expense); + expect(result.expenseDate).toBe('2025-01-15T00:00:00.000Z'); + }); + + it('should mark settlement expenses as reimbursements', () => { + const settlement = { ...expense, splitType: SplitType.SETTLEMENT }; + const result = normalize(settlement); + expect(result.isReimbursement).toBe(true); + }); + + it('should not mark EQUAL expenses as reimbursements', () => { + const result = normalize(expense); + expect(result.isReimbursement).toBe(false); + }); + + it('should handle null paidByUser gracefully', () => { + const unlinked = { + ...expense, + paidByUser: null, + }; + const result = normalize(unlinked); + expect(result.paidBy.id).toBe(0); + expect(result.paidBy.name).toBeNull(); + }); +}); + +describe('REST input to service input mapping', () => { + it('should map expenseName to name and paidById to paidBy', () => { + const rest = { + expenseName: 'Coffee', + amount: 500, + currency: 'USD', + category: 'Food', + groupId: null as number | null, + paidById: 1, + splitMethod: SplitType.EQUAL, + participants: [{ userId: 1 }, { userId: 2 }], + }; + + expect(rest.expenseName).toBe('Coffee'); + expect(BigInt(rest.amount)).toBe(500n); + expect(rest.paidById).toBe(1); + }); + + it('should calculate equal shares for EQUAL split type', () => { + const participantCount = 4; + const amount = 1000n; + const equalShare = amount / BigInt(participantCount); + + expect(equalShare).toBe(250n); + }); + + it('should integer-divide amount for equal splits', () => { + const participantCount = 3; + const amount = 1000n; + const share = amount / BigInt(participantCount); + + expect(share).toBe(333n); + }); +}); + +describe('validateEditExpensePermission logic', () => { + describe('addedBy check condition: !participant && addedBy?.addedBy !== userId', () => { + it('should not throw when user is a participant (short-circuit)', () => { + const participant = { userId: 1 }; + const addedBy = { addedBy: 2 }; + const userId = 1; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(false); + }); + + it('should not throw when user is the creator (addedBy matches userId)', () => { + const participant = null; + const addedBy = { addedBy: 2 }; + const userId = 2; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(false); + }); + + it('should throw when user is neither participant nor creator', () => { + const participant = null; + const addedBy = { addedBy: 2 }; + const userId = 3; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(true); + }); + + it('should throw when addedBy is null and user is not a participant', () => { + const participant = null; + const addedBy = null as { addedBy: number } | null; + const userId = 3; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(true); + }); + }); +}); + +describe('group membership validation logic', () => { + it('should allow when user is in the group member set', () => { + const memberIds = new Set([1, 2, 3]); + const userId = 1; + + expect(memberIds.has(userId)).toBe(true); + }); + + it('should deny when user is not in the group member set', () => { + const memberIds = new Set([1, 2, 3]); + const userId = 4; + + expect(memberIds.has(userId)).toBe(false); + }); + + it('should ensure all participants are group members', () => { + const memberIds = new Set([1, 2, 3]); + const participantIds = [1, 2, 3]; + + const allMembers = participantIds.every((id) => memberIds.has(id)); + expect(allMembers).toBe(true); + }); + + it('should detect when a participant is not a group member', () => { + const memberIds = new Set([1, 2]); + const participantIds = [1, 2, 3]; + + const allMembers = participantIds.every((id) => memberIds.has(id)); + expect(allMembers).toBe(false); + }); +}); From fb4f76fe1124acbcf403cbfb2551a7fa6848b9ef Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:56:22 +0100 Subject: [PATCH 2/9] prettier --- src/components/Account/ApiKeyManager.tsx | 11 +++--- src/server/api/routers/openapi.ts | 11 ++---- src/tests/openapi.test.ts | 44 +++++++++++++++++++++--- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/components/Account/ApiKeyManager.tsx b/src/components/Account/ApiKeyManager.tsx index 5ec1aa29..f0011c6e 100644 --- a/src/components/Account/ApiKeyManager.tsx +++ b/src/components/Account/ApiKeyManager.tsx @@ -61,10 +61,13 @@ export const ApiKeyManager: React.FC = ({ children }) = [deleteApiKey, t], ); - const copyToClipboard = useCallback((text: string) => { - navigator.clipboard.writeText(text).catch(console.error); - toast.success(t('group_details.copied')); - }, [t]); + const copyToClipboard = useCallback( + (text: string) => { + navigator.clipboard.writeText(text).catch(console.error); + toast.success(t('group_details.copied')); + }, + [t], + ); return ( diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts index 822d1b7c..b3bbf492 100644 --- a/src/server/api/routers/openapi.ts +++ b/src/server/api/routers/openapi.ts @@ -98,12 +98,7 @@ const mapRestToServiceInput = ( expenseDate: input.expenseDate ? new Date(input.expenseDate) : new Date(), participants: input.participants.map((p) => ({ userId: p.userId, - amount: - null !== equalShare - ? equalShare - : p.share !== undefined - ? BigInt(p.share) - : 0n, + amount: null !== equalShare ? equalShare : p.share !== undefined ? BigInt(p.share) : 0n, })), }; }; @@ -360,9 +355,7 @@ export const openApiRouter = createTRPCRouter({ }), ]); - const simplified = group?.simplifyDebts - ? simplifyDebts(rawBalances) - : [...rawBalances]; + const simplified = group?.simplifyDebts ? simplifyDebts(rawBalances) : [...rawBalances]; const userIds = [...new Set(simplified.map((b) => b.friendId ?? b.userId))]; diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts index 83cf5490..e54b1097 100644 --- a/src/tests/openapi.test.ts +++ b/src/tests/openapi.test.ts @@ -52,9 +52,33 @@ describe('API key hash', () => { describe('simplifyDebts', () => { it('should simplify group balances to zero in a 3-way cycle', () => { const balances = [ - { userId: 1, friendId: 2, groupId: 1, currency: 'USD', amount: 100n, createdAt: new Date(), updatedAt: new Date() }, - { userId: 2, friendId: 3, groupId: 1, currency: 'USD', amount: 100n, createdAt: new Date(), updatedAt: new Date() }, - { userId: 3, friendId: 1, groupId: 1, currency: 'USD', amount: 100n, createdAt: new Date(), updatedAt: new Date() }, + { + userId: 1, + friendId: 2, + groupId: 1, + currency: 'USD', + amount: 100n, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + userId: 2, + friendId: 3, + groupId: 1, + currency: 'USD', + amount: 100n, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + userId: 3, + friendId: 1, + groupId: 1, + currency: 'USD', + amount: 100n, + createdAt: new Date(), + updatedAt: new Date(), + }, ]; const simplified = simplifyDebts(balances); @@ -64,12 +88,22 @@ describe('simplifyDebts', () => { it('should preserve a simple direct debt', () => { const balances = [ - { userId: 1, friendId: 2, groupId: null, currency: 'USD', amount: 5000n, createdAt: new Date(), updatedAt: new Date() }, + { + userId: 1, + friendId: 2, + groupId: null, + currency: 'USD', + amount: 5000n, + createdAt: new Date(), + updatedAt: new Date(), + }, ]; const simplified = simplifyDebts(balances); expect(simplified.length).toBeGreaterThanOrEqual(1); - expect(simplified.some(b => b.userId === 1 && b.friendId === 2 && b.amount === 5000n)).toBe(true); + expect(simplified.some((b) => b.userId === 1 && b.friendId === 2 && b.amount === 5000n)).toBe( + true, + ); }); }); From f2f913ec1f14a81e7d8a72e1661f916815fe3e47 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:08:08 +0100 Subject: [PATCH 3/9] migrations --- .../20260701220715_add_api_keys/migration.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 prisma/migrations/20260701220715_add_api_keys/migration.sql diff --git a/prisma/migrations/20260701220715_add_api_keys/migration.sql b/prisma/migrations/20260701220715_add_api_keys/migration.sql new file mode 100644 index 00000000..34088913 --- /dev/null +++ b/prisma/migrations/20260701220715_add_api_keys/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; From c0869446da1dfb69750ce1aa6de264ff89b8be04 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:57:55 +0100 Subject: [PATCH 4/9] fix: apply sign convention for EQUAL splits in REST expense API --- src/server/api/routers/openapi.ts | 52 ++++++++++++++++++-- src/tests/openapi.test.ts | 80 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts index b3bbf492..ff9e3c58 100644 --- a/src/server/api/routers/openapi.ts +++ b/src/server/api/routers/openapi.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { simplifyDebts } from '~/lib/simplify'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; +import { BigMath } from '~/utils/numbers'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; import type { CreateExpense as CreateExpenseType } from '~/types/expense.types'; @@ -66,6 +67,16 @@ const validateEditExpensePermission = async (expenseId: string, userId: number) } }; +const deduplicateByUserId = (participants: T[]): T[] => { + const seen = new Map(); + for (const p of participants) { + if (!seen.has(p.userId)) { + seen.set(p.userId, p); + } + } + return [...seen.values()]; +}; + const mapRestToServiceInput = ( input: { expenseName: string; @@ -86,20 +97,51 @@ const mapRestToServiceInput = ( ? BigInt(input.amount) / BigInt(participantCount) : null; + const totalAmount = BigInt(input.amount); + + const participants = deduplicateByUserId( + input.participants.map((p) => { + if (null !== equalShare) { + const share = equalShare; + if (p.userId === input.paidById) { + return { userId: p.userId, amount: -share + totalAmount }; + } + return { userId: p.userId, amount: -share }; + } + + const amount = p.share !== undefined ? BigInt(p.share) : 0n; + return { userId: p.userId, amount }; + }), + ); + + if (null !== equalShare) { + let penniesLeft = participants.reduce((acc, p) => acc + p.amount, 0n); + const nonPayerParticipants = participants.filter( + (p) => p.userId !== input.paidById && 0n !== p.amount, + ); + + if (nonPayerParticipants.length > 0) { + let i = 0; + while (0n !== penniesLeft) { + const p = nonPayerParticipants[i % nonPayerParticipants.length]!; + p.amount -= BigMath.sign(penniesLeft); + penniesLeft -= BigMath.sign(penniesLeft); + i++; + } + } + } + return { ...(expenseId ? { expenseId } : {}), name: input.expenseName, - amount: BigInt(input.amount), + amount: totalAmount, currency: input.currency, category: input.category, groupId: input.groupId, paidBy: input.paidById, splitType: input.splitMethod as SplitType, expenseDate: input.expenseDate ? new Date(input.expenseDate) : new Date(), - participants: input.participants.map((p) => ({ - userId: p.userId, - amount: null !== equalShare ? equalShare : p.share !== undefined ? BigInt(p.share) : 0n, - })), + participants, }; }; diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts index e54b1097..d6483c84 100644 --- a/src/tests/openapi.test.ts +++ b/src/tests/openapi.test.ts @@ -232,6 +232,86 @@ describe('REST input to service input mapping', () => { expect(share).toBe(333n); }); + + it('should apply sign convention for EQUAL split: payer gets positive, non-payers negative', () => { + const amount = 10; + const paidBy = 150; + const participants = [{ userId: 150 }, { userId: 151 }]; + + const totalAmount = BigInt(amount); + const equalShare = totalAmount / BigInt(participants.length); + + const result = participants.map((p) => { + if (p.userId === paidBy) { + return { userId: p.userId, amount: -equalShare + totalAmount }; + } + return { userId: p.userId, amount: -equalShare }; + }); + + expect(result[0]?.amount).toBe(5n); + expect(result[1]?.amount).toBe(-5n); + expect(result.reduce((acc, p) => acc + p.amount, 0n)).toBe(0n); + }); + + it('should handle penny remainder for EQUAL split with uneven division', () => { + const amount = 10; + const paidBy = 1; + const participants = [{ userId: 1 }, { userId: 2 }, { userId: 3 }]; + + const totalAmount = BigInt(amount); + const equalShare = totalAmount / BigInt(participants.length); + + const result = participants.map((p) => { + if (p.userId === paidBy) { + return { userId: p.userId, amount: -equalShare + totalAmount }; + } + return { userId: p.userId, amount: -equalShare }; + }); + + let penniesLeft = result.reduce((acc, p) => acc + p.amount, 0n); + const nonPayerParticipants = result.filter((p) => p.userId !== paidBy && 0n !== p.amount); + const sign = (x: bigint) => (0n === x ? 0n : 0n > x ? -1n : 1n); + let i = 0; + while (0n !== penniesLeft) { + const p = nonPayerParticipants[i % nonPayerParticipants.length]!; + p.amount -= sign(penniesLeft); + penniesLeft -= sign(penniesLeft); + i++; + } + + expect(result[0]?.amount).toBe(7n); + expect(result.reduce((acc, p) => acc + p.amount, 0n)).toBe(0n); + }); + + it('should leave non-EQUAL split shares as-is (pass-through)', () => { + const participants = [{ userId: 1, share: 50 }, { userId: 2, share: -50 }]; + + const result = participants.map((p) => ({ + userId: p.userId, + amount: p.share !== undefined ? BigInt(p.share) : 0n, + })); + + expect(result[0]?.amount).toBe(50n); + expect(result[1]?.amount).toBe(-50n); + }); + + it('should compute 0 as share for EQUAL split with single participant', () => { + const amount = 1000; + const paidBy = 1; + const participants = [{ userId: 1 }]; + + const totalAmount = BigInt(amount); + const equalShare = totalAmount / BigInt(participants.length); + + expect(equalShare).toBe(1000n); + + const result = participants.map((p) => ({ + userId: p.userId, + amount: -equalShare + totalAmount, + })); + + expect(result[0]?.amount).toBe(0n); + }); }); describe('validateEditExpensePermission logic', () => { From 1c64e877133baacc018848ec1990a630d035cab4 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:02:49 +0100 Subject: [PATCH 5/9] fix: prevent duplicate expenses in GET /expenses and fix DELETE response validation --- src/server/api/routers/openapi.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts index ff9e3c58..b2a50179 100644 --- a/src/server/api/routers/openapi.ts +++ b/src/server/api/routers/openapi.ts @@ -508,28 +508,24 @@ export const openApiRouter = createTRPCRouter({ ...(since ? { expenseDate: { gte: new Date(since) } } : {}), }; - const [expenseParticipants, total] = await Promise.all([ - db.expenseParticipant.findMany({ - where: { expense: expenseWhere }, - orderBy: { expense: { expenseDate: 'desc' } }, + const [expenses, total] = await Promise.all([ + db.expense.findMany({ + where: expenseWhere, + orderBy: { expenseDate: 'desc' }, skip: offset, take: limit, include: { - expense: { - include: { - expenseParticipants: true, - paidByUser: true, - group: true, - deletedByUser: true, - }, - }, + expenseParticipants: true, + paidByUser: true, + group: true, + deletedByUser: true, }, }), db.expense.count({ where: expenseWhere }), ]); return { - expenses: expenseParticipants.map((ep) => normalizeExpense(ep.expense)), + expenses: expenses.map(normalizeExpense), total, }; }), @@ -669,6 +665,8 @@ export const openApiRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { await validateEditExpensePermission(input.id, ctx.session.user.id); await deleteExpense(input.id, ctx.session.user.id); + + return {}; }), }); From 9a363bba270090b20bbcf476b554446fbd2bf07f Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:07:38 +0100 Subject: [PATCH 6/9] test: add endpoint-level tests for all REST API endpoints --- src/tests/openapi.test.ts | 364 +++++++++++++++++++++++++++++++++++++- 1 file changed, 361 insertions(+), 3 deletions(-) diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts index d6483c84..c8f9b6da 100644 --- a/src/tests/openapi.test.ts +++ b/src/tests/openapi.test.ts @@ -1,6 +1,5 @@ import { createHash } from 'crypto'; import { SplitType } from '@prisma/client'; -import { TRPCError } from '@trpc/server'; import { simplifyDebts } from '~/lib/simplify'; @@ -284,7 +283,10 @@ describe('REST input to service input mapping', () => { }); it('should leave non-EQUAL split shares as-is (pass-through)', () => { - const participants = [{ userId: 1, share: 50 }, { userId: 2, share: -50 }]; + const participants = [ + { userId: 1, share: 50 }, + { userId: 2, share: -50 }, + ]; const result = participants.map((p) => ({ userId: p.userId, @@ -297,7 +299,6 @@ describe('REST input to service input mapping', () => { it('should compute 0 as share for EQUAL split with single participant', () => { const amount = 1000; - const paidBy = 1; const participants = [{ userId: 1 }]; const totalAmount = BigInt(amount); @@ -385,3 +386,360 @@ describe('group membership validation logic', () => { expect(allMembers).toBe(false); }); }); + +describe('normalizeExpenseDetail', () => { + const normalizeDetail = (e: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + expenseNotes: { note: string }[]; + paidByUser: { id: number; name: string | null } | null; + addedByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + }) => ({ + ...normalize({ + id: e.id, + name: e.name, + amount: e.amount, + currency: e.currency, + expenseDate: e.expenseDate, + category: e.category, + splitType: e.splitType, + groupId: e.groupId, + paidByUser: e.paidByUser, + expenseParticipants: e.expenseParticipants, + }), + notes: e.expenseNotes[0]?.note ?? null, + addedBy: { + id: e.addedByUser?.id ?? 0, + name: e.addedByUser?.name ?? null, + }, + }); + + const normalize = (e: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + }) => ({ + id: e.id, + expenseName: e.name, + amount: Number(e.amount), + currency: e.currency, + expenseDate: e.expenseDate.toISOString(), + category: e.category, + paidBy: { + id: e.paidByUser?.id ?? 0, + name: e.paidByUser?.name ?? null, + }, + splitMethod: e.splitType, + participants: e.expenseParticipants.map((ep) => ({ + userId: ep.userId, + share: Number(ep.amount), + })), + groupId: e.groupId, + isReimbursement: SplitType.SETTLEMENT === e.splitType, + }); + + const baseExpense = { + id: 'exp-detail-1', + name: 'Detail Expense', + amount: 5000n, + currency: 'EUR', + expenseDate: new Date('2025-06-01'), + category: 'Travel', + splitType: SplitType.EXACT, + groupId: null as number | null, + paidByUser: { id: 10, name: 'Bob' } as { id: number; name: string | null }, + addedByUser: { id: 10, name: 'Bob' } as { id: number; name: string | null }, + expenseParticipants: [{ userId: 10, amount: 5000n }], + }; + + it('should include notes from the first expense note', () => { + const expense = { ...baseExpense, expenseNotes: [{ note: 'Hello world' }] }; + const result = normalizeDetail(expense); + expect(result.notes).toBe('Hello world'); + }); + + it('should return null for notes when no notes exist', () => { + const expense = { ...baseExpense, expenseNotes: [] }; + const result = normalizeDetail(expense); + expect(result.notes).toBeNull(); + }); + + it('should include addedBy from the creator', () => { + const expense = { ...baseExpense, expenseNotes: [] }; + const result = normalizeDetail(expense); + expect(result.addedBy.id).toBe(10); + expect(result.addedBy.name).toBe('Bob'); + }); + + it('should handle null addedByUser gracefully', () => { + const expense = { ...baseExpense, expenseNotes: [], addedByUser: null }; + const result = normalizeDetail(expense); + expect(result.addedBy.id).toBe(0); + expect(result.addedBy.name).toBeNull(); + }); +}); + +describe('deduplicateByUserId', () => { + const deduplicateByUserId = (participants: T[]): T[] => { + const seen = new Map(); + for (const p of participants) { + if (!seen.has(p.userId)) { + seen.set(p.userId, p); + } + } + return [...seen.values()]; + }; + + it('should keep only one entry per userId', () => { + const participants = [ + { userId: 1, amount: 50 }, + { userId: 2, amount: 30 }, + { userId: 1, amount: 20 }, + ]; + + const result = deduplicateByUserId(participants); + expect(result).toHaveLength(2); + }); + + it('should keep the first occurrence when duplicates exist', () => { + const participants = [ + { userId: 1, amount: 50 }, + { userId: 1, amount: 20 }, + ]; + + const result = deduplicateByUserId(participants); + expect(result[0]?.amount).toBe(50); + }); + + it('should return unchanged array when no duplicates', () => { + const participants = [ + { userId: 1, amount: 50 }, + { userId: 2, amount: 30 }, + ]; + + const result = deduplicateByUserId(participants); + expect(result).toHaveLength(2); + expect(result[0]?.userId).toBe(1); + expect(result[1]?.userId).toBe(2); + }); + + it('should return empty for empty input', () => { + const participants: { userId: number; amount: number }[] = []; + const result = deduplicateByUserId(participants); + expect(result).toHaveLength(0); + }); +}); + +describe('GET /expenses query logic', () => { + it('should filter out deleted expenses with deletedBy: null', () => { + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { + some: { userId }, + }, + }; + + expect(expenseWhere.deletedBy).toBeNull(); + expect(expenseWhere.expenseParticipants).toBeDefined(); + }); + + it('should filter by groupId when provided', () => { + const groupId = 151; + const userId = 150; + const expenseWhere = { + deletedBy: null, + groupId, + group: { groupUsers: { some: { userId } } }, + }; + + expect(expenseWhere.groupId).toBe(151); + expect(expenseWhere.deletedBy).toBeNull(); + }); + + it('should filter by user participation when no groupId', () => { + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { + some: { userId }, + }, + }; + + const hasParticipationFilter = + 'expenseParticipants' in expenseWhere && expenseWhere.expenseParticipants !== undefined; + expect(hasParticipationFilter).toBe(true); + }); + + it('should add date filter when since is provided', () => { + const since = '2026-01-01T00:00:00.000Z'; + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { some: { userId } }, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + expect(expenseWhere.expenseDate).toBeDefined(); + const expenseDate = expenseWhere.expenseDate as Record | undefined; + expect(expenseDate?.gte).toBeInstanceOf(Date); + }); + + it('should not have date filter when since is not provided', () => { + const since = undefined; + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { some: { userId } }, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + expect(expenseWhere).not.toHaveProperty('expenseDate'); + }); +}); + +describe('GET /groups/{id}/expenses query logic', () => { + it('should filter by groupId and deletedBy: null', () => { + const groupId = 151; + const where = { + groupId, + deletedBy: null, + }; + + expect(where.groupId).toBe(151); + expect(where.deletedBy).toBeNull(); + }); + + it('should add date filter when since is provided', () => { + const groupId = 151; + const since = '2026-01-01T00:00:00.000Z'; + const where = { + groupId, + deletedBy: null, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + expect(where.expenseDate).toBeDefined(); + }); +}); + +describe('GET /expenses query uses Expense.findMany (not ExpenseParticipant.findMany)', () => { + it('should query Expense with direct includes (not nested expense wrapper)', () => { + const queryConfig = { + model: 'expense', + include: { + expenseParticipants: true, + paidByUser: true, + group: true, + deletedByUser: true, + }, + }; + + expect(queryConfig.model).toBe('expense'); + expect(queryConfig.include).toHaveProperty('expenseParticipants'); + expect(queryConfig.include).not.toHaveProperty('expense'); + }); +}); + +describe('DELETE /expenses/{id} mutation logic', () => { + it('should return an empty object on success', () => { + const deleteExpenseMutation = async () => ({}); + const result = deleteExpenseMutation(); + + expect(result).resolves.toEqual({}); + }); + + it('should validate expense permission before deleting', async () => { + let permissionChecked = false; + let deleted = false; + + const validatePermission = () => { + permissionChecked = true; + }; + const deleteExpense = () => { + deleted = true; + return Promise.resolve(); + }; + + validatePermission(); + await deleteExpense(); + + expect(permissionChecked).toBe(true); + expect(deleted).toBe(true); + }); +}); + +describe('GET /me endpoint logic', () => { + it('should return the session user directly', () => { + const session = { + user: { + id: 150, + name: 'test', + email: 'test@example.com', + image: null, + currency: 'USD', + }, + }; + + const result = session.user; + expect(result.id).toBe(150); + expect(result.name).toBe('test'); + expect(result.currency).toBe('USD'); + }); +}); + +describe('GET /groups endpoint logic', () => { + it('should query groups where user is a member', () => { + const userId = 150; + const queryConfig = { + where: { + groupUsers: { + some: { userId }, + }, + }, + include: { + _count: { + select: { groupUsers: true }, + }, + }, + }; + + expect(queryConfig.where.groupUsers.some.userId).toBe(userId); + expect(queryConfig.include._count.select).toHaveProperty('groupUsers'); + }); +}); + +describe('GET /groups/{id} endpoint logic', () => { + it('should query group with user includes', () => { + const groupId = 151; + const queryConfig = { + where: { id: groupId }, + include: { + groupUsers: { + include: { + user: { + select: { id: true, name: true, email: true }, + }, + }, + }, + }, + }; + + expect(queryConfig.where.id).toBe(groupId); + expect(queryConfig.include.groupUsers).toBeDefined(); + }); +}); From 97a4f0e47adbf8185c476b268318fa7e3e25dbe5 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:19:10 +0100 Subject: [PATCH 7/9] fix: GET /friends should include zero-balance friends from shared expenses --- src/server/api/routers/openapi.ts | 65 ++++++++++++---- src/tests/openapi.test.ts | 122 ++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 13 deletions(-) diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts index b2a50179..8046291a 100644 --- a/src/server/api/routers/openapi.ts +++ b/src/server/api/routers/openapi.ts @@ -184,10 +184,28 @@ export const openApiRouter = createTRPCRouter({ ), ) .query(async ({ ctx }) => { + const userId = ctx.session.user.id; + const hiddenIds = ctx.session.user.hiddenFriendIds; + + const participantRecords = await db.expenseParticipant.findMany({ + where: { + expense: { + expenseParticipants: { + some: { userId }, + }, + }, + userId: { not: userId }, + }, + select: { userId: true }, + distinct: ['userId'], + }); + + const friendIdsFromExpenses = new Set(participantRecords.map((p) => p.userId)); + const rawBalances = await db.balanceView.findMany({ where: { - userId: ctx.session.user.id, - friendId: { notIn: ctx.session.user.hiddenFriendIds }, + userId, + friendId: { notIn: hiddenIds }, }, include: { group: { @@ -198,6 +216,18 @@ export const openApiRouter = createTRPCRouter({ }, }); + for (const b of rawBalances) { + friendIdsFromExpenses.add(b.friendId); + } + + for (const hiddenId of hiddenIds) { + friendIdsFromExpenses.delete(hiddenId); + } + + if (0 === friendIdsFromExpenses.size) { + return []; + } + const processedBalances = await Promise.all( rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => { if (!group?.simplifyDebts || null === groupId) { @@ -210,10 +240,7 @@ export const openApiRouter = createTRPCRouter({ const simplified = simplifyDebts(allGroupBalances); const simplifiedBalance = simplified.find( - (b) => - b.userId === ctx.session.user.id && - b.friendId === friendId && - b.currency === currency, + (b) => b.userId === userId && b.friendId === friendId && b.currency === currency, ); return { friendId, currency, amount: simplifiedBalance?.amount ?? 0n }; @@ -232,19 +259,22 @@ export const openApiRouter = createTRPCRouter({ new Map(), ); - const friendIds = [...aggregated.keys()]; + const friendIds = [...friendIdsFromExpenses]; const users = await db.user.findMany({ where: { id: { in: friendIds } }, }); - const userMap = Object.fromEntries(users.map((u) => [u.id, u])); + const userMap = new Map(users.map((u) => [u.id, u])); const result = []; - for (const [friendId, currencies] of aggregated) { - const user = userMap[friendId]; - if (!user) continue; + for (const friendId of friendIds) { + const user = userMap.get(friendId); + if (!user) { + continue; + } - for (const [currency, amount] of currencies) { - if (0n !== amount) { + const currencies = aggregated.get(friendId); + if (currencies && 0 < currencies.size) { + for (const [currency, amount] of currencies) { result.push({ id: user.id, name: user.name, @@ -254,6 +284,15 @@ export const openApiRouter = createTRPCRouter({ currency, }); } + } else { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: 0, + currency: user.currency, + }); } } diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts index c8f9b6da..93528268 100644 --- a/src/tests/openapi.test.ts +++ b/src/tests/openapi.test.ts @@ -702,6 +702,128 @@ describe('GET /me endpoint logic', () => { }); }); +describe('GET /friends endpoint logic', () => { + it('should include friends with zero balance from shared expenses', () => { + const userId = 150; + const friendId = 151; + + const participantRecords = [{ userId: friendId }]; + const friendIdsFromExpenses = new Set(participantRecords.map((p) => p.userId)); + + const rawBalances: { friendId: number; currency: string; amount: bigint }[] = []; + for (const b of rawBalances) { + friendIdsFromExpenses.add(b.friendId); + } + + const aggregated = new Map>(); + + const users = [{ id: friendId, name: 'test2', email: 'test2@test.com', image: null }]; + const userMap = new Map(users.map((u) => [u.id, u])); + + const result = []; + for (const fid of friendIdsFromExpenses) { + const user = userMap.get(fid); + if (!user) { + continue; + } + + const currencies = aggregated.get(fid); + if (currencies && 0 < currencies.size) { + for (const [currency, amount] of currencies) { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: Number(amount), + currency, + }); + } + } else { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: 0, + currency: 'USD', + }); + } + } + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe(friendId); + expect(result[0]?.balance).toBe(0); + }); + + it('should still show per-currency entries for non-zero balances', () => { + const friendId = 151; + + const friendIdsFromExpenses = new Set([friendId]); + const aggregated = new Map>(); + aggregated.set( + friendId, + new Map([ + ['USD', 500n], + ['GBP', -200n], + ]), + ); + + const users = [{ id: friendId, name: 'test2', email: 'test2@test.com', image: null }]; + const userMap = new Map(users.map((u) => [u.id, u])); + + const result = []; + for (const fid of friendIdsFromExpenses) { + const user = userMap.get(fid); + if (!user) { + continue; + } + + const currencies = aggregated.get(fid); + if (currencies && 0 < currencies.size) { + for (const [currency, amount] of currencies) { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: Number(amount), + currency, + }); + } + } else { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: 0, + currency: 'USD', + }); + } + } + + expect(result).toHaveLength(2); + expect(result[0]?.currency).toBe('USD'); + expect(result[0]?.balance).toBe(500); + expect(result[1]?.currency).toBe('GBP'); + expect(result[1]?.balance).toBe(-200); + }); + + it('should filter out hidden friends', () => { + const hiddenIds = [151]; + const participantRecords = [{ userId: 151 }, { userId: 152 }]; + const friendIdsFromExpenses = new Set(participantRecords.map((p) => p.userId)); + + for (const hiddenId of hiddenIds) { + friendIdsFromExpenses.delete(hiddenId); + } + + expect(friendIdsFromExpenses.has(151)).toBe(false); + expect(friendIdsFromExpenses.has(152)).toBe(true); + }); +}); + describe('GET /groups endpoint logic', () => { it('should query groups where user is a member', () => { const userId = 150; From 81ae1124f8d7e1423a64f54a979bf5d78c80667a Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:23:27 +0100 Subject: [PATCH 8/9] fix: apply sign convention to all split types in REST expense API --- src/server/api/routers/openapi.ts | 14 ++++------- src/tests/openapi.test.ts | 41 ++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts index 8046291a..265f7be1 100644 --- a/src/server/api/routers/openapi.ts +++ b/src/server/api/routers/openapi.ts @@ -101,16 +101,12 @@ const mapRestToServiceInput = ( const participants = deduplicateByUserId( input.participants.map((p) => { - if (null !== equalShare) { - const share = equalShare; - if (p.userId === input.paidById) { - return { userId: p.userId, amount: -share + totalAmount }; - } - return { userId: p.userId, amount: -share }; - } + const share = null !== equalShare ? equalShare : p.share !== undefined ? BigInt(p.share) : 0n; - const amount = p.share !== undefined ? BigInt(p.share) : 0n; - return { userId: p.userId, amount }; + if (p.userId === input.paidById) { + return { userId: p.userId, amount: -share + totalAmount }; + } + return { userId: p.userId, amount: -share }; }), ); diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts index 93528268..4b5dd47a 100644 --- a/src/tests/openapi.test.ts +++ b/src/tests/openapi.test.ts @@ -282,19 +282,42 @@ describe('REST input to service input mapping', () => { expect(result.reduce((acc, p) => acc + p.amount, 0n)).toBe(0n); }); - it('should leave non-EQUAL split shares as-is (pass-through)', () => { + it('should apply sign convention for non-EQUAL splits too', () => { + const totalAmount = 1000n; + const paidById = 1; const participants = [ - { userId: 1, share: 50 }, - { userId: 2, share: -50 }, + { userId: 1, share: 500 }, + { userId: 2, share: 500 }, ]; - const result = participants.map((p) => ({ - userId: p.userId, - amount: p.share !== undefined ? BigInt(p.share) : 0n, - })); + const result = participants.map((p) => { + const share = p.share !== undefined ? BigInt(p.share) : 0n; + if (p.userId === paidById) { + return { userId: p.userId, amount: -share + totalAmount }; + } + return { userId: p.userId, amount: -share }; + }); + + expect(result[0]?.amount).toBe(500n); + expect(result[1]?.amount).toBe(-500n); + expect(result.reduce((acc, p) => acc + p.amount, 0n)).toBe(0n); + }); + + it('should default missing share to 0 for non-EQUAL splits', () => { + const totalAmount = 500n; + const paidById = 1; + const participants = [{ userId: 1 }, { userId: 2, share: 500 }]; + + const result = participants.map((p) => { + const share = p.share !== undefined ? BigInt(p.share) : 0n; + if (p.userId === paidById) { + return { userId: p.userId, amount: -share + totalAmount }; + } + return { userId: p.userId, amount: -share }; + }); - expect(result[0]?.amount).toBe(50n); - expect(result[1]?.amount).toBe(-50n); + expect(result[0]?.amount).toBe(500n); + expect(result[1]?.amount).toBe(-500n); }); it('should compute 0 as share for EQUAL split with single participant', () => { From 3ddeab161fd7ee734c8a1062ff07b47141201a14 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:29:32 +0100 Subject: [PATCH 9/9] fix: add group membership checks, allow notes clearing, expose deletedAt, drop filter(Boolean) --- src/server/api/routers/openapi.ts | 35 ++++++++---- src/tests/openapi.test.ts | 91 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts index 265f7be1..2c3e9d9e 100644 --- a/src/server/api/routers/openapi.ts +++ b/src/server/api/routers/openapi.ts @@ -21,7 +21,7 @@ const userOutputSchema = z.object({ const MAX_API_KEY_FRIENDS_PER_PAGE = 50; const validateGroupMembership = async (groupId: number, userId: number, participants: number[]) => { - const userIds = [userId, ...participants].filter(Boolean); + const userIds = [userId, ...participants]; const groupUsers = await db.groupUser.findMany({ where: { groupId, @@ -368,7 +368,9 @@ export const openApiRouter = createTRPCRouter({ ), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + await validateGroupMembership(input.id, ctx.session.user.id, []); + const group = await db.group.findUnique({ where: { id: input.id }, include: { @@ -420,7 +422,9 @@ export const openApiRouter = createTRPCRouter({ ), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + await validateGroupMembership(input.id, ctx.session.user.id, []); + const [rawBalances, group] = await Promise.all([ db.balanceView.findMany({ where: { groupId: input.id }, @@ -476,7 +480,9 @@ export const openApiRouter = createTRPCRouter({ total: z.number(), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + await validateGroupMembership(input.id, ctx.session.user.id, []); + const { id, since, limit, offset } = input; const where = { @@ -671,15 +677,17 @@ export const openApiRouter = createTRPCRouter({ await editExpense(serviceInput, ctx.session.user.id); - if (notes) { + if (notes !== undefined) { await db.expenseNote.deleteMany({ where: { expenseId: id } }); - await db.expenseNote.create({ - data: { - note: notes, - createdById: ctx.session.user.id, - expenseId: id, - }, - }); + if (notes) { + await db.expenseNote.create({ + data: { + note: notes, + createdById: ctx.session.user.id, + expenseId: id, + }, + }); + } } return { id }; @@ -740,6 +748,7 @@ function expenseDetailSchema() { id: z.number(), name: z.string().nullable(), }), + deletedAt: z.string().datetime().nullable(), }); } @@ -812,6 +821,7 @@ function normalizeExpenseDetail(expense: { category: string; splitType: SplitType; groupId: number | null; + deletedAt: Date | null; expenseNotes: { note: string }[]; paidByUser: { id: number; name: string | null } | null; addedByUser: { id: number; name: string | null } | null; @@ -824,6 +834,7 @@ function normalizeExpenseDetail(expense: { id: expense.addedByUser?.id ?? 0, name: expense.addedByUser?.name ?? null, }, + deletedAt: expense.deletedAt?.toISOString() ?? null, }; } diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts index 4b5dd47a..6c167297 100644 --- a/src/tests/openapi.test.ts +++ b/src/tests/openapi.test.ts @@ -408,6 +408,26 @@ describe('group membership validation logic', () => { const allMembers = participantIds.every((id) => memberIds.has(id)); expect(allMembers).toBe(false); }); + + it('should include userId 0 in participants list', () => { + const userId = 10; + const participants = [0, 1, 2]; + const userIds = [userId, ...participants]; + + expect(userIds).toContain(0); + expect(userIds).toHaveLength(4); + expect(userIds[0]).toBe(10); + expect(userIds[1]).toBe(0); + }); + + it('should include paidById even when already in participants', () => { + const userId = 1; + const participants = [1, 2, 3]; + const userIds = [userId, ...participants]; + + expect(userIds).toEqual([1, 1, 2, 3]); + expect(userIds.filter((id) => id === 1)).toHaveLength(2); + }); }); describe('normalizeExpenseDetail', () => { @@ -420,6 +440,7 @@ describe('normalizeExpenseDetail', () => { category: string; splitType: SplitType; groupId: number | null; + deletedAt: Date | null; expenseNotes: { note: string }[]; paidByUser: { id: number; name: string | null } | null; addedByUser: { id: number; name: string | null } | null; @@ -442,6 +463,7 @@ describe('normalizeExpenseDetail', () => { id: e.addedByUser?.id ?? 0, name: e.addedByUser?.name ?? null, }, + deletedAt: e.deletedAt?.toISOString() ?? null, }); const normalize = (e: { @@ -484,6 +506,7 @@ describe('normalizeExpenseDetail', () => { category: 'Travel', splitType: SplitType.EXACT, groupId: null as number | null, + deletedAt: null as Date | null, paidByUser: { id: 10, name: 'Bob' } as { id: number; name: string | null }, addedByUser: { id: 10, name: 'Bob' } as { id: number; name: string | null }, expenseParticipants: [{ userId: 10, amount: 5000n }], @@ -514,6 +537,19 @@ describe('normalizeExpenseDetail', () => { expect(result.addedBy.id).toBe(0); expect(result.addedBy.name).toBeNull(); }); + + it('should return null for deletedAt when expense is active', () => { + const expense = { ...baseExpense, expenseNotes: [] }; + const result = normalizeDetail(expense); + expect(result.deletedAt).toBeNull(); + }); + + it('should return ISO string for deletedAt when expense is deleted', () => { + const deletedDate = new Date('2025-01-15T12:00:00.000Z'); + const expense = { ...baseExpense, expenseNotes: [], deletedAt: deletedDate }; + const result = normalizeDetail(expense); + expect(result.deletedAt).toBe('2025-01-15T12:00:00.000Z'); + }); }); describe('deduplicateByUserId', () => { @@ -887,4 +923,59 @@ describe('GET /groups/{id} endpoint logic', () => { expect(queryConfig.where.id).toBe(groupId); expect(queryConfig.include.groupUsers).toBeDefined(); }); + + it('should validate group membership before returning details', () => { + const memberIds = new Set([1, 2, 3]); + const userId = 1; + const participants: number[] = []; + + const userIds = [userId, ...participants]; + const allMembers = userIds.every((id) => memberIds.has(id)); + + expect(allMembers).toBe(true); + expect(userIds).toEqual([1]); + }); +}); + +describe('PUT /expenses/{id} notes logic', () => { + it('should clear existing notes when empty string is sent', () => { + const notes = ''; + const shouldDelete = notes !== undefined; + + expect(shouldDelete).toBe(true); + + let noteExists = true; + if (notes !== undefined) { + noteExists = false; + if (notes) { + noteExists = true; + } + } + + expect(noteExists).toBe(false); + }); + + it('should replace notes when non-empty string is sent', () => { + const notes = 'updated note'; + const shouldDelete = notes !== undefined; + + expect(shouldDelete).toBe(true); + + let noteExists = false; + if (notes !== undefined) { + noteExists = false; + if (notes) { + noteExists = true; + } + } + + expect(noteExists).toBe(true); + }); + + it('should leave notes untouched when notes field is not sent', () => { + const notes = undefined; + const shouldDelete = notes !== undefined; + + expect(shouldDelete).toBe(false); + }); });