diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..4fd6b7e0d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Mark generated OpenAPI spec as linguist-generated so it collapses in diffs. +src/server/api/openapi.generated.json linguist-generated=true diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..036d2a6d8 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,23 @@ +#!/bin/sh +# Keep the committed OpenAPI spec in sync with the API router: regenerate it +# and abort the push when the regenerated file would change, so the spec can +# never go stale. Only runs when API source files changed, to avoid slowing +# down pushes that don't touch the API. Compare against both the working tree +# and the index so the check passes only after the file is actually committed. + +# Determine which commits are being pushed: compare local HEAD against the +# upstream branch if set, otherwise just the previous commit. +if git rev-parse --verify @{upstream} >/dev/null 2>&1; then + RANGE="@{upstream}...HEAD" +else + RANGE="HEAD~1..HEAD" +fi + +if git diff --name-only "$RANGE" -- src/server/api/ | grep -q .; then + pnpm gen:openapi + if [ -n "$(git status --porcelain -- src/server/api/openapi.generated.json)" ]; then + echo "✖ src/server/api/openapi.generated.json is out of date." + echo " Run: pnpm gen:openapi && git add src/server/api/openapi.generated.json && git commit" + exit 1 + fi +fi diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 000000000..1f4bc90a3 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,94 @@ +# SplitPro Public REST API + +SplitPro exposes a small, versioned REST API for integrations (e.g. syncing +expenses to a budgeting tool). The API is served at `/api/v1` and documented +interactively at **`/api/docs`** (Scalar), backed by the machine-readable +OpenAPI 3.1 spec at **`/api/openapi.json`**. + +## Authentication + +1. In the app, open **Account → API keys** and create a key. Optionally set an + expiry date. The plaintext key (`spro_…`) is shown exactly once; only a + SHA-256 hash is stored server-side. +2. Send the key with every request: + + ``` + Authorization: Bearer spro_… + ``` + + The `X-API-Key: spro_…` header is also accepted. Requests without a valid, + unexpired key receive `401`. + +API keys act with your user's permissions: every endpoint only returns data +you are part of, and writes run the same permission checks as the app. + +## Endpoints + +| Endpoint | Method | Description | +| ------------------------------- | ------ | ----------------------------------------------- | +| `user.me` | GET | Authenticated user profile | +| `user.getFriends` | GET | Friends with balances | +| `user.getOwnExpenses` | GET | Expenses paid by you **(paginated)** | +| `user.getBalancesWithFriend` | GET | Per-group balances with a friend | +| `user.getFriend` | GET | Friend profile + default split | +| `group.getAllGroups` | GET | All groups you belong to | +| `group.getGroupDetails` | GET | Single group with members and balances | +| `expense.getBalances` | GET | Per-friend balances across all groups | +| `expense.getAllExpenses` | GET | All expenses you participate in **(paginated)** | +| `expense.getExpensesWithFriend` | GET | Expense history with a friend **(paginated)** | +| `expense.getGroupExpenses` | GET | All expenses in a group **(paginated)** | +| `expense.getExpenseDetails` | GET | Full expense detail | +| `expense.addOrEditExpense` | POST | Create or update expenses (array body) | + +All requests go to `https:///api/v1/`, e.g. +`GET /api/v1/user.me`. + +### Input formats + +GET endpoints accept individual query params (`?groupId=150&limit=20`), a +plain JSON object (`?input={"groupId":150}`), or a native superjson envelope. +POST bodies are plain JSON; a superjson envelope also works when calling from +a tRPC client. + +Because JSON has no BigInt or Date type, monetary amounts accept numbers or +numeric strings (strings keep full precision beyond `Number.MAX_SAFE_INTEGER`) +and are returned as numbers; timestamps are ISO 8601 strings. + +### Pagination + +List endpoints use offset pagination: + +- Input: `?limit=20&offset=0` (defaults `limit=20`, `offset=0`, max `limit=100`) +- Response: `{ items: […], pagination: { total, limit, offset, hasMore } }` + +### Example + +```bash +curl -H "Authorization: Bearer spro_…" \ + 'https://splitpro.example.com/api/v1/expense.getGroupExpenses?groupId=16&limit=10' +``` + +```bash +curl -X POST -H "Authorization: Bearer spro_…" -H "Content-Type: application/json" \ + -d '[{"name":"Lunch","currency":"EUR","groupId":null,"amount":4200,"paidBy":1, + "category":"general","splitType":"EQUAL", + "participants":[{"userId":1,"amount":2100},{"userId":2,"amount":-2100}], + "expenseDate":"2026-10-25T12:00:00Z"}]' \ + 'https://splitpro.example.com/api/v1/expense.addOrEditExpense' +``` + +Note the participant amounts follow the app's stored sign convention: the +payer's entry is `amount − their share` (positive), every other participant's +entry is their negated share. The example above is a `4200` expense split +equally between user 1 (payer) and user 2, so each share is `2100`. + +## Maintenance (for developers) + +- The API router is `src/server/api/apiRouter.ts` — a curated subset of the + app procedures, shared via the exported method records in the router files. +- Regenerate the OpenAPI spec after changing any procedure with + `pnpm gen:openapi`. The pre-push hook fails if the committed + `src/server/api/openapi.generated.json` is out of date, and `pnpm build` + regenerates it. +- `/api/docs` loads the Scalar bundle pinned to an exact version with a + subresource-integrity hash (see `src/pages/api/docs.ts` for upgrade steps). diff --git a/package.json b/package.json index 3f1d4dc9e..1bf0d6889 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "next build", "just-build": "next build", + "gen:openapi": "cross-env SKIP_ENV_VALIDATION=true tsx scripts/generate-openapi.ts", "db:push": "prisma db push && prisma generate", "db:studio": "prisma studio", "db:dev": "prisma migrate dev && prisma generate", @@ -101,7 +102,9 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@types/web-push": "^3.6.3", + "@trpc/openapi": "11.18.0-alpha", "babel-plugin-react-compiler": "^1.0.0", + "cross-env": "^10.1.0", "esbuild": "^0.25.12", "husky": "^9.1.7", "jest": "30.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9de2a633..498ae74fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@trpc/next': specifier: ^11.2.0 version: 11.8.0(@tanstack/react-query@5.90.12(react@19.3.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.3.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@16.3.8(@babel/core@7.29.6)(@playwright/test@1.62.1)(@types/node@22.19.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@5.7.3) + '@trpc/openapi': + specifier: 11.18.0-alpha + version: 11.18.0-alpha(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3)(zod@3.25.76) '@trpc/react-query': specifier: ^11.2.0 version: 11.8.0(@tanstack/react-query@5.90.12(react@19.3.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@5.7.3) @@ -207,6 +210,9 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 esbuild: specifier: ^0.25.12 version: 0.25.12 @@ -532,6 +538,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -2630,6 +2639,20 @@ packages: '@trpc/react-query': optional: true + '@trpc/openapi@11.18.0-alpha': + resolution: {integrity: sha512-BdtOEd8v5SIs5j0ZF5q4upR44yLM6t5N6o8+NX7qlBVXr/05yzoKJZC4b6z2z2nER70CDWfl49G68leiXjauHQ==} + hasBin: true + peerDependencies: + '@hey-api/openapi-ts': '>=0.94.5' + '@trpc/server': 11.18.0 + typescript: '>=5.7.2' + zod: '>=4.0.0' + peerDependenciesMeta: + '@hey-api/openapi-ts': + optional: true + zod: + optional: true + '@trpc/react-query@11.8.0': resolution: {integrity: sha512-zJG22PqhGUBq6ke58McGxRBGWGDTHlmsgpxp/rQx8iT2yi0Ja1H2/UcSsjZ/MG65DhCfpKvMVH0OVivTn9FWwA==} peerDependencies: @@ -3215,6 +3238,11 @@ packages: resolution: {integrity: sha512-T3S35zmD0Ai2B4ko6+mEM+k9C6tipe2nB9RLiGT6QL2Wn0Vsn2cCZAC8Oeuf4CaE00GZWVdpYitbpWCNlIWqdA==} hasBin: true + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4341,6 +4369,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} @@ -5535,6 +5566,8 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -7473,6 +7506,14 @@ snapshots: '@tanstack/react-query': 5.90.12(react@19.3.1) '@trpc/react-query': 11.8.0(@tanstack/react-query@5.90.12(react@19.3.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@5.7.3) + '@trpc/openapi@11.18.0-alpha(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3)(zod@3.25.76)': + dependencies: + '@trpc/server': 11.8.0(typescript@5.7.3) + openapi-types: 12.1.3 + typescript: 5.7.3 + optionalDependencies: + zod: 3.25.76 + '@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.3.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@5.7.3)': dependencies: '@tanstack/react-query': 5.90.12(react@19.3.1) @@ -8076,6 +8117,11 @@ snapshots: cronstrue@3.9.0: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9365,6 +9411,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + openapi-types@12.1.3: {} + openid-client@5.7.1: dependencies: jose: 4.15.9 diff --git a/prisma/migrations/20260715120000_add_api_key/migration.sql b/prisma/migrations/20260715120000_add_api_key/migration.sql new file mode 100644 index 000000000..97afc00fd --- /dev/null +++ b/prisma/migrations/20260715120000_add_api_key/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "hashedKey" TEXT NOT NULL, + "partialKey" TEXT NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85e6dfaf2..95900e944 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,11 +63,27 @@ model User { friendDefaultSplitsA FriendDefaultSplit[] @relation("FriendDefaultSplitUserA") friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB") sessions Session[] + apiKeys ApiKey[] hiddenFriendIds Int[] @default([]) @@schema("public") } +model ApiKey { + id String @id @default(cuid()) + userId Int + name String + hashedKey String @unique + partialKey String + lastUsedAt DateTime? + expiresAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@schema("public") +} + model VerificationToken { identifier String token String @unique diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ee0cab553..0346255a6 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -30,6 +30,28 @@ "title": "Edit details" }, "follow_on_x": "Follow us on X", + "api_keys": { + "title": "API keys", + "description": "Use API keys to access the SplitPro API. Send the key as", + "docs_prefix": "Documentation is at", + "copy_now_notice": "Copy your key now — it won't be shown again.", + "name_label": "New key name", + "name_placeholder": "e.g. Actual Budget", + "expires_label": "Expires (optional)", + "expires_hint": "Expires at end of day in your local timezone.", + "create": "Create", + "last_used": "used {date}", + "never_used": "never used", + "expires_on": "expires {date}", + "empty": "No API keys yet.", + "messages": { + "name_required": "Please enter a name for the key", + "create_failed": "Failed to create API key", + "revoke_failed": "Failed to revoke API key", + "copied": "Copied to clipboard", + "copy_failed": "Failed to copy" + } + }, "import_from_splitwise": "Import from Splitwise", "import_from_splitwise_details": { "choose_file": "Choose file", diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 000000000..df31153f6 --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,116 @@ +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { generateOpenAPIDocument } from '@trpc/openapi'; +import { format, resolveConfig } from 'prettier'; + +/** + * Generates the OpenAPI spec for the public API from `apiRouter`. + * + * `@trpc/openapi` statically analyses the router's TypeScript types (it never + * runs the code), so this is a build-time step — the result is committed to + * `openapi.generated.json` and served at `/api/openapi.json`. The pre-push + * hook and `pnpm build` regenerate it, keeping the committed file in sync. + * + * Run with: `pnpm gen:openapi` (sets SKIP_ENV_VALIDATION so no app secrets + * are required to typecheck the router). + */ + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const routerPath = path.join(rootDir, 'src/server/api/apiRouter.ts'); +const outputPath = path.join(rootDir, 'src/server/api/openapi.generated.json'); + +/** + * Relative server URL: the spec is served from the same origin as the API + * (`/api/openapi.json`), so self-hosted deployments get a working "Try it" + * button in the docs regardless of their host. + */ +const SERVER_URL = '/api/v1'; + +/** Minimal shape of a query parameter object we post-process below. */ +interface QueryParameterObject { + name?: unknown; + content?: Record } }>; +} + +const isQueryParameterObject = (value: unknown): value is QueryParameterObject => + 'object' === typeof value && null !== value && 'name' in value; + +/** Minimal shape of an operation object we post-process below. */ +interface OperationObject { + parameters?: unknown; +} + +const isOperationObject = (value: unknown): value is OperationObject => + 'object' === typeof value && null !== value && 'parameters' in value; + +/** + * The generator infers Zod `.default()` as optional fields but does not emit + * the actual default values into the schema. Walk every query parameter named + * `input` and inject the defaults for `limit` (20) and `offset` (0) so the + * docs UI and generated clients pick them up automatically. + */ +const injectPaginationDefaults = (doc: Awaited>) => { + const defaultMap: Record = { limit: 20, offset: 0 }; + + for (const pathItem of Object.values(doc.paths ?? {})) { + for (const operation of Object.values(pathItem ?? {})) { + if (!isOperationObject(operation) || !Array.isArray(operation.parameters)) { + continue; + } + + for (const parameter of operation.parameters) { + if (!isQueryParameterObject(parameter) || 'input' !== parameter.name) { + continue; + } + const props = parameter.content?.['application/json']?.schema?.properties; + if (!props) { + continue; + } + + for (const [field, defaultValue] of Object.entries(defaultMap)) { + const prop = props[field]; + if (undefined !== prop && undefined === prop.default) { + prop.default = defaultValue; + } + } + } + } + } +}; + +async function main() { + const doc = await generateOpenAPIDocument(routerPath, { + exportName: 'apiRouter', + title: 'SplitPro API', + version: process.env.NEXT_PUBLIC_APP_VERSION ?? '0.1.0', + servers: [{ url: SERVER_URL }], + }); + + // The generator infers paths/schemas but not auth, so we declare bearer + // API-key auth globally (also drives the docs UI's "Authorize" button). + doc.components ??= {}; + doc.components.securitySchemes = { + ...doc.components.securitySchemes, + bearerAuth: { type: 'http', scheme: 'bearer', description: 'SplitPro API key (spro_…)' }, + }; + doc.security = [{ bearerAuth: [] }]; + + injectPaginationDefaults(doc); + + // Format the output with the project's prettier config so the generated file + // Is byte-identical to what the pre-commit hook produces. + const prettierConfig = await resolveConfig(outputPath); + const spec = await format(JSON.stringify(doc), { ...prettierConfig, parser: 'json' }); + + await writeFile(outputPath, spec); + console.log( + `✓ Wrote OpenAPI spec (${Object.keys(doc.paths ?? {}).length} paths) to ${outputPath}`, + ); +} + +main().catch((error) => { + console.error('Failed to generate OpenAPI spec:', error); + process.exit(1); +}); diff --git a/src/components/Account/ApiKeys.tsx b/src/components/Account/ApiKeys.tsx new file mode 100644 index 000000000..808946bbd --- /dev/null +++ b/src/components/Account/ApiKeys.tsx @@ -0,0 +1,192 @@ +import { Copy, Trash2 } from 'lucide-react'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { toast } from 'sonner'; + +import { api } from '~/utils/api'; + +import { AppDrawer } from '../ui/drawer'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; + +/** + * Account-settings UI to create, list, and revoke API keys used by the public + * REST API (`/api/v1`). A freshly created key's plaintext is shown exactly + * once; only a SHA-256 hash is stored server-side. Keys can optionally be + * created with an expiry date. + */ +export const ApiKeys: React.FC = ({ children }) => { + const { t } = useTranslation('common'); + + const [name, setName] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const [createdKey, setCreatedKey] = useState(null); + const [minExpiry] = useState(() => new Date().toISOString().split('T')[0]); + + const utils = api.useUtils(); + const keysQuery = api.user.listApiKeys.useQuery(); + const createMutation = api.user.createApiKey.useMutation(); + const revokeMutation = api.user.revokeApiKey.useMutation(); + + const onCreate = useCallback(async () => { + const trimmed = name.trim(); + if (!trimmed) { + toast.error(t('account.api_keys.messages.name_required')); + return; + } + + try { + const result = await createMutation.mutateAsync({ + name: trimmed, + expiresAt: expiresAt ? new Date(`${expiresAt}T23:59:59`) : null, + }); + setCreatedKey(result.key); + setName(''); + setExpiresAt(''); + await utils.user.listApiKeys.invalidate(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t('account.api_keys.messages.create_failed'), + ); + } + }, [name, expiresAt, createMutation, utils.user.listApiKeys, t]); + + const onRevoke = useCallback( + async (id: string) => { + try { + await revokeMutation.mutateAsync({ id }); + await utils.user.listApiKeys.invalidate(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t('account.api_keys.messages.revoke_failed'), + ); + } + }, + [revokeMutation, utils.user.listApiKeys, t], + ); + + const copyKey = useCallback( + async (value: string) => { + try { + await navigator.clipboard.writeText(value); + toast.success(t('account.api_keys.messages.copied')); + } catch { + toast.error(t('account.api_keys.messages.copy_failed')); + } + }, + [t], + ); + + return ( + { + if (!open) { + setCreatedKey(null); + } + }} + className="h-[85vh]" + > +
+

+ {t('account.api_keys.description')}{' '} + Authorization: Bearer <key>.{' '} + {t('account.api_keys.docs_prefix')}{' '} + + /api/docs + + . +

+ + {createdKey ? ( +
+

+ {t('account.api_keys.copy_now_notice')} +

+
+ + {createdKey} + + +
+
+ ) : null} + +
+
+ + setName(e.target.value)} + /> +
+
+ + setExpiresAt(e.target.value)} + /> +

+ {t('account.api_keys.expires_hint')} +

+
+ +
+ +
+ {keysQuery.data?.length ? ( + keysQuery.data.map((key) => ( +
+
+ {key.name} + + {key.partialKey}… ·{' '} + {key.lastUsedAt + ? t('account.api_keys.last_used', { + date: key.lastUsedAt.toLocaleDateString(), + }) + : t('account.api_keys.never_used')} + {key.expiresAt + ? ` · ${t('account.api_keys.expires_on', { + date: key.expiresAt.toLocaleDateString(), + })}` + : ''} + +
+ +
+ )) + ) : ( +

{t('account.api_keys.empty')}

+ )} +
+
+
+ ); +}; diff --git a/src/pages/account.tsx b/src/pages/account.tsx index b786f144f..3dc26b17e 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -6,6 +6,7 @@ import { DownloadCloud, FileDown, HeartHandshakeIcon, + KeyRound, Languages, Star, } from 'lucide-react'; @@ -36,6 +37,7 @@ import { import { api } from '~/utils/api'; import type { NextPageWithUser } from '~/types'; import { DebugInfo } from '~/components/Account/DebugInfo'; +import { ApiKeys } from '~/components/Account/ApiKeys'; import { useAppStore } from '~/store/appStore'; const AccountPage: NextPageWithUser<{ @@ -175,6 +177,13 @@ const AccountPage: NextPageWithUser<{ {t('account.import_from_splitwise')} + + + + {t('account.api_keys.title')} + + + diff --git a/src/pages/api/docs.ts b/src/pages/api/docs.ts new file mode 100644 index 000000000..cb74028da --- /dev/null +++ b/src/pages/api/docs.ts @@ -0,0 +1,36 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; + +/** + * Renders interactive API reference docs (Scalar) for the public REST API at + * `GET /api/docs`, pointing at `/api/openapi.json`. + * + * The Scalar bundle is loaded from jsDelivr pinned to an exact version with a + * subresource-integrity hash: the script runs on the app origin, so a + * compromised upstream build must not be able to execute. To upgrade, fetch + * the pinned file, recompute `sha384` of its bytes (e.g. + * `openssl dgst -sha384 -binary file | openssl base64 -A`) and update both. + */ +const SCALAR_VERSION = '1.76.0'; +const SCALAR_INTEGRITY = 'sha384-oH1C0RwqcwbVXkC+r8A+4qRoyEUQdtxAzOwumkxZtQBBcsy61FOoPt7B50OVJYYQ'; + +export default function handler(_req: NextApiRequest, res: NextApiResponse) { + const html = ` + + + SplitPro API Reference + + + + + + + +`; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + 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 000000000..8781a6858 --- /dev/null +++ b/src/pages/api/openapi.json.ts @@ -0,0 +1,14 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; + +import openApiDocument from '~/server/api/openapi.generated.json'; + +/** + * Serves the generated OpenAPI 3.1 spec for the public REST API at + * `GET /api/openapi.json`. The document is generated at build time by + * `scripts/generate-openapi.ts`. + */ +export default function handler(_req: NextApiRequest, res: NextApiResponse) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.status(200).send(openApiDocument); +} diff --git a/src/pages/api/v1/[trpc].ts b/src/pages/api/v1/[trpc].ts new file mode 100644 index 000000000..b322e8c5c --- /dev/null +++ b/src/pages/api/v1/[trpc].ts @@ -0,0 +1,51 @@ +import { createNextApiHandler } from '@trpc/server/adapters/next'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { env } from '~/env'; +import { apiRouter } from '~/server/api/apiRouter'; +import { createApiContext } from '~/server/api/apiContext'; +import { coerceQueryParam } from '~/utils/queryParams'; + +const handler = createNextApiHandler({ + router: apiRouter, + createContext: createApiContext, + onError: + 'development' === env.NODE_ENV + ? ({ path, error }) => { + console.error(`❌ API failed on ${path ?? ''}: ${error.message}`); + } + : undefined, +}); + +/** + * Public REST API handler (API-key auth), mounted at `/api/v1/*`, e.g. + * `GET /api/v1/user.me`. The wrapper converts individual query params + * (`?groupId=150&limit=20`) into tRPC's `?input={"groupId":150,"limit":20}` + * format; the API router's transformer tolerates plain JSON input, so this + * works for all query procedures including future ones. POST bodies are passed + * through unchanged (plain JSON or a superjson envelope). + */ +async function apiHandler(req: NextApiRequest, res: NextApiResponse) { + if ('GET' === req.method && req.url) { + const url = new URL(req.url, 'http://localhost'); + + if (!url.searchParams.has('input')) { + const params: Record = {}; + + url.searchParams.forEach((value, key) => { + if ('batch' !== key && 'trpc' !== key) { + params[key] = coerceQueryParam(value); + } + }); + + if (0 < Object.keys(params).length) { + url.searchParams.set('input', JSON.stringify(params)); + req.url = url.pathname + url.search; + } + } + } + + await handler(req, res); +} + +export default apiHandler; diff --git a/src/server/api/apiContext.ts b/src/server/api/apiContext.ts new file mode 100644 index 000000000..a8000e270 --- /dev/null +++ b/src/server/api/apiContext.ts @@ -0,0 +1,34 @@ +import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; +import { type Session } from 'next-auth'; + +import { db } from '~/server/db'; + +import { parseApiKeyFromHeaders, resolveApiKeyUser, toSessionUser } from './apiKey'; + +/** Fallback for the synthetic session expiry when the key never expires. */ +const NON_EXPIRING_SESSION_MS = 100 * 365 * 24 * 60 * 60 * 1000; // 100 years + +/** + * Public REST API context (`/api/v1`). Authenticates via API key instead of the + * cookie session used by the app's `/api/trpc` handler, but returns the same + * context shape (`{ session, db }`) so procedures are shared between them. + * + * `session.expires` is synthetic: it mirrors the API key's expiry (which is + * configurable when the key is created) so nothing downstream caches an API-key + * session beyond the key's lifetime. + */ +export const createApiContext = async (opts: CreateNextContextOptions) => { + const rawKey = parseApiKeyFromHeaders(opts.req.headers); + const resolved = await resolveApiKeyUser(db, rawKey); + + const session: Session | null = resolved + ? { + user: toSessionUser(resolved.user), + expires: ( + resolved.expiresAt ?? new Date(Date.now() + NON_EXPIRING_SESSION_MS) + ).toISOString(), + } + : null; + + return { session, db }; +}; diff --git a/src/server/api/apiKey.ts b/src/server/api/apiKey.ts new file mode 100644 index 000000000..373dd8c1a --- /dev/null +++ b/src/server/api/apiKey.ts @@ -0,0 +1,126 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { type Session } from 'next-auth'; +import { type IncomingHttpHeaders } from 'node:http'; + +import { type PrismaClient, type User } from '@prisma/client'; + +/** + * API-key authentication helpers for the public REST API (`/api/v1`). + * + * Keys are shown to the user exactly once at creation. Only a SHA-256 hash of + * the key is persisted, so a leaked database never exposes usable credentials. + */ + +export const API_KEY_PREFIX = 'spro_'; + +/** How long the plaintext prefix shown in listings is (e.g. `spro_A1b2c3`). */ +const PARTIAL_KEY_LENGTH = API_KEY_PREFIX.length + 6; + +/** Only refresh `lastUsedAt` if it is older than this, to avoid a write per request. */ +const LAST_USED_THROTTLE_MS = 60 * 1000; + +export interface GeneratedApiKey { + /** The full plaintext key — returned to the caller once and never stored. */ + key: string; + /** SHA-256 hex digest persisted as `ApiKey.hashedKey`. */ + hashedKey: string; + /** Non-secret prefix persisted for display, e.g. `spro_A1b2c3`. */ + partialKey: string; +} + +/** SHA-256 hex digest of a plaintext key. Deterministic — used for storage and lookup. */ +export function hashApiKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + +/** Mint a new random API key together with its hash and display prefix. */ +export function generateApiKey(): GeneratedApiKey { + const key = `${API_KEY_PREFIX}${randomBytes(32).toString('base64url')}`; + + return { + key, + hashedKey: hashApiKey(key), + partialKey: key.slice(0, PARTIAL_KEY_LENGTH), + }; +} + +/** + * Extract a bearer token from request headers. Accepts `Authorization: Bearer + * ` (preferred) or the `X-API-Key: ` header. Returns null when absent. + */ +export function parseApiKeyFromHeaders(headers: IncomingHttpHeaders): string | null { + const authorization = headers.authorization; + if (authorization) { + const [scheme, token] = authorization.split(' '); + if ('bearer' === scheme?.toLowerCase() && token) { + return token.trim(); + } + } + + const apiKeyHeader = headers['x-api-key']; + if ('string' === typeof apiKeyHeader && apiKeyHeader.trim()) { + return apiKeyHeader.trim(); + } + + return null; +} + +/** True when a key with the given expiry is no longer valid at `now`. */ +export function isApiKeyExpired(expiresAt: Date | null, now: Date = new Date()): boolean { + return null !== expiresAt && expiresAt.getTime() <= now.getTime(); +} + +/** + * Look up the user owning a plaintext API key, together with the key's expiry + * (used for the synthetic session lifetime). Returns null when the key is + * unknown or expired. Refreshes `lastUsedAt` at most once per throttle window. + */ +export async function resolveApiKeyUser( + db: PrismaClient, + rawKey: string | null, + now: Date = new Date(), +): Promise<{ user: User; expiresAt: Date | null } | null> { + if (!rawKey) { + return null; + } + + const record = await db.apiKey.findUnique({ + where: { hashedKey: hashApiKey(rawKey) }, + include: { user: true }, + }); + + if (!record || isApiKeyExpired(record.expiresAt, now)) { + return null; + } + + const isStale = + !record.lastUsedAt || now.getTime() - record.lastUsedAt.getTime() > LAST_USED_THROTTLE_MS; + if (isStale) { + await db.apiKey + .update({ where: { id: record.id }, data: { lastUsedAt: now } }) + .catch(() => null); + } + + return { user: record.user, expiresAt: record.expiresAt }; +} + +/** + * Build a next-auth session user from a database user, matching the shape the + * cookie `session` callback produces in `~/server/auth`, so procedures behave + * identically whether authenticated by cookie or API key. + */ +export function toSessionUser(user: User): Session['user'] { + return { + id: user.id, + name: user.name ?? '', + email: user.email ?? '', + image: user.image ?? '', + currency: user.currency, + defaultCurrency: user.defaultCurrency, + obapiProviderId: user.obapiProviderId ?? undefined, + bankingId: user.bankingId ?? undefined, + preferredLanguage: user.preferredLanguage, + hiddenFriendIds: user.hiddenFriendIds, + }; +} diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts new file mode 100644 index 000000000..7a94080be --- /dev/null +++ b/src/server/api/apiRouter.ts @@ -0,0 +1,63 @@ +import { createApiTRPCRouter } from './apiTrpc'; +import { + addOrEditExpenseApiProcedure, + expenseMethods, + getAllExpensesApiProcedure, + getExpensesWithFriendApiProcedure, + getGroupExpensesApiProcedure, +} from './routers/expense'; +import { groupMethods } from './routers/group'; +import { getOwnExpensesApiProcedure, userMethods } from './routers/user'; + +/** + * Pick a subset of a router's method record while preserving the exact + * procedure types (needed for the static OpenAPI generation below). + * + * Note: the picked records are assigned to intermediate constants. Spreading + * the call directly into the object literal passed to `createApiTRPCRouter` + * makes TypeScript widen the picked keys via the call's contextual type, + * re-exposing every method of the source record. + */ +const pickMethods = , K extends keyof T>( + methods: T, + keys: readonly K[], +): Pick => Object.fromEntries(keys.map((key) => [key, methods[key]])) as Pick; + +const userApiMethods = pickMethods(userMethods, [ + 'me', + 'getFriends', + 'getBalancesWithFriend', + 'getFriend', +]); + +const groupApiMethods = pickMethods(groupMethods, ['getAllGroups', 'getGroupDetails']); + +const expenseApiMethods = pickMethods(expenseMethods, ['getBalances', 'getExpenseDetails']); + +/** + * Public REST API surface, served at `/api/v1` with API-key auth and documented + * by the generated OpenAPI spec (`scripts/generate-openapi.ts`). + * + * Procedures are shared with the app routers via their exported method records + * (see `userMethods` & co.), so each procedure exists exactly once. API-only + * variants (pagination, plain-JSON friendly input schemas) are defined next to + * their app counterparts in the router files. `@trpc/openapi` has no + * per-procedure filter, so exposure is controlled structurally by what is + * mounted here. Add new public endpoints one at a time. + */ +export const apiRouter = createApiTRPCRouter({ + user: createApiTRPCRouter({ + ...userApiMethods, + getOwnExpenses: getOwnExpensesApiProcedure, + }), + group: createApiTRPCRouter(groupApiMethods), + expense: createApiTRPCRouter({ + ...expenseApiMethods, + getAllExpenses: getAllExpensesApiProcedure, + getExpensesWithFriend: getExpensesWithFriendApiProcedure, + getGroupExpenses: getGroupExpensesApiProcedure, + addOrEditExpense: addOrEditExpenseApiProcedure, + }), +}); + +export type ApiRouter = typeof apiRouter; diff --git a/src/server/api/apiTransformer.ts b/src/server/api/apiTransformer.ts new file mode 100644 index 000000000..af9b759b7 --- /dev/null +++ b/src/server/api/apiTransformer.ts @@ -0,0 +1,69 @@ +import { type SuperJSONResult, deserialize as superjsonDeserialize } from 'superjson'; + +/** + * A superjson envelope is `{ json: …, meta?: … }` and nothing else. Plain JSON + * payloads (what the public API documents) never match this shape. + */ +const isSuperjsonEnvelope = (data: object): data is SuperJSONResult => { + if (Array.isArray(data)) { + return false; + } + + const keys = Object.keys(data); + return keys.includes('json') && keys.every((key) => 'json' === key || 'meta' === key); +}; + +/** + * Deeply convert a procedure result to plain JSON that matches the generated + * OpenAPI schemas: `Date` → ISO 8601 string. `BigInt` values that fit in a + * safe number become numbers; larger values are returned as strings so the + * output never silently loses precision (matching input, which accepts + * numeric strings for amounts beyond `Number.MAX_SAFE_INTEGER`). + */ +const toPlainJson = (value: unknown): unknown => { + if ('bigint' === typeof value) { + if (value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(Number.MIN_SAFE_INTEGER)) { + return Number(value); + } + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + return value.map(toPlainJson); + } + if ('object' === typeof value && null !== value) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, toPlainJson(entry)]), + ); + } + + return value; +}; + +/** + * Transformer for the public REST API (`/api/v1`). API consumers send and + * receive plain JSON: + * + * - Input accepts plain JSON (query params, JSON bodies) and additionally + * native superjson envelopes, so tRPC clients work against the API too. + * - Output is serialized to plain JSON instead of a superjson envelope, so + * responses match the schemas in the generated OpenAPI document. + */ +export const apiTransformer = { + input: { + serialize: (value: unknown) => value, + deserialize: (data: unknown) => { + if ('object' !== typeof data || null === data) { + return data; + } + + return isSuperjsonEnvelope(data) ? superjsonDeserialize(data) : data; + }, + }, + output: { + serialize: (value: unknown) => toPlainJson(value), + deserialize: (value: unknown) => value, + }, +}; diff --git a/src/server/api/apiTrpc.ts b/src/server/api/apiTrpc.ts new file mode 100644 index 000000000..32f9cd3e4 --- /dev/null +++ b/src/server/api/apiTrpc.ts @@ -0,0 +1,22 @@ +import { initTRPC } from '@trpc/server'; + +import { apiTransformer } from './apiTransformer'; +import { type InnerTRPCContext, trpcErrorFormatter } from './trpc'; + +/** + * Separate tRPC instance for the public API router. It shares the app's + * context type and error formatter but uses the plain-JSON transformer from + * `./apiTransformer`. + * + * Building the API router from this instance (instead of patching the shared + * config of the app instance) guarantees that mounting procedures in the API + * router can never change how `/api/trpc` serializes data — the transformer + * is resolved from the router each handler mounts, and both `_config` objects + * stay independent. + */ +const tApi = initTRPC.context().create({ + transformer: apiTransformer, + errorFormatter: trpcErrorFormatter, +}); + +export const createApiTRPCRouter = tApi.router; diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json new file mode 100644 index 000000000..f5c92faf4 --- /dev/null +++ b/src/server/api/openapi.generated.json @@ -0,0 +1,2665 @@ +{ + "openapi": "3.1.1", + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", + "info": { "title": "SplitPro API", "version": "0.1.0" }, + "servers": [{ "url": "/api/v1" }], + "paths": { + "/user.getOwnExpenses": { + "get": { + "operationId": "user.getOwnExpenses", + "tags": ["user"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { "data": { "$ref": "#/components/schemas/PaginatedResult" } }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "limit": { "type": "number", "default": 20 }, + "offset": { "type": "number", "default": 0 } + }, + "additionalProperties": false + } + } + } + } + ] + } + }, + "/user.me": { + "get": { + "operationId": "user.me", + "tags": ["user"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "email": { "type": ["string", "null"] }, + "image": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "obapiProviderId": { "type": "string" }, + "bankingId": { "type": "string" }, + "preferredLanguage": { "type": "string" }, + "hiddenFriendIds": { "type": "array", "items": { "type": "number" } } + }, + "required": ["id", "currency", "preferredLanguage", "hiddenFriendIds"], + "additionalProperties": false + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/user.getFriends": { + "get": { + "operationId": "user.getFriends", + "tags": ["user"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { "type": "array", "items": { "type": "number" } } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + } + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/user.getBalancesWithFriend": { + "get": { + "operationId": "user.getBalancesWithFriend", + "tags": ["user"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "friendId": { "type": "number" }, + "currency": { "type": "string" }, + "amount": { "type": "integer", "format": "bigint" }, + "groupId": { "type": ["number", "null"] }, + "groupName": { "type": ["string", "null"] } + }, + "required": ["friendId", "currency", "amount", "groupId", "groupName"], + "additionalProperties": false + } + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "friendId": { "type": "number" } }, + "required": ["friendId"], + "additionalProperties": false + } + } + } + } + ] + } + }, + "/user.getFriend": { + "get": { + "operationId": "user.getFriend", + "tags": ["user"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "type": "object", + "properties": { + "defaultSplit": { + "oneOf": [ + { "$ref": "#/components/schemas/SerializedDefaultSplitConfig" }, + { "type": "null" } + ] + }, + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "defaultSplit", + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "friendId": { "type": "number" } }, + "required": ["friendId"], + "additionalProperties": false + } + } + } + } + ] + } + }, + "/group.getAllGroups": { + "get": { + "operationId": "group.getAllGroups", + "tags": ["group"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "properties": { + "group": { + "type": "object", + "properties": { + "groupDefaultSplit": { + "oneOf": [ + { + "type": "object", + "properties": { + "groupId": { "type": "number" }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "shares": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/RecursiveType" + } + }, + { "$ref": "#/components/schemas/JsonArray" }, + { "type": "boolean" }, + { "type": "null" } + ] + } + }, + "required": [ + "groupId", + "createdAt", + "updatedAt", + "splitType", + "shares" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "groupUsers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "userId": { "type": "number" }, + "groupId": { "type": "number" } + }, + "required": ["user", "userId", "groupId"], + "additionalProperties": false + } + }, + "name": { "type": "string" }, + "id": { "type": "number" }, + "userId": { "type": "number" }, + "image": { "type": ["string", "null"] }, + "defaultCurrency": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "publicId": { "type": "string" }, + "splitwiseGroupId": { "type": ["string", "null"] }, + "simplifyDebts": { "type": "boolean" }, + "archivedAt": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + } + }, + "required": [ + "groupDefaultSplit", + "groupUsers", + "name", + "id", + "userId", + "image", + "defaultCurrency", + "createdAt", + "updatedAt", + "publicId", + "splitwiseGroupId", + "simplifyDebts", + "archivedAt" + ], + "additionalProperties": false + } + }, + "required": ["group"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "userId": { "type": "number" }, + "groupId": { "type": "number" } + }, + "required": ["userId", "groupId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "group": { + "type": "object", + "properties": { + "defaultSplit": { + "oneOf": [ + { + "$ref": "#/components/schemas/SerializedDefaultSplitConfig" + }, + { "type": "null" } + ] + }, + "groupDefaultSplit": { + "oneOf": [ + { + "type": "object", + "properties": { + "groupId": { "type": "number" }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "shares": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/RecursiveType" + } + }, + { "$ref": "#/components/schemas/JsonArray" }, + { "type": "boolean" }, + { "type": "null" } + ] + } + }, + "required": [ + "groupId", + "createdAt", + "updatedAt", + "splitType", + "shares" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "groupUsers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "userId": { "type": "number" }, + "groupId": { "type": "number" } + }, + "required": ["user", "userId", "groupId"], + "additionalProperties": false + } + }, + "name": { "type": "string" }, + "id": { "type": "number" }, + "userId": { "type": "number" }, + "image": { "type": ["string", "null"] }, + "defaultCurrency": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "publicId": { "type": "string" }, + "splitwiseGroupId": { "type": ["string", "null"] }, + "simplifyDebts": { "type": "boolean" }, + "archivedAt": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + } + }, + "required": [ + "defaultSplit", + "groupDefaultSplit", + "groupUsers", + "name", + "id", + "userId", + "image", + "defaultCurrency", + "createdAt", + "updatedAt", + "publicId", + "splitwiseGroupId", + "simplifyDebts", + "archivedAt" + ], + "additionalProperties": false + } + }, + "required": ["group"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/group.getGroupDetails": { + "get": { + "operationId": "group.getGroupDetails", + "tags": ["group"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "type": "object", + "properties": { + "defaultSplit": { + "oneOf": [ + { "$ref": "#/components/schemas/SerializedDefaultSplitConfig" }, + { "type": "null" } + ] + }, + "groupDefaultSplit": { + "oneOf": [ + { + "type": "object", + "properties": { + "groupId": { "type": "number" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "shares": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/RecursiveType" + } + }, + { "$ref": "#/components/schemas/JsonArray" }, + { "type": "boolean" }, + { "type": "null" } + ] + } + }, + "required": [ + "groupId", + "createdAt", + "updatedAt", + "splitType", + "shares" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "groupUsers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "userId": { "type": "number" }, + "groupId": { "type": "number" } + }, + "required": ["user", "userId", "groupId"], + "additionalProperties": false + } + }, + "groupBalances": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { "type": "number" }, + "currency": { "type": "string" }, + "friendId": { "type": "number" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "userId", + "currency", + "friendId", + "groupId", + "amount", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "name": { "type": "string" }, + "id": { "type": "number" }, + "userId": { "type": "number" }, + "image": { "type": ["string", "null"] }, + "defaultCurrency": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "publicId": { "type": "string" }, + "splitwiseGroupId": { "type": ["string", "null"] }, + "simplifyDebts": { "type": "boolean" }, + "archivedAt": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + } + }, + "required": [ + "defaultSplit", + "groupDefaultSplit", + "groupUsers", + "groupBalances", + "name", + "id", + "userId", + "image", + "defaultCurrency", + "createdAt", + "updatedAt", + "publicId", + "splitwiseGroupId", + "simplifyDebts", + "archivedAt" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "groupId": { "type": "number" } }, + "required": ["groupId"], + "additionalProperties": false + } + } + } + } + ] + } + }, + "/expense.getAllExpenses": { + "get": { + "operationId": "expense.getAllExpenses", + "tags": ["expense"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { "data": { "$ref": "#/components/schemas/PaginatedResult2" } }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "limit": { "type": "number", "default": 20 }, + "offset": { "type": "number", "default": 0 } + }, + "additionalProperties": false + } + } + } + } + ] + } + }, + "/expense.getExpensesWithFriend": { + "get": { + "operationId": "expense.getExpensesWithFriend", + "tags": ["expense"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { "data": { "$ref": "#/components/schemas/PaginatedResult3" } }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "friendId": { "type": "number" }, + "limit": { "type": "number", "default": 20 }, + "offset": { "type": "number", "default": 0 } + }, + "required": ["friendId"], + "additionalProperties": false + } + } + } + } + ] + } + }, + "/expense.getGroupExpenses": { + "get": { + "operationId": "expense.getGroupExpenses", + "tags": ["expense"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { "data": { "$ref": "#/components/schemas/PaginatedResult4" } }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groupId": { "type": "number" }, + "limit": { "type": "number", "default": 20 }, + "offset": { "type": "number", "default": 0 } + }, + "required": ["groupId"], + "additionalProperties": false + } + } + } + } + ] + } + }, + "/expense.addOrEditExpense": { + "post": { + "operationId": "expense.addOrEditExpense", + "tags": ["expense"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false + } + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "integer", "format": "bigint" } + ] + }, + "paidBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "participants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { "type": "number" }, + "amount": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "integer", "format": "bigint" } + ] + } + }, + "required": ["userId", "amount"], + "additionalProperties": false + } + }, + "expenseDate": { + "oneOf": [{ "type": "string" }, { "type": "string", "format": "date-time" }] + }, + "fileKey": { "type": "string" }, + "transactionId": { "type": "string" }, + "conversionToId": { "type": "string" }, + "expenseId": { "type": "string" }, + "cronExpression": { "type": "string" } + }, + "required": [ + "name", + "currency", + "groupId", + "amount", + "paidBy", + "category", + "splitType", + "participants" + ], + "additionalProperties": false + } + } + } + } + } + } + }, + "/expense.getBalances": { + "get": { + "operationId": "expense.getBalances", + "tags": ["expense"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "balances": { + "type": "array", + "items": { + "type": "object", + "properties": { + "friendId": { "type": "number" }, + "currencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "currency": { "type": "string" }, + "amount": { "type": "integer", "format": "bigint" } + }, + "required": ["currency", "amount"], + "additionalProperties": false + } + }, + "friend": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "maxAmount": { "type": "integer", "format": "bigint" } + }, + "required": ["friendId", "currencies", "friend", "maxAmount"], + "additionalProperties": false + } + } + }, + "required": ["balances"], + "additionalProperties": false + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/expense.getExpenseDetails": { + "get": { + "operationId": "expense.getExpenseDetails", + "tags": ["expense"], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "type": "object", + "properties": { + "group": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "id": { "type": "number" }, + "userId": { "type": "number" }, + "image": { "type": ["string", "null"] }, + "defaultCurrency": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "publicId": { "type": "string" }, + "splitwiseGroupId": { "type": ["string", "null"] }, + "simplifyDebts": { "type": "boolean" }, + "archivedAt": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + } + }, + "required": [ + "name", + "id", + "userId", + "image", + "defaultCurrency", + "createdAt", + "updatedAt", + "publicId", + "splitwiseGroupId", + "simplifyDebts", + "archivedAt" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "expenseNotes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" }, + "note": { "type": "string" }, + "createdById": { "type": "number" }, + "expenseId": { "type": "string" } + }, + "required": [ + "id", + "createdAt", + "note", + "createdById", + "expenseId" + ], + "additionalProperties": false + } + }, + "expenseParticipants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "userId": { "type": "number" }, + "amount": { "type": "integer", "format": "bigint" }, + "expenseId": { "type": "string" } + }, + "required": ["user", "userId", "amount", "expenseId"], + "additionalProperties": false + } + }, + "addedByUser": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "deletedByUser": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "conversionTo": { + "oneOf": [ + { + "type": "object", + "properties": { + "expenseParticipants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "userId": { "type": "number" }, + "amount": { "type": "integer", "format": "bigint" }, + "expenseId": { "type": "string" } + }, + "required": ["user", "userId", "amount", "expenseId"], + "additionalProperties": false + } + }, + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "expenseParticipants", + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "paidByUser": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "recurrence": { + "oneOf": [ + { + "type": "object", + "properties": { + "job": { + "type": "object", + "properties": { + "schedule": { "type": "string" }, + "command": { "type": "string" } + }, + "required": ["schedule", "command"], + "additionalProperties": false + }, + "id": { "type": "number" }, + "jobId": { "type": "integer", "format": "bigint" }, + "notified": { "type": "boolean" } + }, + "required": ["job", "id", "jobId", "notified"], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "updatedByUser": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { + "type": "array", + "items": { "type": "number" } + } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "group", + "expenseNotes", + "expenseParticipants", + "addedByUser", + "deletedByUser", + "conversionTo", + "paidByUser", + "recurrence", + "updatedByUser", + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { "$ref": "#/components/responses/Error" } + }, + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "style": "deepObject", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "expenseId": { "type": "string" } }, + "required": ["expenseId"], + "additionalProperties": false + } + } + } + } + ] + } + } + }, + "components": { + "schemas": { + "PaginatedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "id": { "type": "number" }, + "userId": { "type": "number" }, + "image": { "type": ["string", "null"] }, + "defaultCurrency": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "publicId": { "type": "string" }, + "splitwiseGroupId": { "type": ["string", "null"] }, + "simplifyDebts": { "type": "boolean" }, + "archivedAt": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + } + }, + "required": [ + "name", + "id", + "userId", + "image", + "defaultCurrency", + "createdAt", + "updatedAt", + "publicId", + "splitwiseGroupId", + "simplifyDebts", + "archivedAt" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "group", + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { "type": "number" }, + "limit": { "type": "number" }, + "offset": { "type": "number" }, + "hasMore": { "type": "boolean" } + }, + "required": ["total", "limit", "offset", "hasMore"], + "additionalProperties": false + } + }, + "required": ["items", "pagination"], + "additionalProperties": false + }, + "SerializedDefaultSplitConfig": { + "type": "object", + "properties": { + "splitType": { "type": "string", "enum": ["EQUAL", "PERCENTAGE", "SHARE"] }, + "shares": { "type": "object", "additionalProperties": { "type": "string" } } + }, + "required": ["splitType", "shares"], + "additionalProperties": false + }, + "RecursiveType": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { + "type": "object", + "additionalProperties": { "$ref": "#/components/schemas/RecursiveType" } + }, + { "$ref": "#/components/schemas/JsonArray" }, + { "type": "boolean" }, + { "type": "null" } + ] + }, + "JsonArray": { + "type": "object", + "properties": { + "length": { "type": "number" }, + "toString": { "type": "object" }, + "toLocaleString": { "type": "object" }, + "pop": { "type": "object" }, + "push": { "type": "object" }, + "concat": { "type": "object" }, + "join": { "type": "object" }, + "reverse": { "type": "object" }, + "shift": { "type": "object" }, + "slice": { "type": "object" }, + "sort": { "type": "object" }, + "splice": { "type": "object" }, + "unshift": { "type": "object" }, + "indexOf": { "type": "object" }, + "lastIndexOf": { "type": "object" }, + "every": { "type": "object" }, + "some": { "type": "object" }, + "forEach": { "type": "object" }, + "map": { "type": "object" }, + "filter": { "type": "object" }, + "reduce": { "type": "object" }, + "reduceRight": { "type": "object" }, + "find": { "type": "object" }, + "findIndex": { "type": "object" }, + "fill": { "type": "object" }, + "copyWithin": { "type": "object" }, + "entries": { "type": "object" }, + "keys": { "type": "object" }, + "values": { "type": "object" }, + "includes": { "type": "object" }, + "flatMap": { "type": "object" }, + "flat": { "type": "object" }, + "at": { "type": "object" }, + "findLast": { "type": "object" }, + "findLastIndex": { "type": "object" }, + "toReversed": { "type": "object" }, + "toSorted": { "type": "object" }, + "toSpliced": { "type": "object" }, + "with": { "type": "object" } + }, + "required": [ + "length", + "toString", + "toLocaleString", + "pop", + "push", + "concat", + "join", + "reverse", + "shift", + "slice", + "sort", + "splice", + "unshift", + "indexOf", + "lastIndexOf", + "every", + "some", + "forEach", + "map", + "filter", + "reduce", + "reduceRight", + "find", + "findIndex", + "fill", + "copyWithin", + "entries", + "keys", + "values", + "includes", + "flatMap", + "flat", + "at", + "findLast", + "findLastIndex", + "toReversed", + "toSorted", + "toSpliced", + "with" + ], + "additionalProperties": false + }, + "PaginatedResult2": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "expense": { + "type": "object", + "properties": { + "deletedByUser": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "image": { "type": ["string", "null"] } + }, + "required": ["name", "id", "email", "image"], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "paidByUser": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "image": { "type": ["string", "null"] } + }, + "required": ["name", "id", "email", "image"], + "additionalProperties": false + }, + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "deletedByUser", + "paidByUser", + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + }, + "userId": { "type": "number" }, + "amount": { "type": "integer", "format": "bigint" }, + "expenseId": { "type": "string" } + }, + "required": ["expense", "userId", "amount", "expenseId"], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { "type": "number" }, + "limit": { "type": "number" }, + "offset": { "type": "number" }, + "hasMore": { "type": "boolean" } + }, + "required": ["total", "limit", "offset", "hasMore"], + "additionalProperties": false + } + }, + "required": ["items", "pagination"], + "additionalProperties": false + }, + "PaginatedResult3": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "id": { "type": "number" }, + "simplifyDebts": { "type": "boolean" } + }, + "required": ["name", "id", "simplifyDebts"], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "expenseParticipants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { "type": "number" }, + "amount": { "type": "integer", "format": "bigint" }, + "expenseId": { "type": "string" } + }, + "required": ["userId", "amount", "expenseId"], + "additionalProperties": false + } + }, + "conversionTo": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "paidByUser": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { "type": "array", "items": { "type": "number" } } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "group", + "expenseParticipants", + "conversionTo", + "paidByUser", + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { "type": "number" }, + "limit": { "type": "number" }, + "offset": { "type": "number" }, + "hasMore": { "type": "boolean" } + }, + "required": ["total", "limit", "offset", "hasMore"], + "additionalProperties": false + } + }, + "required": ["items", "pagination"], + "additionalProperties": false + }, + "PaginatedResult4": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "expenseParticipants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { "type": "number" }, + "amount": { "type": "integer", "format": "bigint" }, + "expenseId": { "type": "string" } + }, + "required": ["userId", "amount", "expenseId"], + "additionalProperties": false + } + }, + "deletedByUser": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { "type": "array", "items": { "type": "number" } } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "conversionTo": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + }, + "paidByUser": { + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "id": { "type": "number" }, + "email": { "type": ["string", "null"] }, + "emailVerified": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "image": { "type": ["string", "null"] }, + "currency": { "type": "string" }, + "defaultCurrency": { "type": ["string", "null"] }, + "preferredLanguage": { "type": "string" }, + "bankingId": { "type": ["string", "null"] }, + "obapiProviderId": { "type": ["string", "null"] }, + "hiddenFriendIds": { "type": "array", "items": { "type": "number" } } + }, + "required": [ + "name", + "id", + "email", + "emailVerified", + "image", + "currency", + "defaultCurrency", + "preferredLanguage", + "bankingId", + "obapiProviderId", + "hiddenFriendIds" + ], + "additionalProperties": false + }, + "name": { "type": "string" }, + "id": { "type": "string" }, + "currency": { "type": "string" }, + "groupId": { "type": ["number", "null"] }, + "amount": { "type": "integer", "format": "bigint" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "paidBy": { "type": "number" }, + "addedBy": { "type": "number" }, + "category": { "type": "string" }, + "splitType": { + "type": "string", + "enum": [ + "EQUAL", + "PERCENTAGE", + "EXACT", + "SHARE", + "ADJUSTMENT", + "SETTLEMENT", + "CURRENCY_CONVERSION" + ] + }, + "expenseDate": { "type": "string", "format": "date-time" }, + "fileKey": { "type": ["string", "null"] }, + "deletedAt": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "deletedBy": { "type": ["number", "null"] }, + "updatedBy": { "type": ["number", "null"] }, + "transactionId": { "type": ["string", "null"] }, + "recurrenceId": { "type": ["number", "null"] }, + "conversionToId": { "type": ["string", "null"] } + }, + "required": [ + "expenseParticipants", + "deletedByUser", + "conversionTo", + "paidByUser", + "name", + "id", + "currency", + "groupId", + "amount", + "createdAt", + "updatedAt", + "paidBy", + "addedBy", + "category", + "splitType", + "expenseDate", + "fileKey", + "deletedAt", + "deletedBy", + "updatedBy", + "transactionId", + "recurrenceId", + "conversionToId" + ], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { "type": "number" }, + "limit": { "type": "number" }, + "offset": { "type": "number" }, + "hasMore": { "type": "boolean" } + }, + "required": ["total", "limit", "offset", "hasMore"], + "additionalProperties": false + } + }, + "required": ["items", "pagination"], + "additionalProperties": false + }, + "typeToFlattenedError": { + "type": "object", + "properties": { + "formErrors": { "type": "array", "items": { "type": "string" } }, + "fieldErrors": { + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } } + } + }, + "required": ["formErrors", "fieldErrors"], + "additionalProperties": false + } + }, + "responses": { + "Error": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "zodError": { + "oneOf": [ + { "$ref": "#/components/schemas/typeToFlattenedError" }, + { "type": "null" } + ] + }, + "appErrorCode": { "type": ["string", "null"] }, + "code": { + "type": "string", + "enum": [ + "PARSE_ERROR", + "BAD_REQUEST", + "INTERNAL_SERVER_ERROR", + "NOT_IMPLEMENTED", + "BAD_GATEWAY", + "SERVICE_UNAVAILABLE", + "GATEWAY_TIMEOUT", + "UNAUTHORIZED", + "PAYMENT_REQUIRED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_SUPPORTED", + "TIMEOUT", + "CONFLICT", + "PRECONDITION_FAILED", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "UNPROCESSABLE_CONTENT", + "PRECONDITION_REQUIRED", + "TOO_MANY_REQUESTS", + "CLIENT_CLOSED_REQUEST" + ] + }, + "httpStatus": { "type": "number" }, + "path": { "type": "string" }, + "stack": { "type": "string" } + }, + "required": ["zodError", "appErrorCode", "code", "httpStatus"], + "additionalProperties": false + }, + "message": { "type": "string" }, + "code": { + "type": "number", + "enum": [ + -32700, -32600, -32603, -32001, -32002, -32003, -32004, -32005, -32008, + -32009, -32012, -32013, -32015, -32022, -32028, -32029, -32099 + ] + } + }, + "required": ["data", "message", "code"], + "additionalProperties": false + } + }, + "required": ["error"] + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "SplitPro API key (spro_…)" + } + } + }, + "security": [{ "bearerAuth": [] }] +} diff --git a/src/server/api/pagination.ts b/src/server/api/pagination.ts new file mode 100644 index 000000000..e2f781724 --- /dev/null +++ b/src/server/api/pagination.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +/** + * Shared pagination input schema for the public API list endpoints. + * Uses Zod `.default()` so the OpenAPI generator emits `default` values. + * + * @example + * // Pagination-only input: + * .input(paginationInput.default({})) + * // Combined with other fields: + * .input(z.object({ groupId: z.number() }).extend(paginationInput.shape)) + */ +export const paginationInput = z.object({ + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0), +}); + +export type PaginationInput = z.infer; + +export interface PaginatedResult { + items: T[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +/** + * Build the paginated response envelope from a page of items plus the total + * count of all matching rows. + */ +export const paginatedResult = ( + items: T[], + total: number, + { limit, offset }: PaginationInput, +): PaginatedResult => ({ + items, + pagination: { total, limit, offset, hasMore: offset + items.length < total }, +}); diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index ea821f392..a02475cb4 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -2,17 +2,18 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { simplifyDebts } from '~/lib/simplify'; +import { paginatedResult, paginationInput } from '~/server/api/pagination'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; -import { BigMath, currencyConversion } from '~/utils/numbers'; - import { + type CreateExpense, arrayify, createCurrencyConversionSchema, createExpenseSchema, getBatchCurrencyRatesSchema, getCurrencyRateSchema, } from '~/types/expense.types'; +import { BigMath, currencyConversion } from '~/utils/numbers'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; import { currencyRateProvider } from '../services/currencyRateService'; import { isCurrencyCode } from '~/lib/currency'; @@ -20,7 +21,104 @@ import { SplitType } from '@prisma/client'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { getUserMap } from './user'; -export const expenseRouter = createTRPCRouter({ +/** + * Shared write path for creating/editing expenses, used by both the app + * router's `addOrEditExpense` and the public API variant. Re-run permission + * checks per expense and stop on the first failure. + */ +const upsertExpenses = async (expenses: CreateExpense[], userId: number) => { + const results = []; + // Expense writes must preserve input order and stop on the first validation/write failure. + // oxlint-disable no-await-in-loop + for (const input of expenses) { + await validateExpenseWritePermission(input.expenseId, input.groupId, userId); + if (input.splitType === SplitType.CURRENCY_CONVERSION) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); + } + + try { + const expense = input.expenseId + ? await editExpense(input, userId) + : await createExpense(input, userId); + + results.push(expense); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create expense', + }); + } + } + // oxlint-enable no-await-in-loop + + return results; +}; + +/** Where-clause shared by the app and paginated API group expense listings. */ +const groupExpensesWhere = (groupId: number) => ({ + groupId, + deletedBy: null, + OR: [ + { + NOT: { + splitType: SplitType.CURRENCY_CONVERSION, + }, + }, + { + NOT: { + conversionToId: null, + }, + }, + ], +}); + +/** Where-clause shared by the app and paginated API friend expense listings. */ +const expensesWithFriendWhere = (userId: number, friendId: number) => ({ + AND: [ + { + expenseParticipants: { + some: { + userId: friendId, + amount: { not: 0n }, + }, + }, + }, + { + expenseParticipants: { + some: { + userId, + amount: { not: 0n }, + }, + }, + }, + { + OR: [{ paidBy: userId }, { paidBy: friendId }], + }, + { deletedBy: null }, + { + OR: [ + { + NOT: { + splitType: SplitType.CURRENCY_CONVERSION, + }, + }, + { + NOT: { + conversionToId: null, + }, + }, + ], + }, + ], +}); + +/** + * Method record for `expenseRouter`, exported so the public API router + * (`~/server/api/apiRouter`) can mount a curated subset of the same procedures + * without duplicating them. + */ +export const expenseMethods = { getCumulatedBalances: protectedProcedure.query(async ({ ctx }) => { const cumulatedBalances = await db.balanceView.groupBy({ by: ['currency'], @@ -134,34 +232,7 @@ export const expenseRouter = createTRPCRouter({ addOrEditExpense: protectedProcedure .input(arrayify(createExpenseSchema)) - .mutation(async ({ input: expenses, ctx }) => { - const results = []; - // Expense writes must preserve input order and stop on the first validation/write failure. - // oxlint-disable no-await-in-loop - for (const input of expenses) { - await validateExpenseWritePermission(input.expenseId, input.groupId, ctx.session.user.id); - if (input.splitType === SplitType.CURRENCY_CONVERSION) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); - } - - try { - const expense = input.expenseId - ? await editExpense(input, ctx.session.user.id) - : await createExpense(input, ctx.session.user.id); - - results.push(expense); - } catch (error) { - console.error(error); - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create expense', - }); - } - } - // oxlint-enable no-await-in-loop - - return results; - }), + .mutation(async ({ input: expenses, ctx }) => upsertExpenses(expenses, ctx.session.user.id)), addOrEditCurrencyConversion: protectedProcedure .input(createCurrencyConversionSchema) @@ -224,57 +295,7 @@ export const expenseRouter = createTRPCRouter({ .input(z.object({ friendId: z.number() })) .query(async ({ input, ctx }) => { const expenses = await db.expense.findMany({ - where: { - AND: [ - { - expenseParticipants: { - some: { - userId: input.friendId, - amount: { - not: 0n, - }, - }, - }, - }, - { - expenseParticipants: { - some: { - userId: ctx.session.user.id, - amount: { - not: 0n, - }, - }, - }, - }, - { - OR: [ - { - paidBy: ctx.session.user.id, - }, - { - paidBy: input.friendId, - }, - ], - }, - { - deletedBy: null, - }, - { - OR: [ - { - NOT: { - splitType: SplitType.CURRENCY_CONVERSION, - }, - }, - { - NOT: { - conversionToId: null, - }, - }, - ], - }, - ], - }, + where: expensesWithFriendWhere(ctx.session.user.id, input.friendId), orderBy: { expenseDate: 'desc', }, @@ -310,22 +331,7 @@ export const expenseRouter = createTRPCRouter({ .input(z.object({ groupId: z.number() })) .query(async ({ input, ctx }) => { const expenses = await ctx.db.expense.findMany({ - where: { - groupId: input.groupId, - deletedBy: null, - OR: [ - { - NOT: { - splitType: SplitType.CURRENCY_CONVERSION, - }, - }, - { - NOT: { - conversionToId: null, - }, - }, - ], - }, + where: groupExpensesWhere(input.groupId), orderBy: { expenseDate: 'desc', }, @@ -625,7 +631,9 @@ export const expenseRouter = createTRPCRouter({ return { rates }; }), -}); +}; + +export const expenseRouter = createTRPCRouter(expenseMethods); const validateEditExpensePermission = async (expenseId: string, userId: number): Promise => { const [expenseParticipant, addedBy] = await Promise.all([ @@ -684,4 +692,151 @@ const validateExpenseWritePermission = async ( } }; +/** + * JSON has no BigInt or Date type, so the public API additionally accepts + * plain JSON numbers (and numeric strings, for amounts beyond + * `Number.MAX_SAFE_INTEGER`) and coerces them before validation by the shared + * `createExpenseSchema` rules. + */ +const apiAmountSchema = z + .union([z.bigint(), z.number().int().safe(), z.string().regex(/^-?\d+$/)]) + .transform((value) => BigInt(value)); + +const apiDateSchema = z + .union([z.date(), z.string().datetime({ offset: true })]) + .transform((value) => new Date(value)); + +const apiCreateExpenseSchema = createExpenseSchema.extend({ + amount: apiAmountSchema, + participants: z.array(z.object({ userId: z.number(), amount: apiAmountSchema })), + expenseDate: apiDateSchema.optional(), +}); + +/** + * API-only variant of `addOrEditExpense` mounted in the public API router. + * Accepts a JSON array body (plain JSON or a superjson envelope) and runs the + * exact same write path and permission checks as the app procedure. + */ +export const addOrEditExpenseApiProcedure = protectedProcedure + .input(z.array(apiCreateExpenseSchema)) + .mutation(async ({ input: expenses, ctx }) => upsertExpenses(expenses, ctx.session.user.id)); + +/** + * API-only variant of `getGroupExpenses` with offset pagination. Keeps the + * `groupProcedure` membership check. + */ +export const getGroupExpensesApiProcedure = groupProcedure + .input(z.object({ groupId: z.number() }).extend(paginationInput.shape)) + .query(async ({ input, ctx }) => { + const where = groupExpensesWhere(input.groupId); + + const [items, total] = await Promise.all([ + ctx.db.expense.findMany({ + where, + orderBy: [{ expenseDate: 'desc' }, { id: 'desc' }], + include: { + expenseParticipants: true, + paidByUser: true, + deletedByUser: true, + conversionTo: true, + }, + take: input.limit, + skip: input.offset, + }), + ctx.db.expense.count({ where }), + ]); + + return paginatedResult(items, total, input); + }); + +/** + * API-only variant of `getExpensesWithFriend` with offset pagination. + */ +export const getExpensesWithFriendApiProcedure = protectedProcedure + .input(z.object({ friendId: z.number() }).extend(paginationInput.shape)) + .query(async ({ input, ctx }) => { + const where = expensesWithFriendWhere(ctx.session.user.id, input.friendId); + + const [items, total] = await Promise.all([ + db.expense.findMany({ + where, + // Tie-break with `id`: `expenseDate` is not unique and Postgres may return + // Tied rows in an arbitrary order between queries, breaking pagination + // For clients walking pages. + orderBy: [{ expenseDate: 'desc' }, { id: 'desc' }], + include: { + expenseParticipants: { + where: { + OR: [{ userId: ctx.session.user.id }, { userId: input.friendId }], + }, + }, + paidByUser: true, + conversionTo: true, + group: { + select: { + id: true, + name: true, + simplifyDebts: true, + }, + }, + }, + take: input.limit, + skip: input.offset, + }), + db.expense.count({ where }), + ]); + + return paginatedResult(items, total, input); + }); + +/** + * API-only variant of `getAllExpenses` with offset pagination. + */ +export const getAllExpensesApiProcedure = protectedProcedure + .input(paginationInput.default({})) + .query(async ({ input, ctx }) => { + const where = { userId: ctx.session.user.id }; + + const [items, total] = await Promise.all([ + db.expenseParticipant.findMany({ + where, + // Tie-break on the composite primary key: rows sharing an expense + // Have identical `expenseId`, so both halves are needed for a stable + // Order across queries. + orderBy: [ + { expense: { createdAt: 'desc' as const } }, + { expenseId: 'desc' as const }, + { userId: 'desc' as const }, + ], + include: { + expense: { + include: { + paidByUser: { + select: { + name: true, + email: true, + image: true, + id: true, + }, + }, + deletedByUser: { + select: { + name: true, + email: true, + image: true, + id: true, + }, + }, + }, + }, + }, + take: input.limit, + skip: input.offset, + }), + db.expenseParticipant.count({ where }), + ]); + + return paginatedResult(items, total, input); + }); + export type ExpenseRouter = typeof expenseRouter; diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index 96a664438..b1128d9bd 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -13,7 +13,12 @@ import { serializeDefaultSplit, } from '~/lib/defaultSplit'; -export const groupRouter = createTRPCRouter({ +/** + * Method record for `groupRouter`, exported so the public API router + * (`~/server/api/apiRouter`) can mount a curated subset of the same procedures + * without duplicating them. + */ +export const groupMethods = { create: protectedProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) => { @@ -486,6 +491,8 @@ export const groupRouter = createTRPCRouter({ return group; }), -}); +}; + +export const groupRouter = createTRPCRouter(groupMethods); export type GroupRouter = typeof groupRouter; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index ec36a6063..ccc8b6e26 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -11,6 +11,8 @@ import { claimInviteCooldown } from '~/lib/inviteCooldown'; import { InviteErrorCode } from '~/lib/error/invite'; import { simplifyDebts } from '~/lib/simplify'; import { AppError } from '~/server/api/appError'; +import { generateApiKey } from '~/server/api/apiKey'; +import { paginatedResult, paginationInput } from '~/server/api/pagination'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; @@ -35,7 +37,14 @@ const throwInviteError = ( throw new TRPCError({ code, message, cause: new AppError(inviteErrorCode, message) }); }; -export const userRouter = createTRPCRouter({ +const MAX_API_KEYS_PER_USER = 10; + +/** + * Method record for `userRouter`, exported so the public API router + * (`~/server/api/apiRouter`) can mount a curated subset of the same procedures + * without duplicating them. + */ +export const userMethods = { me: protectedProcedure.query(({ ctx }) => ctx.session.user), getFriends: protectedProcedure.query(async ({ ctx }) => { @@ -449,7 +458,94 @@ export const userRouter = createTRPCRouter({ }), getWebPushPublicKey: protectedProcedure.query(() => env.WEB_PUSH_PUBLIC_KEY ?? ''), -}); + + listApiKeys: protectedProcedure.query(async ({ ctx }) => + db.apiKey.findMany({ + where: { userId: ctx.session.user.id }, + select: { + id: true, + name: true, + partialKey: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }), + ), + + createApiKey: protectedProcedure + .input( + z.object({ name: z.string().min(1).max(100), expiresAt: z.date().nullable().optional() }), + ) + .mutation(async ({ input, ctx }) => { + const keyCount = await db.apiKey.count({ where: { userId: ctx.session.user.id } }); + if (keyCount >= MAX_API_KEYS_PER_USER) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `You can have at most ${MAX_API_KEYS_PER_USER} API keys`, + }); + } + + const { key, hashedKey, partialKey } = generateApiKey(); + + const apiKey = await db.apiKey.create({ + data: { + userId: ctx.session.user.id, + name: input.name, + hashedKey, + partialKey, + expiresAt: input.expiresAt ?? null, + }, + select: { id: true, name: true, partialKey: true, expiresAt: true, createdAt: true }, + }); + + // `key` is the plaintext value, returned exactly once and never stored. + return { ...apiKey, key }; + }), + + revokeApiKey: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + const { count } = await db.apiKey.deleteMany({ + where: { id: input.id, userId: ctx.session.user.id }, + }); + + if (0 === count) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); + } + + return { success: true }; + }), +}; + +export const userRouter = createTRPCRouter(userMethods); + +/** + * API-only variant of `getOwnExpenses` with offset pagination, mounted in the + * public API router instead of the app router. + */ +export const getOwnExpensesApiProcedure = protectedProcedure + .input(paginationInput.default({})) + .query(async ({ input, ctx }) => { + const where = { paidBy: ctx.session.user.id, deletedBy: null }; + + const [items, total] = await Promise.all([ + db.expense.findMany({ + where, + // Tie-break with `id`: `expenseDate` is not unique and Postgres may return + // Tied rows in an arbitrary order between queries, breaking pagination + // For clients walking pages. + orderBy: [{ expenseDate: 'desc' }, { id: 'desc' }], + include: { group: true }, + take: input.limit, + skip: input.offset, + }), + db.expense.count({ where }), + ]); + + return paginatedResult(items, total, input); + }); export const getUserMap = async (userIds: number[]) => { const users = await db.user.findMany({ diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 907849d7b..e84bb3a00 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -7,7 +7,7 @@ * need to use are documented accordingly near the end. */ -import { TRPCError, initTRPC } from '@trpc/server'; +import { type TRPCDefaultErrorShape, TRPCError, initTRPC } from '@trpc/server'; import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; import { type PrismaClient } from '@prisma/client'; import { type Session } from 'next-auth'; @@ -73,18 +73,29 @@ export type InnerTRPCContext = ReturnType; * errors on the backend. */ +/** + * Shared error formatter, used by both the app tRPC instance below and the + * public API instance in `~/server/api/apiTrpc`, so error shapes stay + * identical across `/api/trpc` and `/api/v1`. + */ +export const trpcErrorFormatter = ({ + shape, + error, +}: { + shape: TRPCDefaultErrorShape; + error: TRPCError; +}) => ({ + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + appErrorCode: getAppErrorCode(error.cause), + }, +}); + const t = initTRPC.context().create({ transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - appErrorCode: getAppErrorCode(error.cause), - }, - }; - }, + errorFormatter: trpcErrorFormatter, }); /** diff --git a/src/tests/apiKey.test.ts b/src/tests/apiKey.test.ts new file mode 100644 index 000000000..ea8315438 --- /dev/null +++ b/src/tests/apiKey.test.ts @@ -0,0 +1,185 @@ +import { type PrismaClient, type User } from '@prisma/client'; + +import { + API_KEY_PREFIX, + generateApiKey, + hashApiKey, + isApiKeyExpired, + parseApiKeyFromHeaders, + resolveApiKeyUser, + toSessionUser, +} from '../server/api/apiKey'; + +describe('hashApiKey', () => { + it('is deterministic and returns a 64-char sha256 hex digest', () => { + const a = hashApiKey('spro_test'); + const b = hashApiKey('spro_test'); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + }); + + it('produces different digests for different keys', () => { + expect(hashApiKey('spro_a')).not.toBe(hashApiKey('spro_b')); + }); +}); + +describe('generateApiKey', () => { + it('mints a prefixed key whose hash and partial match the plaintext', () => { + const { key, hashedKey, partialKey } = generateApiKey(); + expect(key.startsWith(API_KEY_PREFIX)).toBe(true); + expect(hashedKey).toBe(hashApiKey(key)); + expect(key.startsWith(partialKey)).toBe(true); + expect(partialKey).toHaveLength(API_KEY_PREFIX.length + 6); + }); + + it('does not store the plaintext in the hash', () => { + const { key, hashedKey } = generateApiKey(); + expect(hashedKey).not.toContain(key); + }); + + it('mints unique keys', () => { + expect(generateApiKey().key).not.toBe(generateApiKey().key); + }); +}); + +describe('parseApiKeyFromHeaders', () => { + it('reads Authorization: Bearer (case-insensitive scheme)', () => { + expect(parseApiKeyFromHeaders({ authorization: 'Bearer spro_abc' })).toBe('spro_abc'); + expect(parseApiKeyFromHeaders({ authorization: 'bearer spro_abc' })).toBe('spro_abc'); + }); + + it('reads X-API-Key', () => { + expect(parseApiKeyFromHeaders({ 'x-api-key': 'spro_xyz' })).toBe('spro_xyz'); + }); + + it('returns null when absent or malformed', () => { + expect(parseApiKeyFromHeaders({})).toBeNull(); + expect(parseApiKeyFromHeaders({ authorization: 'Basic abc' })).toBeNull(); + expect(parseApiKeyFromHeaders({ authorization: 'Bearer ' })).toBeNull(); + }); +}); + +describe('isApiKeyExpired', () => { + const now = new Date('2026-07-15T00:00:00Z'); + + it('treats a null expiry as never expiring', () => { + expect(isApiKeyExpired(null, now)).toBe(false); + }); + + it('is expired at or after the expiry instant', () => { + expect(isApiKeyExpired(new Date('2026-07-14T23:59:59Z'), now)).toBe(true); + expect(isApiKeyExpired(new Date('2026-07-15T00:00:00Z'), now)).toBe(true); + }); + + it('is valid before the expiry instant', () => { + expect(isApiKeyExpired(new Date('2026-07-15T00:00:01Z'), now)).toBe(false); + }); +}); + +const makeUser = (over: Partial = {}): User => ({ + id: 1, + name: 'Kush', + email: 'kush@example.com', + image: null, + currency: 'USD', + defaultCurrency: null, + preferredLanguage: '', + bankingId: null, + obapiProviderId: null, + hiddenFriendIds: [], + emailVerified: null, + ...over, +}); + +const makeDb = (record: unknown) => { + const update = jest.fn().mockResolvedValue({}); + const findUnique = jest.fn().mockResolvedValue(record); + return { + db: { apiKey: { findUnique, update } } as unknown as PrismaClient, + findUnique, + update, + }; +}; + +describe('resolveApiKeyUser', () => { + const now = new Date('2026-07-15T12:00:00Z'); + + it('returns null for a missing raw key without touching the db', async () => { + const { db, findUnique } = makeDb(null); + expect(await resolveApiKeyUser(db, null, now)).toBeNull(); + expect(findUnique).not.toHaveBeenCalled(); + }); + + it('returns null for an unknown key', async () => { + const { db } = makeDb(null); + expect(await resolveApiKeyUser(db, 'spro_unknown', now)).toBeNull(); + }); + + it('returns null for an expired key', async () => { + const { db } = makeDb({ + id: 'k1', + expiresAt: new Date('2026-07-15T11:59:59Z'), + lastUsedAt: null, + user: makeUser(), + }); + expect(await resolveApiKeyUser(db, 'spro_valid', now)).toBeNull(); + }); + + it('returns the user and expiry, and refreshes stale lastUsedAt for a valid key', async () => { + const expiresAt = new Date('2026-08-01T00:00:00Z'); + const { db, update } = makeDb({ + id: 'k1', + expiresAt, + lastUsedAt: null, + user: makeUser({ id: 42 }), + }); + const resolved = await resolveApiKeyUser(db, 'spro_valid', now); + expect(resolved?.user.id).toBe(42); + expect(resolved?.expiresAt).toBe(expiresAt); + expect(update).toHaveBeenCalledWith({ where: { id: 'k1' }, data: { lastUsedAt: now } }); + }); + + it('returns a null expiry for a never-expiring key', async () => { + const { db, update } = makeDb({ + id: 'k1', + expiresAt: null, + lastUsedAt: null, + user: makeUser({ id: 7 }), + }); + const resolved = await resolveApiKeyUser(db, 'spro_valid', now); + expect(resolved?.user.id).toBe(7); + expect(resolved?.expiresAt).toBeNull(); + expect(update).toHaveBeenCalledWith({ where: { id: 'k1' }, data: { lastUsedAt: now } }); + }); + + it('does not refresh lastUsedAt when recently used', async () => { + const { db, update } = makeDb({ + id: 'k1', + expiresAt: null, + lastUsedAt: new Date('2026-07-15T11:59:30Z'), // 30s ago < throttle window + user: makeUser(), + }); + await resolveApiKeyUser(db, 'spro_valid', now); + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe('toSessionUser', () => { + it('maps a db user to the session user shape with null fallbacks', () => { + const session = toSessionUser( + makeUser({ name: null, email: null, image: null, obapiProviderId: null, bankingId: null }), + ); + expect(session).toMatchObject({ + id: 1, + name: '', + email: '', + image: '', + currency: 'USD', + defaultCurrency: null, + obapiProviderId: undefined, + bankingId: undefined, + preferredLanguage: '', + hiddenFriendIds: [], + }); + }); +}); diff --git a/src/tests/apiTransformer.test.ts b/src/tests/apiTransformer.test.ts new file mode 100644 index 000000000..3cc420afd --- /dev/null +++ b/src/tests/apiTransformer.test.ts @@ -0,0 +1,126 @@ +import { apiTransformer } from '~/server/api/apiTransformer'; +import { paginatedResult, paginationInput } from '~/server/api/pagination'; +import { coerceQueryParam } from '~/utils/queryParams'; + +describe('apiTransformer', () => { + describe('input.deserialize', () => { + it('passes plain JSON objects through unchanged', () => { + expect(apiTransformer.input.deserialize({ groupId: 150 })).toEqual({ groupId: 150 }); + }); + + it('passes plain JSON arrays through unchanged', () => { + expect(apiTransformer.input.deserialize([{ json: 1 }])).toEqual([{ json: 1 }]); + }); + + it('passes primitives and null through unchanged', () => { + expect(apiTransformer.input.deserialize(null)).toBeNull(); + expect(apiTransformer.input.deserialize('{"a":1}')).toBe('{"a":1}'); + expect(apiTransformer.input.deserialize(42)).toBe(42); + }); + + it('unwraps superjson envelopes, reviving special types', () => { + const envelope = { json: { amount: '1000' }, meta: { values: { amount: ['bigint'] } } }; + expect(apiTransformer.input.deserialize(envelope)).toEqual({ amount: 1000n }); + }); + + it('does not treat objects with extra keys as envelopes', () => { + const plain = { json: 1, meta: 2, other: 3 }; + expect(apiTransformer.input.deserialize(plain)).toBe(plain); + }); + }); + + describe('output.serialize', () => { + it('converts BigInt to number and Date to ISO string', () => { + const at = new Date('2026-01-01T12:00:00.000Z'); + expect(apiTransformer.output.serialize({ amount: 1234n, at, name: 'lunch' })).toEqual({ + amount: 1234, + at: '2026-01-01T12:00:00.000Z', + name: 'lunch', + }); + }); + + it('walks nested objects and arrays', () => { + const at = new Date('2026-01-01T12:00:00.000Z'); + expect( + apiTransformer.output.serialize({ + items: [{ amount: -5n, nested: { at } }], + empty: null, + }), + ).toEqual({ + items: [{ amount: -5, nested: { at: '2026-01-01T12:00:00.000Z' } }], + empty: null, + }); + }); + + it('is JSON.stringify-safe for monetary payloads', () => { + const serialized = apiTransformer.output.serialize({ total: 9999999999n }); + expect(() => JSON.stringify(serialized)).not.toThrow(); + expect(serialized).toEqual({ total: 9999999999 }); + }); + + it('returns BigInt beyond MAX_SAFE_INTEGER as a string to preserve precision', () => { + const unsafe = 9007199254740993n; // Number.MAX_SAFE_INTEGER + 2 + expect(apiTransformer.output.serialize({ total: unsafe })).toEqual({ + total: '9007199254740993', + }); + expect(typeof apiTransformer.output.serialize({ total: unsafe }).total).toBe('string'); + }); + }); +}); + +describe('coerceQueryParam', () => { + it('coerces booleans', () => { + expect(coerceQueryParam('true')).toBe(true); + expect(coerceQueryParam('false')).toBe(false); + }); + + it('coerces canonical number strings', () => { + expect(coerceQueryParam('150')).toBe(150); + expect(coerceQueryParam('-3')).toBe(-3); + expect(coerceQueryParam('1.5')).toBe(1.5); + }); + + it('leaves non-canonical values as strings', () => { + expect(coerceQueryParam('01')).toBe('01'); + expect(coerceQueryParam('1e3')).toBe('1e3'); + expect(coerceQueryParam('true-ish')).toBe('true-ish'); + expect(coerceQueryParam('')).toBe(''); + }); + + it('turns numeric-looking and boolean-looking query strings into their JSON types, which can fail zod string schemas', () => { + // ?name=123 becomes name: 123 (number), which would fail a z.string() schema. + expect(coerceQueryParam('123')).toBe(123); + expect(typeof coerceQueryParam('123')).toBe('number'); + // ?name=true becomes name: true (boolean), which would fail a z.string() schema. + expect(coerceQueryParam('true')).toBe(true); + expect(typeof coerceQueryParam('true')).toBe('boolean'); + }); +}); + +describe('paginationInput', () => { + it('applies defaults for missing values', () => { + expect(paginationInput.parse({})).toEqual({ limit: 20, offset: 0 }); + expect(paginationInput.default({}).parse(undefined)).toEqual({ limit: 20, offset: 0 }); + }); + + it('rejects fractional and out-of-range values', () => { + expect(paginationInput.safeParse({ limit: 1.5 }).success).toBe(false); + expect(paginationInput.safeParse({ limit: 0 }).success).toBe(false); + expect(paginationInput.safeParse({ limit: 101 }).success).toBe(false); + expect(paginationInput.safeParse({ offset: -1 }).success).toBe(false); + }); +}); + +describe('paginatedResult', () => { + it('wraps items with pagination metadata', () => { + const page = [1, 2, 3]; + expect(paginatedResult(page, 10, { limit: 3, offset: 0 })).toEqual({ + items: page, + pagination: { total: 10, limit: 3, offset: 0, hasMore: true }, + }); + }); + + it('marks the last page', () => { + expect(paginatedResult([9, 10], 10, { limit: 5, offset: 8 }).pagination.hasMore).toBe(false); + }); +}); diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts index 2992459c5..23d22429d 100644 --- a/src/utils/queryParams.ts +++ b/src/utils/queryParams.ts @@ -1 +1,22 @@ export const isStringQueryParam = (value: unknown): value is string => 'string' === typeof value; + +/** + * Coerce a query-param string to a plain JSON value: booleans and canonical + * number strings become their JSON types, everything else stays a string. + * Used by the public API handler to bridge individual query params into tRPC's + * JSON `?input=` format. + */ +export const coerceQueryParam = (value: string): string | number | boolean => { + if ('true' === value) { + return true; + } + if ('false' === value) { + return false; + } + const n = Number(value); + if (!Number.isNaN(n) && String(n) === value) { + return n; + } + + return value; +};