From 9a1cebfa051b3b19a72f05211e961e4f19ccf2e5 Mon Sep 17 00:00:00 2001 From: kvora32 Date: Wed, 15 Jul 2026 13:35:49 +0100 Subject: [PATCH 01/15] feat: add OpenAPI REST API with API-key auth (user.me) Adds an isolated public REST surface generated by @trpc/openapi (alpha): - apiRouter (curated subset) served at /api/v1 with API-key auth via createApiContext - ApiKey model (sha256-hashed, spro_ prefix) + create/list/revoke procedures + Account UI - OpenAPI spec at /api/openapi.json and Scalar docs at /api/docs - first exposed endpoint: user.me Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 4 +- pnpm-lock.yaml | 30 ++ .../20260715120000_add_api_key/migration.sql | 22 ++ prisma/schema.prisma | 16 ++ scripts/generate-openapi.ts | 50 ++++ src/components/Account/ApiKeys.tsx | 144 ++++++++++ src/pages/account.tsx | 9 + src/pages/api/docs.ts | 23 ++ src/pages/api/openapi.json.ts | 14 + src/pages/api/v1/[trpc].ts | 21 ++ src/server/api/apiContext.ts | 25 ++ src/server/api/apiKey.ts | 125 +++++++++ src/server/api/apiRouter.ts | 19 ++ src/server/api/openapi.generated.json | 258 ++++++++++++++++++ src/server/api/routers/user.ts | 68 ++++- src/tests/apiKey.test.ts | 171 ++++++++++++ 16 files changed, 997 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260715120000_add_api_key/migration.sql create mode 100644 scripts/generate-openapi.ts create mode 100644 src/components/Account/ApiKeys.tsx create mode 100644 src/pages/api/docs.ts create mode 100644 src/pages/api/openapi.json.ts create mode 100644 src/pages/api/v1/[trpc].ts create mode 100644 src/server/api/apiContext.ts create mode 100644 src/server/api/apiKey.ts create mode 100644 src/server/api/apiRouter.ts create mode 100644 src/server/api/openapi.generated.json create mode 100644 src/tests/apiKey.test.ts diff --git a/package.json b/package.json index 8ef2a1e1..6e9753d1 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "next build --no-lint", + "build": "pnpm gen:openapi && next build --no-lint", "just-build": "next build --no-lint", + "gen:openapi": "tsx scripts/generate-openapi.ts", "db:push": "prisma db push", "db:studio": "prisma studio", "db:dev": "prisma migrate dev", @@ -37,6 +38,7 @@ "@tanstack/react-query": "^5.79.2", "@trpc/client": "^11.2.0", "@trpc/next": "^11.2.0", + "@trpc/openapi": "11.18.0-alpha", "@trpc/react-query": "^11.2.0", "@trpc/server": "^11.2.0", "boring-avatars": "^1.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8692877..e464f4fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@trpc/next': specifier: ^11.2.0 version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.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.2.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.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.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.2.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.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) @@ -2157,6 +2160,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: @@ -3636,6 +3653,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==} @@ -6261,6 +6281,14 @@ snapshots: '@tanstack/react-query': 5.90.12(react@19.2.1) '@trpc/react-query': 11.8.0(@tanstack/react-query@5.90.12(react@19.2.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.2.1(react@19.2.1))(react@19.2.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.2.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.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': dependencies: '@tanstack/react-query': 5.90.12(react@19.2.1) @@ -7879,6 +7907,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 00000000..97afc00f --- /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 576ece26..ab035f22 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,11 +64,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/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 00000000..5b342c1e --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,50 @@ +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { generateOpenAPIDocument } from '@trpc/openapi'; + +// Static spec generation needs only router types; env validation is disabled here so build/CI can run without app secrets. +process.env.SKIP_ENV_VALIDATION ||= 'true'; + +/** + * Generates the OpenAPI spec for the public REST 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/emitted + * to `openapi.generated.json` and served at `/api/openapi.json`. + * + * Run with: `pnpm gen:openapi` + */ + +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'); + +const baseUrl = process.env.NEXTAUTH_URL ?? 'http://localhost:3000'; + +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: `${baseUrl}/api/v1` }], + }); + + // The generator infers paths/schemas but not auth, so we declare bearer + // API-key auth globally (also drives Scalar's "Authorize" button). + doc.components = doc.components ?? {}; + doc.components.securitySchemes = { + ...doc.components.securitySchemes, + bearerAuth: { type: 'http', scheme: 'bearer', description: 'SplitPro API key (spro_…)' }, + }; + doc.security = [{ bearerAuth: [] }]; + + await writeFile(outputPath, `${JSON.stringify(doc, null, 2)}\n`); + 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 00000000..67ef77e5 --- /dev/null +++ b/src/components/Account/ApiKeys.tsx @@ -0,0 +1,144 @@ +import { Copy, Trash2 } from 'lucide-react'; +import React, { useCallback, useState } from 'react'; +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. + */ +export const ApiKeys: React.FC = ({ children }) => { + const [name, setName] = useState(''); + const [createdKey, setCreatedKey] = useState(null); + + 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('Please enter a name for the key'); + return; + } + + try { + const result = await createMutation.mutateAsync({ name: trimmed }); + setCreatedKey(result.key); + setName(''); + await utils.user.listApiKeys.invalidate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create API key'); + } + }, [name, createMutation, utils.user.listApiKeys]); + + 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 : 'Failed to revoke API key'); + } + }, + [revokeMutation, utils.user.listApiKeys], + ); + + const copyKey = useCallback(async (value: string) => { + try { + await navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + } catch { + toast.error('Failed to copy'); + } + }, []); + + return ( + setCreatedKey(null)} + className="h-[85vh]" + > +
+

+ Use API keys to access the SplitPro API. Send it as{' '} + Authorization: Bearer <key>. Docs are at{' '} + + /api/docs + + . +

+ + {createdKey ? ( +
+

+ Copy your key now — it won't be shown again. +

+
+ + {createdKey} + + +
+
+ ) : null} + +
+
+ + setName(e.target.value)} + /> +
+ +
+ +
+ {keysQuery.data?.length ? ( + keysQuery.data.map((key) => ( +
+
+ {key.name} + + {key.partialKey}… · {key.lastUsedAt ? `used ${key.lastUsedAt.toLocaleDateString()}` : 'never used'} + +
+ +
+ )) + ) : ( +

No API keys yet.

+ )} +
+
+
+ ); +}; diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 3f37ad26..7e9ba108 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<{ @@ -172,6 +174,13 @@ const AccountPage: NextPageWithUser<{ {t('account.import_from_splitwise')} + + + + API keys + + + diff --git a/src/pages/api/docs.ts b/src/pages/api/docs.ts new file mode 100644 index 00000000..31b7bc44 --- /dev/null +++ b/src/pages/api/docs.ts @@ -0,0 +1,23 @@ +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`. + */ +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 00000000..8781a685 --- /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 00000000..74160323 --- /dev/null +++ b/src/pages/api/v1/[trpc].ts @@ -0,0 +1,21 @@ +import { createNextApiHandler } from '@trpc/server/adapters/next'; + +import { env } from '~/env'; +import { apiRouter } from '~/server/api/apiRouter'; +import { createApiContext } from '~/server/api/apiContext'; + +/** + * Public REST API handler (API-key auth), mounted at `/api/v1/*`, e.g. + * `GET /api/v1/user.me`. Kept separate from the app's cookie-authed + * `/api/trpc` handler so API keys can only reach the curated `apiRouter`. + */ +export default createNextApiHandler({ + router: apiRouter, + createContext: createApiContext, + onError: + 'development' === env.NODE_ENV + ? ({ path, error }) => { + console.error(`❌ API failed on ${path ?? ''}: ${error.message}`); + } + : undefined, +}); diff --git a/src/server/api/apiContext.ts b/src/server/api/apiContext.ts new file mode 100644 index 00000000..046518d9 --- /dev/null +++ b/src/server/api/apiContext.ts @@ -0,0 +1,25 @@ +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'; + +/** + * 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. + */ +export const createApiContext = async (opts: CreateNextContextOptions) => { + const rawKey = parseApiKeyFromHeaders(opts.req.headers); + const user = await resolveApiKeyUser(db, rawKey); + + const session: Session | null = user + ? { + user: toSessionUser(user), + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + } + : null; + + return { session, db }; +}; diff --git a/src/server/api/apiKey.ts b/src/server/api/apiKey.ts new file mode 100644 index 00000000..db35950e --- /dev/null +++ b/src/server/api/apiKey.ts @@ -0,0 +1,125 @@ +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, or 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 { + 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 record.user; +} + +/** + * 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 00000000..a8ee3fde --- /dev/null +++ b/src/server/api/apiRouter.ts @@ -0,0 +1,19 @@ +import { createTRPCRouter } from '~/server/api/trpc'; + +import { meProcedure } from './routers/user'; + +/** + * Public REST API surface, served at `/api/v1` with API-key auth and documented + * by the generated OpenAPI spec. + * + * This is intentionally a *curated subset* of `appRouter`: `@trpc/openapi` has + * no per-procedure filter, so exposure is controlled structurally by only + * mounting procedures here. Add new public endpoints one at a time. + */ +export const apiRouter = createTRPCRouter({ + user: createTRPCRouter({ + me: meProcedure, + }), +}); + +export type ApiRouter = typeof apiRouter; diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json new file mode 100644 index 00000000..beb4b1cd --- /dev/null +++ b/src/server/api/openapi.generated.json @@ -0,0 +1,258 @@ +{ + "openapi": "3.1.1", + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", + "info": { + "title": "SplitPro API", + "version": "0.1.0" + }, + "servers": [ + { + "url": "http://localhost:3000/api/v1" + } + ], + "paths": { + "/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" + } + } + } + } + }, + "components": { + "schemas": { + "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" + } + ] + }, + "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", + "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/routers/user.ts b/src/server/api/routers/user.ts index 1b7e9a87..01aac582 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -9,6 +9,7 @@ import { toSortedFriendPair, } from '~/lib/defaultSplit'; import { simplifyDebts } from '~/lib/simplify'; +import { generateApiKey } from '~/server/api/apiKey'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; @@ -25,8 +26,16 @@ import { importUserBalanceFromSplitWise, } from '../services/splitService'; +const MAX_API_KEYS_PER_USER = 10; + +/** + * Returns the authenticated user. Exported standalone so it can be shared + * between `userRouter` (cookie auth) and the public `apiRouter` (API-key auth). + */ +export const meProcedure = protectedProcedure.query(({ ctx }) => ctx.session.user); + export const userRouter = createTRPCRouter({ - me: protectedProcedure.query(({ ctx }) => ctx.session.user), + me: meProcedure, getFriends: protectedProcedure.query(async ({ ctx }) => { const friends = await db.balanceView.findMany({ @@ -419,6 +428,63 @@ export const userRouter = createTRPCRouter({ }), getWebPushPublicKey: protectedProcedure.query(() => env.WEB_PUSH_PUBLIC_KEY ?? ''), + + listApiKeys: protectedProcedure.query(async ({ ctx }) => { + return 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 getUserMap = async (userIds: number[]) => { diff --git a/src/tests/apiKey.test.ts b/src/tests/apiKey.test.ts new file mode 100644 index 00000000..0f8c8294 --- /dev/null +++ b/src/tests/apiKey.test.ts @@ -0,0 +1,171 @@ +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, + }) as User; + +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 refreshes stale lastUsedAt for a valid key', async () => { + const { db, update } = makeDb({ + id: 'k1', + expiresAt: null, + lastUsedAt: null, + user: makeUser({ id: 42 }), + }); + const user = await resolveApiKeyUser(db, 'spro_valid', now); + expect(user?.id).toBe(42); + 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: [], + }); + }); +}); From e32ba0be514c544b6a2806d715925800fa228e37 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:25:57 +0100 Subject: [PATCH 02/15] user.getFriends API --- src/server/api/apiRouter.ts | 3 +- src/server/api/openapi.generated.json | 175 ++++++++++++++++---------- src/server/api/routers/user.ts | 31 ++--- 3 files changed, 128 insertions(+), 81 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index a8ee3fde..a21ff84a 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -1,6 +1,6 @@ import { createTRPCRouter } from '~/server/api/trpc'; -import { meProcedure } from './routers/user'; +import { getFriendsProcedure, meProcedure } from './routers/user'; /** * Public REST API surface, served at `/api/v1` with API-key auth and documented @@ -13,6 +13,7 @@ import { meProcedure } from './routers/user'; export const apiRouter = createTRPCRouter({ user: createTRPCRouter({ me: meProcedure, + getFriends: getFriendsProcedure, }), }); diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index beb4b1cd..277c9242 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -14,9 +14,7 @@ "/user.me": { "get": { "operationId": "user.me", - "tags": [ - "user" - ], + "tags": ["user"], "responses": { "200": { "description": "Successful response", @@ -32,22 +30,13 @@ "type": "object", "properties": { "name": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "email": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "image": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "id": { "type": "number" @@ -56,10 +45,7 @@ "type": "string" }, "defaultCurrency": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "obapiProviderId": { "type": "string" @@ -77,23 +63,110 @@ } } }, - "required": [ - "id", - "currency", - "preferredLanguage", - "hiddenFriendIds" - ], + "required": ["id", "currency", "preferredLanguage", "hiddenFriendIds"], "additionalProperties": false } }, - "required": [ - "data" - ] + "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" - ] + "required": ["result"] } } } @@ -126,10 +199,7 @@ } } }, - "required": [ - "formErrors", - "fieldErrors" - ], + "required": ["formErrors", "fieldErrors"], "additionalProperties": false } }, @@ -193,11 +263,7 @@ "type": "string" } }, - "required": [ - "zodError", - "code", - "httpStatus" - ], + "required": ["zodError", "code", "httpStatus"], "additionalProperties": false }, "message": { @@ -206,37 +272,16 @@ "code": { "type": "number", "enum": [ - -32700, - -32600, - -32603, - -32001, - -32002, - -32003, - -32004, - -32005, - -32008, - -32009, - -32012, - -32013, - -32015, - -32022, - -32028, - -32029, - -32099 + -32700, -32600, -32603, -32001, -32002, -32003, -32004, -32005, -32008, + -32009, -32012, -32013, -32015, -32022, -32028, -32029, -32099 ] } }, - "required": [ - "data", - "message", - "code" - ], + "required": ["data", "message", "code"], "additionalProperties": false } }, - "required": [ - "error" - ] + "required": ["error"] } } } diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 01aac582..f1c499f3 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -34,18 +34,19 @@ const MAX_API_KEYS_PER_USER = 10; */ export const meProcedure = protectedProcedure.query(({ ctx }) => ctx.session.user); -export const userRouter = createTRPCRouter({ - me: meProcedure, +export const getFriendsProcedure = protectedProcedure.query(async ({ ctx }) => { + const friends = await db.balanceView.findMany({ + where: { userId: ctx.session.user.id, friendId: { notIn: ctx.session.user.hiddenFriendIds } }, + include: { friend: true }, + distinct: ['friendId'], + }); - getFriends: protectedProcedure.query(async ({ ctx }) => { - const friends = await db.balanceView.findMany({ - where: { userId: ctx.session.user.id, friendId: { notIn: ctx.session.user.hiddenFriendIds } }, - include: { friend: true }, - distinct: ['friendId'], - }); + return friends.map((f) => f.friend); +}); - return friends.map((f) => f.friend); - }), +export const userRouter = createTRPCRouter({ + me: meProcedure, + getFriends: getFriendsProcedure, getOwnExpenses: protectedProcedure.query(async ({ ctx }) => { const expenses = await db.expense.findMany({ @@ -429,8 +430,7 @@ export const userRouter = createTRPCRouter({ getWebPushPublicKey: protectedProcedure.query(() => env.WEB_PUSH_PUBLIC_KEY ?? ''), - listApiKeys: protectedProcedure.query(async ({ ctx }) => { - return db.apiKey.findMany({ + listApiKeys: protectedProcedure.query(async ({ ctx }) => db.apiKey.findMany({ where: { userId: ctx.session.user.id }, select: { id: true, @@ -441,11 +441,12 @@ export const userRouter = createTRPCRouter({ createdAt: true, }, orderBy: { createdAt: 'desc' }, - }); - }), + })), createApiKey: protectedProcedure - .input(z.object({ name: z.string().min(1).max(100), expiresAt: z.date().nullable().optional() })) + .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) { From f326a138b944644e1c412d46fa523b1b152b6f90 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:31:27 +0100 Subject: [PATCH 03/15] group.getAllGroups API --- src/server/api/apiRouter.ts | 4 + src/server/api/openapi.generated.json | 188 ++++++++++++++++++++++++++ src/server/api/routers/group.ts | 42 +++--- 3 files changed, 214 insertions(+), 20 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index a21ff84a..6d7aa704 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -1,5 +1,6 @@ import { createTRPCRouter } from '~/server/api/trpc'; +import { getAllGroupsProcedure } from './routers/group'; import { getFriendsProcedure, meProcedure } from './routers/user'; /** @@ -15,6 +16,9 @@ export const apiRouter = createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, }), + group: createTRPCRouter({ + getAllGroups: getAllGroupsProcedure, + }), }); export type ApiRouter = typeof apiRouter; diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index 277c9242..fb3d5ced 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -176,6 +176,194 @@ } } } + }, + "/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": { + "type": "object", + "properties": { + "group": { + "type": "object", + "properties": { + "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": [ + "groupUsers", + "name", + "id", + "userId", + "image", + "defaultCurrency", + "createdAt", + "updatedAt", + "publicId", + "splitwiseGroupId", + "simplifyDebts", + "archivedAt" + ], + "additionalProperties": false + }, + "userId": { + "type": "number" + }, + "groupId": { + "type": "number" + } + }, + "required": ["group", "userId", "groupId"], + "additionalProperties": false + } + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } } }, "components": { diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index 22b3495a..0b4fb77c 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -13,6 +13,27 @@ import { serializeDefaultSplit, } from '~/lib/defaultSplit'; +export const getAllGroupsProcedure = protectedProcedure.query(async ({ ctx }) => { + const groups = await ctx.db.groupUser.findMany({ + where: { + userId: ctx.session.user.id, + }, + include: { + group: { + include: { + groupUsers: { + include: { + user: true, + }, + }, + }, + }, + }, + }); + + return groups; +}); + export const groupRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ name: z.string().min(1) })) @@ -33,26 +54,7 @@ export const groupRouter = createTRPCRouter({ return group; }), - getAllGroups: protectedProcedure.query(async ({ ctx }) => { - const groups = await ctx.db.groupUser.findMany({ - where: { - userId: ctx.session.user.id, - }, - include: { - group: { - include: { - groupUsers: { - include: { - user: true, - }, - }, - }, - }, - }, - }); - - return groups; - }), + getAllGroups: getAllGroupsProcedure, getAllGroupsWithBalances: protectedProcedure .input(z.object({ getArchived: z.boolean() }).optional()) From 62b7173712b7b251edd07430af05bd7992a83772 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:02:50 +0100 Subject: [PATCH 04/15] group.getGroupDetails API and fix inputs for superjson --- src/pages/api/v1/[trpc].ts | 39 +- src/server/api/apiRouter.ts | 3 +- src/server/api/openapi.generated.json | 530 ++++++++++++++++++++++++++ src/server/api/routers/group.ts | 71 ++-- 4 files changed, 600 insertions(+), 43 deletions(-) diff --git a/src/pages/api/v1/[trpc].ts b/src/pages/api/v1/[trpc].ts index 74160323..85a54aed 100644 --- a/src/pages/api/v1/[trpc].ts +++ b/src/pages/api/v1/[trpc].ts @@ -1,15 +1,11 @@ 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'; -/** - * Public REST API handler (API-key auth), mounted at `/api/v1/*`, e.g. - * `GET /api/v1/user.me`. Kept separate from the app's cookie-authed - * `/api/trpc` handler so API keys can only reach the curated `apiRouter`. - */ -export default createNextApiHandler({ +const handler = createNextApiHandler({ router: apiRouter, createContext: createApiContext, onError: @@ -19,3 +15,34 @@ export default createNextApiHandler({ } : undefined, }); + +/** + * Public REST API handler (API-key auth), mounted at `/api/v1/*`, e.g. + * `GET /api/v1/user.me`. Kept separate from the app's cookie-authed + * `/api/trpc` handler so API keys can only reach the curated `apiRouter`. + * + * The wrapper re-encodes plain-JSON `?input=` payloads as superjson format + * ({json: …}) so the server transformer can deserialise them correctly. + * API consumers send regular JSON; the wrapper bridges the two worlds. + */ +export default async (req: NextApiRequest, res: NextApiResponse) => { + if ('GET' === req.method && req.url) { + const url = new URL(req.url, 'http://localhost'); + const rawInput = url.searchParams.get('input'); + + if (rawInput) { + try { + const parsed = JSON.parse(rawInput); + + if (!('json' in parsed)) { + url.searchParams.set('input', JSON.stringify({ json: parsed })); + req.url = url.pathname + url.search; + } + } catch { + /* Leave malformed input alone — tRPC will surface the parse error */ + } + } + } + + await handler(req, res); +}; diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index 6d7aa704..70673319 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -1,6 +1,6 @@ import { createTRPCRouter } from '~/server/api/trpc'; -import { getAllGroupsProcedure } from './routers/group'; +import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; import { getFriendsProcedure, meProcedure } from './routers/user'; /** @@ -18,6 +18,7 @@ export const apiRouter = createTRPCRouter({ }), group: createTRPCRouter({ getAllGroups: getAllGroupsProcedure, + getGroupDetails: getGroupDetailsProcedure, }), }); diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index fb3d5ced..57d7259c 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -364,10 +364,540 @@ } } } + }, + "/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 + } + } + } + } + ] + } } }, "components": { "schemas": { + "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 + }, "typeToFlattenedError": { "type": "object", "properties": { diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index 0b4fb77c..362080f5 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -34,6 +34,40 @@ export const getAllGroupsProcedure = protectedProcedure.query(async ({ ctx }) => return groups; }); +export const getGroupDetailsProcedure = groupProcedure.query(async ({ input, ctx }) => { + const group = await ctx.db.group.findUnique({ + where: { + id: input.groupId, + }, + include: { + groupUsers: { + include: { + user: true, + }, + }, + groupBalances: true, + groupDefaultSplit: true, + }, + }); + + if (!group) { + return group; + } + + if (group.simplifyDebts) { + group.groupBalances = simplifyDebts(group.groupBalances); + } + + const defaultSplit = + group.groupDefaultSplit && + parseSerializedDefaultSplit(group.groupDefaultSplit.splitType, group.groupDefaultSplit.shares); + + return { + ...group, + defaultSplit, + }; +}); + export const groupRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ name: z.string().min(1) })) @@ -131,42 +165,7 @@ export const groupRouter = createTRPCRouter({ return group; }), - getGroupDetails: groupProcedure.query(async ({ input, ctx }) => { - const group = await ctx.db.group.findUnique({ - where: { - id: input.groupId, - }, - include: { - groupUsers: { - include: { - user: true, - }, - }, - groupBalances: true, - groupDefaultSplit: true, - }, - }); - - if (!group) { - return group; - } - - if (group.simplifyDebts) { - group.groupBalances = simplifyDebts(group.groupBalances); - } - - const defaultSplit = - group.groupDefaultSplit && - parseSerializedDefaultSplit( - group.groupDefaultSplit.splitType, - group.groupDefaultSplit.shares, - ); - - return { - ...group, - defaultSplit, - }; - }), + getGroupDetails: getGroupDetailsProcedure, getGroupTotals: groupProcedure.query(async ({ input, ctx }) => { const totals = await ctx.db.expense.groupBy({ From cff69d79dfbc46afbfcaeb2a1b8641b2b7475534 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:23:40 +0100 Subject: [PATCH 05/15] allow individual params, plain json or native superjson as inputs --- src/pages/api/v1/[trpc].ts | 41 +++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/pages/api/v1/[trpc].ts b/src/pages/api/v1/[trpc].ts index 85a54aed..c03e6e67 100644 --- a/src/pages/api/v1/[trpc].ts +++ b/src/pages/api/v1/[trpc].ts @@ -16,14 +16,30 @@ const handler = createNextApiHandler({ : undefined, }); +const isPlainValue = (v: string): string | number | boolean => { + if ('true' === v) { + return true; + } + if ('false' === v) { + return false; + } + const n = Number(v); + if (!Number.isNaN(n) && String(n) === v) { + return n; + } + + return v; +}; + /** * Public REST API handler (API-key auth), mounted at `/api/v1/*`, e.g. - * `GET /api/v1/user.me`. Kept separate from the app's cookie-authed - * `/api/trpc` handler so API keys can only reach the curated `apiRouter`. + * `GET /api/v1/user.me`. The wrapper: * - * The wrapper re-encodes plain-JSON `?input=` payloads as superjson format - * ({json: …}) so the server transformer can deserialise them correctly. - * API consumers send regular JSON; the wrapper bridges the two worlds. + * 1. Converts individual query params (`?groupId=150`) into tRPC's + * `?input={"groupId":150}` format, coercing values to numbers/booleans + * where obvious. + * 2. Wraps plain-JSON `?input=` payloads as superjson format ({json: …}) so + * the server transformer (superjson) can deserialise them correctly. */ export default async (req: NextApiRequest, res: NextApiResponse) => { if ('GET' === req.method && req.url) { @@ -31,6 +47,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { const rawInput = url.searchParams.get('input'); if (rawInput) { + /* — `?input=` — wrap in superjson format if needed */ try { const parsed = JSON.parse(rawInput); @@ -41,6 +58,20 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { } catch { /* Leave malformed input alone — tRPC will surface the parse error */ } + } else { + /* — individual query params — build an `input` JSON object */ + const params: Record = {}; + + url.searchParams.forEach((value, key) => { + if ('batch' !== key && 'trpc' !== key) { + params[key] = isPlainValue(value); + } + }); + + if (0 < Object.keys(params).length) { + url.searchParams.set('input', JSON.stringify({ json: params })); + req.url = url.pathname + url.search; + } } } From 32ea7319befc0a750934754782ba30478bd4b00f Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:29:19 +0100 Subject: [PATCH 06/15] expense.getExpenseDetails API --- src/server/api/apiRouter.ts | 4 + src/server/api/openapi.generated.json | 856 ++++++++++++++++++++++++++ src/server/api/routers/expense.ts | 152 ++--- 3 files changed, 937 insertions(+), 75 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index 70673319..80e8f958 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -1,5 +1,6 @@ import { createTRPCRouter } from '~/server/api/trpc'; +import { getExpenseDetailsProcedure } from './routers/expense'; import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; import { getFriendsProcedure, meProcedure } from './routers/user'; @@ -20,6 +21,9 @@ export const apiRouter = createTRPCRouter({ getAllGroups: getAllGroupsProcedure, getGroupDetails: getGroupDetailsProcedure, }), + expense: createTRPCRouter({ + getExpenseDetails: getExpenseDetailsProcedure, + }), }); export type ApiRouter = typeof apiRouter; diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index 57d7259c..fbdc7241 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -688,6 +688,862 @@ } ] } + }, + "/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": { diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index afc6f354..ddc1cd8e 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -21,6 +21,82 @@ import { DEFAULT_CATEGORY } from '~/lib/category'; import { getUserMap } from './user'; import { FriendBalance } from '~/components/Friend/FriendBalance'; +export const getExpenseDetailsProcedure = protectedProcedure + .input(z.object({ expenseId: z.string() })) + .query(async ({ input }) => { + const expense = await db.expense.findUnique({ + where: { + id: input.expenseId, + }, + include: { + expenseParticipants: { + include: { + user: true, + }, + }, + expenseNotes: true, + addedByUser: true, + paidByUser: true, + deletedByUser: true, + updatedByUser: true, + group: true, + recurrence: { + include: { + job: { + select: { + schedule: true, + command: true, + }, + }, + }, + }, + conversionTo: { + include: { + expenseParticipants: { + include: { + user: true, + }, + }, + }, + }, + }, + }); + + if (expense && expense.groupId !== null) { + const missingGroupMembers = await db.group.findUnique({ + where: { + id: expense.groupId, + }, + include: { + groupUsers: { + include: { + user: true, + }, + where: { + userId: { + notIn: expense.expenseParticipants.map((ep) => ep.userId), + }, + }, + }, + }, + }); + missingGroupMembers?.groupUsers.forEach((gu) => { + expense.expenseParticipants.push({ + userId: gu.user.id, + expenseId: expense.id, + user: gu.user, + amount: 0n, + }); + }); + } + + if (expense?.recurrence?.job.schedule) { + expense.recurrence.job.schedule = expense.recurrence.job.schedule.replaceAll('$', 'L'); + } + + return expense; + }); + export const expenseRouter = createTRPCRouter({ getCumulatedBalances: protectedProcedure.query(async ({ ctx }) => { const cumulatedBalances = await db.balanceView.groupBy({ @@ -348,81 +424,7 @@ export const expenseRouter = createTRPCRouter({ return expenses; }), - getExpenseDetails: protectedProcedure - .input(z.object({ expenseId: z.string() })) - .query(async ({ input }) => { - const expense = await db.expense.findUnique({ - where: { - id: input.expenseId, - }, - include: { - expenseParticipants: { - include: { - user: true, - }, - }, - expenseNotes: true, - addedByUser: true, - paidByUser: true, - deletedByUser: true, - updatedByUser: true, - group: true, - recurrence: { - include: { - job: { - select: { - schedule: true, - command: true, - }, - }, - }, - }, - conversionTo: { - include: { - expenseParticipants: { - include: { - user: true, - }, - }, - }, - }, - }, - }); - - if (expense && expense.groupId !== null) { - const missingGroupMembers = await db.group.findUnique({ - where: { - id: expense.groupId, - }, - include: { - groupUsers: { - include: { - user: true, - }, - where: { - userId: { - notIn: expense.expenseParticipants.map((ep) => ep.userId), - }, - }, - }, - }, - }); - missingGroupMembers?.groupUsers.forEach((gu) => { - expense.expenseParticipants.push({ - userId: gu.user.id, - expenseId: expense.id, - user: gu.user, - amount: 0n, - }); - }); - } - - if (expense?.recurrence?.job.schedule) { - expense.recurrence.job.schedule = expense.recurrence.job.schedule.replaceAll('$', 'L'); - } - - return expense; - }), + getExpenseDetails: getExpenseDetailsProcedure, getAllExpenses: protectedProcedure.query(async ({ ctx }) => { const expenses = await db.expenseParticipant.findMany({ From 9edae6deed8c0cee8ed4f5e62ac56ea88a7159e0 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:35:38 +0100 Subject: [PATCH 07/15] expense.getGroupExpenses API --- src/server/api/apiRouter.ts | 3 +- src/server/api/openapi.generated.json | 429 ++++++++++++++++++++++++++ src/server/api/routers/expense.ts | 68 ++-- 3 files changed, 466 insertions(+), 34 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index 80e8f958..60f110a8 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -1,6 +1,6 @@ import { createTRPCRouter } from '~/server/api/trpc'; -import { getExpenseDetailsProcedure } from './routers/expense'; +import { getExpenseDetailsProcedure, getGroupExpensesProcedure } from './routers/expense'; import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; import { getFriendsProcedure, meProcedure } from './routers/user'; @@ -23,6 +23,7 @@ export const apiRouter = createTRPCRouter({ }), expense: createTRPCRouter({ getExpenseDetails: getExpenseDetailsProcedure, + getGroupExpenses: getGroupExpensesProcedure, }), }); diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index fbdc7241..b1e0a5ff 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -1544,6 +1544,435 @@ } ] } + }, + "/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": { + "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 + } + } + }, + "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 + } + } + } + } + ] + } } }, "components": { diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index ddc1cd8e..72230269 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -21,6 +21,40 @@ import { DEFAULT_CATEGORY } from '~/lib/category'; import { getUserMap } from './user'; import { FriendBalance } from '~/components/Friend/FriendBalance'; +export const getGroupExpensesProcedure = groupProcedure + .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, + }, + }, + ], + }, + orderBy: { + expenseDate: 'desc', + }, + include: { + expenseParticipants: true, + paidByUser: true, + deletedByUser: true, + conversionTo: true, + }, + }); + + return expenses; + }); + export const getExpenseDetailsProcedure = protectedProcedure .input(z.object({ expenseId: z.string() })) .query(async ({ input }) => { @@ -390,39 +424,7 @@ export const expenseRouter = createTRPCRouter({ return expenses; }), - getGroupExpenses: groupProcedure - .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, - }, - }, - ], - }, - orderBy: { - expenseDate: 'desc', - }, - include: { - expenseParticipants: true, - paidByUser: true, - deletedByUser: true, - conversionTo: true, - }, - }); - - return expenses; - }), + getGroupExpenses: getGroupExpensesProcedure, getExpenseDetails: getExpenseDetailsProcedure, From da7a4d6f8a2f2717159f772f31b0ac9889825251 Mon Sep 17 00:00:00 2001 From: Kushan Vora <511577+kushpvo@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:02:34 +0100 Subject: [PATCH 08/15] expense.addOrEditExpense API with openapi spec body --- src/pages/api/v1/[trpc].ts | 30 ++---- src/server/api/apiRouter.ts | 44 ++++++++- src/server/api/openapi.generated.json | 136 ++++++++++++++++++++++++++ src/server/api/routers/expense.ts | 92 +++++++++-------- 4 files changed, 235 insertions(+), 67 deletions(-) diff --git a/src/pages/api/v1/[trpc].ts b/src/pages/api/v1/[trpc].ts index c03e6e67..956e097e 100644 --- a/src/pages/api/v1/[trpc].ts +++ b/src/pages/api/v1/[trpc].ts @@ -33,33 +33,17 @@ const isPlainValue = (v: string): string | number | boolean => { /** * Public REST API handler (API-key auth), mounted at `/api/v1/*`, e.g. - * `GET /api/v1/user.me`. The wrapper: - * - * 1. Converts individual query params (`?groupId=150`) into tRPC's - * `?input={"groupId":150}` format, coercing values to numbers/booleans - * where obvious. - * 2. Wraps plain-JSON `?input=` payloads as superjson format ({json: …}) so - * the server transformer (superjson) can deserialise them correctly. + * `GET /api/v1/user.me`. The wrapper converts individual query params + * (`?groupId=150`) into tRPC's `?input={"groupId":150}` format, coercing + * values to numbers/booleans where obvious. The API router's transformer is + * configured to tolerate plain JSON input, so this works for all query + * procedures including future ones. */ export default async (req: NextApiRequest, res: NextApiResponse) => { if ('GET' === req.method && req.url) { const url = new URL(req.url, 'http://localhost'); - const rawInput = url.searchParams.get('input'); - if (rawInput) { - /* — `?input=` — wrap in superjson format if needed */ - try { - const parsed = JSON.parse(rawInput); - - if (!('json' in parsed)) { - url.searchParams.set('input', JSON.stringify({ json: parsed })); - req.url = url.pathname + url.search; - } - } catch { - /* Leave malformed input alone — tRPC will surface the parse error */ - } - } else { - /* — individual query params — build an `input` JSON object */ + if (!url.searchParams.has('input')) { const params: Record = {}; url.searchParams.forEach((value, key) => { @@ -69,7 +53,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { }); if (0 < Object.keys(params).length) { - url.searchParams.set('input', JSON.stringify({ json: params })); + url.searchParams.set('input', JSON.stringify(params)); req.url = url.pathname + url.search; } } diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index 60f110a8..06353399 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -1,6 +1,12 @@ +import superjson from 'superjson'; + import { createTRPCRouter } from '~/server/api/trpc'; -import { getExpenseDetailsProcedure, getGroupExpensesProcedure } from './routers/expense'; +import { + addOrEditExpenseApiProcedure, + getExpenseDetailsProcedure, + getGroupExpensesProcedure, +} from './routers/expense'; import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; import { getFriendsProcedure, meProcedure } from './routers/user'; @@ -12,7 +18,7 @@ import { getFriendsProcedure, meProcedure } from './routers/user'; * no per-procedure filter, so exposure is controlled structurally by only * mounting procedures here. Add new public endpoints one at a time. */ -export const apiRouter = createTRPCRouter({ +const _apiRouter = createTRPCRouter({ user: createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, @@ -24,7 +30,41 @@ export const apiRouter = createTRPCRouter({ expense: createTRPCRouter({ getExpenseDetails: getExpenseDetailsProcedure, getGroupExpenses: getGroupExpensesProcedure, + addOrEditExpense: addOrEditExpenseApiProcedure, }), }); +/** + * API consumers send plain JSON. The server's tRPC instance uses superjson, + * whose `deserialize` returns `undefined` for plain objects. We swap in a + * forgiving deserializer that falls back to the raw value when superjson does + * not recognise the payload. Output serialization stays unchanged. + */ +const originalDeserialize = (superjson.deserialize as unknown as (data: unknown) => unknown).bind( + superjson, +); +_apiRouter._def._config.transformer = { + input: { + serialize: superjson.serialize.bind(superjson), + deserialize: (data: unknown) => { + if (null === data || undefined === data) { + return data; + } + + const deserialized = originalDeserialize(data); + if (undefined === deserialized && 'object' === typeof data) { + return data; + } + + return deserialized; + }, + }, + output: { + serialize: superjson.serialize.bind(superjson), + deserialize: originalDeserialize, + }, +}; + +export const apiRouter = _apiRouter; + export type ApiRouter = typeof apiRouter; diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index b1e0a5ff..1b131a9e 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -1973,6 +1973,142 @@ } ] } + }, + "/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": { + "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": { + "type": "integer", + "format": "bigint" + } + }, + "required": ["userId", "amount"], + "additionalProperties": false + } + }, + "expenseDate": { + "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 + } + } + } + } + } + } } }, "components": { diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 72230269..53c90b3b 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -55,6 +55,55 @@ export const getGroupExpensesProcedure = groupProcedure return expenses; }); +const upsertExpenses = async (expenses: z.infer[], userId: number) => { + const results = []; + for (const input of expenses) { + if (input.expenseId) { + await validateEditExpensePermission(input.expenseId, userId); + } + if (input.splitType === SplitType.CURRENCY_CONVERSION) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); + } + + if (input.groupId !== null) { + const group = await db.group.findUnique({ + where: { id: input.groupId }, + select: { archivedAt: true }, + }); + if (!group) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); + } + if (group.archivedAt) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); + } + } + + 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', + }); + } + } + + return results; +}; + +export const addOrEditExpenseProcedure = protectedProcedure + .input(arrayify(createExpenseSchema)) + .mutation(async ({ input: expenses, ctx }) => upsertExpenses(expenses, ctx.session.user.id)); + +export const addOrEditExpenseApiProcedure = protectedProcedure + .input(z.array(createExpenseSchema)) + .mutation(async ({ input: expenses, ctx }) => upsertExpenses(expenses, ctx.session.user.id)); + export const getExpenseDetailsProcedure = protectedProcedure .input(z.object({ expenseId: z.string() })) .query(async ({ input }) => { @@ -239,48 +288,7 @@ export const expenseRouter = createTRPCRouter({ return { balances }; }), - addOrEditExpense: protectedProcedure - .input(arrayify(createExpenseSchema)) - .mutation(async ({ input: expenses, ctx }) => { - const results = []; - for (const input of expenses) { - if (input.expenseId) { - await validateEditExpensePermission(input.expenseId, ctx.session.user.id); - } - if (input.splitType === SplitType.CURRENCY_CONVERSION) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); - } - - if (input.groupId !== null) { - const group = await db.group.findUnique({ - where: { id: input.groupId }, - select: { archivedAt: true }, - }); - if (!group) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); - } - if (group.archivedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); - } - } - - 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', - }); - } - } - - return results; - }), + addOrEditExpense: addOrEditExpenseProcedure, addOrEditCurrencyConversion: protectedProcedure .input(createCurrencyConversionSchema) From 42927bda3915505770534fb348b41c431f8befcb Mon Sep 17 00:00:00 2001 From: Kushan Vora Date: Fri, 17 Jul 2026 11:52:37 +0100 Subject: [PATCH 09/15] =?UTF-8?q?expense.getBalances=E2=80=8B=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/package-lock.json | 304 +++++++++++++++++++++++++- src/server/api/apiRouter.ts | 2 + src/server/api/openapi.generated.json | 134 ++++++++++++ src/server/api/routers/expense.ts | 172 +++++++-------- 4 files changed, 516 insertions(+), 96 deletions(-) diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index abded7b2..9f26aaf5 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -5,40 +5,142 @@ "packages": { "": { "dependencies": { - "@opencode-ai/plugin": "1.4.3" + "@opencode-ai/plugin": "1.17.13" } }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@opencode-ai/plugin": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz", - "integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==", + "version": "1.17.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.13.tgz", + "integrity": "sha512-JjoIs6qTPl0IrXo+J1GTqX9lcb2h2dMoW6BWMR2IbTT3MY2FQ/lvBXDzkx0d3zVRaYN+NTmV4u8Nth396xq+sQ==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.4.3", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.17.13", + "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.1.97", - "@opentui/solid": ">=0.1.97" + "@opentui/core": ">=0.3.4", + "@opentui/keymap": ">=0.3.4", + "@opentui/solid": ">=0.3.4" }, "peerDependenciesMeta": { "@opentui/core": { "optional": true }, + "@opentui/keymap": { + "optional": true + }, "@opentui/solid": { "optional": true } } }, "node_modules/@opencode-ai/sdk": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz", - "integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==", + "version": "1.17.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.13.tgz", + "integrity": "sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -53,12 +155,141 @@ "node": ">= 8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -68,6 +299,22 @@ "node": ">=8" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -89,6 +336,28 @@ "node": ">=8" } }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -104,6 +373,21 @@ "node": ">= 8" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.1.8", "license": "MIT", diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index 06353399..e7749c20 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -4,6 +4,7 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { addOrEditExpenseApiProcedure, + getBalancesProcedure, getExpenseDetailsProcedure, getGroupExpensesProcedure, } from './routers/expense'; @@ -28,6 +29,7 @@ const _apiRouter = createTRPCRouter({ getGroupDetails: getGroupDetailsProcedure, }), expense: createTRPCRouter({ + getBalances: getBalancesProcedure, getExpenseDetails: getExpenseDetailsProcedure, getGroupExpenses: getGroupExpensesProcedure, addOrEditExpense: addOrEditExpenseApiProcedure, diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index 1b131a9e..8e2ee827 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -689,6 +689,140 @@ ] } }, + "/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", diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 53c90b3b..cd59eaf0 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -104,6 +104,91 @@ export const addOrEditExpenseApiProcedure = protectedProcedure .input(z.array(createExpenseSchema)) .mutation(async ({ input: expenses, ctx }) => upsertExpenses(expenses, ctx.session.user.id)); +export const getBalancesProcedure = protectedProcedure.query(async ({ ctx }) => { + const rawBalances = await db.balanceView.findMany({ + where: { + userId: ctx.session.user.id, + friendId: { notIn: ctx.session.user.hiddenFriendIds }, + }, + include: { + group: { + select: { + simplifyDebts: true, + }, + }, + }, + }); + + const processedBalances = await Promise.all( + rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => { + if (!group?.simplifyDebts || null === groupId) { + return { friendId, currency, amount }; + } + + const allGroupBalances = await db.balanceView.findMany({ + where: { groupId, currency }, + }); + + const simplified = simplifyDebts(allGroupBalances); + const simplifiedBalance = simplified.find( + (b) => + b.userId === ctx.session.user.id && b.friendId === friendId && b.currency === currency, + ); + + return { friendId, currency, amount: simplifiedBalance?.amount ?? 0n }; + }), + ); + + const friendIds = [...new Set([...processedBalances].map((b) => b.friendId))]; + const userMap = await getUserMap(friendIds); + + const aggregated = processedBalances + .filter((b) => 0n !== b.amount) + .reduce>( + (acc, { friendId, currency, amount }) => { + const key = `${friendId}-${currency}`; + const existing = acc.get(key); + if (existing) { + existing.amount += amount; + } else { + acc.set(key, { friendId, currency, amount }); + } + return acc; + }, + new Map(), + ); + + const balancesByFriend = [...aggregated.values()].reduce< + Record + >((acc, { friendId, currency, amount }) => { + if (!acc[friendId]) { + acc[friendId] = []; + } + acc[friendId].push({ currency, amount }); + return acc; + }, {}); + + friendIds.forEach((friendId) => { + if (!balancesByFriend[friendId]) { + balancesByFriend[friendId] = []; + } + }); + + const balances = Object.entries(balancesByFriend) + .map(([friendId, currencies]) => ({ + friendId: Number(friendId), + currencies, + friend: userMap[Number(friendId)]!, + maxAmount: currencies.reduce( + (max, curr) => (BigMath.abs(curr.amount) > BigMath.abs(max) ? curr.amount : max), + 0n, + ), + })) + .sort((a, b) => Number(BigMath.abs(b.maxAmount) - BigMath.abs(a.maxAmount))); + + return { balances }; +}); + export const getExpenseDetailsProcedure = protectedProcedure .input(z.object({ expenseId: z.string() })) .query(async ({ input }) => { @@ -201,92 +286,7 @@ export const expenseRouter = createTRPCRouter({ return { youOwe, youGet }; }), - getBalances: protectedProcedure.query(async ({ ctx }) => { - const rawBalances = await db.balanceView.findMany({ - where: { - userId: ctx.session.user.id, - friendId: { notIn: ctx.session.user.hiddenFriendIds }, - }, - include: { - group: { - select: { - simplifyDebts: true, - }, - }, - }, - }); - - const processedBalances = await Promise.all( - rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => { - if (!group?.simplifyDebts || null === groupId) { - return { friendId, currency, amount }; - } - - const allGroupBalances = await db.balanceView.findMany({ - where: { groupId, currency }, - }); - - const simplified = simplifyDebts(allGroupBalances); - const simplifiedBalance = simplified.find( - (b) => - b.userId === ctx.session.user.id && b.friendId === friendId && b.currency === currency, - ); - - return { friendId, currency, amount: simplifiedBalance?.amount ?? 0n }; - }), - ); - - // Group by friendId and fetch user details - const friendIds = [...new Set([...processedBalances].map((b) => b.friendId))]; - const userMap = await getUserMap(friendIds); - - // Aggregate by (friendId, currency) since same pair can appear in multiple groups - const aggregated = processedBalances - .filter((b) => 0n !== b.amount) - .reduce>( - (acc, { friendId, currency, amount }) => { - const key = `${friendId}-${currency}`; - const existing = acc.get(key); - if (existing) { - existing.amount += amount; - } else { - acc.set(key, { friendId, currency, amount }); - } - return acc; - }, - new Map(), - ); - - const balancesByFriend = [...aggregated.values()].reduce< - Record - >((acc, { friendId, currency, amount }) => { - if (!acc[friendId]) { - acc[friendId] = []; - } - acc[friendId].push({ currency, amount }); - return acc; - }, {}); - - friendIds.forEach((friendId) => { - if (!balancesByFriend[friendId]) { - balancesByFriend[friendId] = []; - } - }); - - const balances = Object.entries(balancesByFriend) - .map(([friendId, currencies]) => ({ - friendId: Number(friendId), - currencies, - friend: userMap[Number(friendId)]!, - maxAmount: currencies.reduce( - (max, curr) => (BigMath.abs(curr.amount) > BigMath.abs(max) ? curr.amount : max), - 0n, - ), - })) - .sort((a, b) => Number(BigMath.abs(b.maxAmount) - BigMath.abs(a.maxAmount))); - - return { balances }; - }), + getBalances: getBalancesProcedure, addOrEditExpense: addOrEditExpenseProcedure, From 48ea509eea247b272a42b028988bfb932826c8e5 Mon Sep 17 00:00:00 2001 From: Kushan Vora Date: Fri, 17 Jul 2026 11:57:21 +0100 Subject: [PATCH 10/15] expense.getAllExpenses --- src/server/api/apiRouter.ts | 2 + src/server/api/openapi.generated.json | 202 ++++++++++++++++++++++++++ src/server/api/routers/expense.ts | 74 +++++----- 3 files changed, 242 insertions(+), 36 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index e7749c20..dd61e1c1 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -4,6 +4,7 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { addOrEditExpenseApiProcedure, + getAllExpensesProcedure, getBalancesProcedure, getExpenseDetailsProcedure, getGroupExpensesProcedure, @@ -29,6 +30,7 @@ const _apiRouter = createTRPCRouter({ getGroupDetails: getGroupDetailsProcedure, }), expense: createTRPCRouter({ + getAllExpenses: getAllExpensesProcedure, getBalances: getBalancesProcedure, getExpenseDetails: getExpenseDetailsProcedure, getGroupExpenses: getGroupExpensesProcedure, diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index 8e2ee827..c7e58f25 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -689,6 +689,208 @@ ] } }, + "/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": { + "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 + } + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/expense.getBalances": { "get": { "operationId": "expense.getBalances", diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index cd59eaf0..88e998e8 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -189,6 +189,43 @@ export const getBalancesProcedure = protectedProcedure.query(async ({ ctx }) => return { balances }; }); +export const getAllExpensesProcedure = protectedProcedure.query(async ({ ctx }) => { + const expenses = await db.expenseParticipant.findMany({ + where: { + userId: ctx.session.user.id, + }, + orderBy: { + expense: { + createdAt: 'desc', + }, + }, + include: { + expense: { + include: { + paidByUser: { + select: { + name: true, + email: true, + image: true, + id: true, + }, + }, + deletedByUser: { + select: { + name: true, + email: true, + image: true, + id: true, + }, + }, + }, + }, + }, + }); + + return expenses; +}); + export const getExpenseDetailsProcedure = protectedProcedure .input(z.object({ expenseId: z.string() })) .query(async ({ input }) => { @@ -436,42 +473,7 @@ export const expenseRouter = createTRPCRouter({ getExpenseDetails: getExpenseDetailsProcedure, - getAllExpenses: protectedProcedure.query(async ({ ctx }) => { - const expenses = await db.expenseParticipant.findMany({ - where: { - userId: ctx.session.user.id, - }, - orderBy: { - expense: { - createdAt: 'desc', - }, - }, - include: { - expense: { - include: { - paidByUser: { - select: { - name: true, - email: true, - image: true, - id: true, - }, - }, - deletedByUser: { - select: { - name: true, - email: true, - image: true, - id: true, - }, - }, - }, - }, - }, - }); - - return expenses; - }), + getAllExpenses: getAllExpensesProcedure, getRecurringExpenses: protectedProcedure.query(async ({ ctx }) => { const recurrences = await db.expenseRecurrence.findMany({ From cc2ca72f9db66ad0f5748da87deef32623e03105 Mon Sep 17 00:00:00 2001 From: Kushan Vora Date: Fri, 17 Jul 2026 11:59:18 +0100 Subject: [PATCH 11/15] expense.getExpensesWithFriend --- src/server/api/apiRouter.ts | 2 + src/server/api/openapi.generated.json | 382 ++++++++++++++++++++++++++ src/server/api/routers/expense.ts | 172 ++++++------ 3 files changed, 471 insertions(+), 85 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index dd61e1c1..0ca2c0ea 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -7,6 +7,7 @@ import { getAllExpensesProcedure, getBalancesProcedure, getExpenseDetailsProcedure, + getExpensesWithFriendProcedure, getGroupExpensesProcedure, } from './routers/expense'; import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; @@ -33,6 +34,7 @@ const _apiRouter = createTRPCRouter({ getAllExpenses: getAllExpensesProcedure, getBalances: getBalancesProcedure, getExpenseDetails: getExpenseDetailsProcedure, + getExpensesWithFriend: getExpensesWithFriendProcedure, getGroupExpenses: getGroupExpensesProcedure, addOrEditExpense: addOrEditExpenseApiProcedure, }), diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index c7e58f25..049792e6 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -1881,6 +1881,388 @@ ] } }, + "/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": { + "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 + } + } + }, + "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 + } + } + } + } + ] + } + }, "/expense.getGroupExpenses": { "get": { "operationId": "expense.getGroupExpenses", diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 88e998e8..f91eed87 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -226,6 +226,92 @@ export const getAllExpensesProcedure = protectedProcedure.query(async ({ ctx }) return expenses; }); +export const getExpensesWithFriendProcedure = protectedProcedure + .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, + }, + }, + ], + }, + ], + }, + orderBy: { + expenseDate: '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, + }, + }, + }, + }); + + return expenses; + }); + export const getExpenseDetailsProcedure = protectedProcedure .input(z.object({ expenseId: z.string() })) .query(async ({ input }) => { @@ -383,91 +469,7 @@ export const expenseRouter = createTRPCRouter({ } }), - getExpensesWithFriend: protectedProcedure - .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, - }, - }, - ], - }, - ], - }, - orderBy: { - expenseDate: '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, - }, - }, - }, - }); - - return expenses; - }), + getExpensesWithFriend: getExpensesWithFriendProcedure, getGroupExpenses: getGroupExpensesProcedure, From 358c6e8af9fd1a0881df2cdcd95b1b4ca8d5b343 Mon Sep 17 00:00:00 2001 From: Kushan Vora Date: Fri, 17 Jul 2026 12:01:03 +0100 Subject: [PATCH 12/15] user.getOwnExpenses --- src/server/api/apiRouter.ts | 3 +- src/server/api/openapi.generated.json | 208 ++++++++++++++++++++++++++ src/server/api/routers/user.ts | 41 ++--- 3 files changed, 232 insertions(+), 20 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index 0ca2c0ea..da8392a5 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -11,7 +11,7 @@ import { getGroupExpensesProcedure, } from './routers/expense'; import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; -import { getFriendsProcedure, meProcedure } from './routers/user'; +import { getFriendsProcedure, getOwnExpensesProcedure, meProcedure } from './routers/user'; /** * Public REST API surface, served at `/api/v1` with API-key auth and documented @@ -25,6 +25,7 @@ const _apiRouter = createTRPCRouter({ user: createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, + getOwnExpenses: getOwnExpensesProcedure, }), group: createTRPCRouter({ getAllGroups: getAllGroupsProcedure, diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index 049792e6..bb23e951 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -177,6 +177,214 @@ } } }, + "/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": { + "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 + } + } + }, + "required": ["data"] + } + }, + "required": ["result"] + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/group.getAllGroups": { "get": { "operationId": "group.getAllGroups", diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index f1c499f3..072b6f3a 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -44,26 +44,27 @@ export const getFriendsProcedure = protectedProcedure.query(async ({ ctx }) => { return friends.map((f) => f.friend); }); +export const getOwnExpensesProcedure = protectedProcedure.query(async ({ ctx }) => { + const expenses = await db.expense.findMany({ + where: { + paidBy: ctx.session.user.id, + deletedBy: null, + }, + orderBy: { + expenseDate: 'desc', + }, + include: { + group: true, + }, + }); + + return expenses; +}); + export const userRouter = createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, - - getOwnExpenses: protectedProcedure.query(async ({ ctx }) => { - const expenses = await db.expense.findMany({ - where: { - paidBy: ctx.session.user.id, - deletedBy: null, - }, - orderBy: { - expenseDate: 'desc', - }, - include: { - group: true, - }, - }); - - return expenses; - }), + getOwnExpenses: getOwnExpensesProcedure, inviteFriend: protectedProcedure .input(z.object({ email: z.string(), sendInviteEmail: z.boolean().optional() })) @@ -430,7 +431,8 @@ export const userRouter = createTRPCRouter({ getWebPushPublicKey: protectedProcedure.query(() => env.WEB_PUSH_PUBLIC_KEY ?? ''), - listApiKeys: protectedProcedure.query(async ({ ctx }) => db.apiKey.findMany({ + listApiKeys: protectedProcedure.query(async ({ ctx }) => + db.apiKey.findMany({ where: { userId: ctx.session.user.id }, select: { id: true, @@ -441,7 +443,8 @@ export const userRouter = createTRPCRouter({ createdAt: true, }, orderBy: { createdAt: 'desc' }, - })), + }), + ), createApiKey: protectedProcedure .input( From 4824fc4daf69e2dcc45e09836eccb71358c9f326 Mon Sep 17 00:00:00 2001 From: Kushan Vora Date: Fri, 17 Jul 2026 12:21:46 +0100 Subject: [PATCH 13/15] feat: add offset-based pagination to API list endpoints - Add shared pagination module (paginationInput schema + paginatedResult helper) - Create API-specific paginated variants for getAllExpenses, getExpensesWithFriend, getGroupExpenses, and getOwnExpenses (app procedures unchanged) - All four endpoints return { items, pagination: { total, limit, offset, hasMore } } - Defaults: limit=20, offset=0, max limit=100 - Post-process generated OpenAPI spec to inject default values for limit/offset --- scripts/generate-openapi.ts | 40 +- src/server/api/apiRouter.ts | 16 +- src/server/api/openapi.generated.json | 2576 +++++++++++++------------ src/server/api/pagination.ts | 46 + src/server/api/routers/expense.ts | 107 + src/server/api/routers/user.ts | 22 + 6 files changed, 1602 insertions(+), 1205 deletions(-) create mode 100644 src/server/api/pagination.ts diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 5b342c1e..2c49fba6 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -40,8 +40,46 @@ async function main() { }; doc.security = [{ bearerAuth: [] }]; + // `@trpc/openapi`'s static analyzer infers Zod `.default()` as optional fields + // But doesn't emit the actual default values into the schema. Walk every query + // Parameter named "input" and inject defaults for `limit` (20) and `offset` (0) + // So the Scalar UI and generated clients pick them up automatically. + const defaultMap: Record = { limit: 20, offset: 0 }; + // Biome-ignore lint/suspicious/noExplicitAny: post-processing untyped OpenAPI JSON + const paths: any = doc.paths ?? {}; + + for (const op of Object.values(paths)) { + for (const operation of Object.values(op as Record)) { + // Biome-ignore lint/suspicious/noExplicitAny: post-processing untyped OpenAPI JSON + const o: any = operation; + if (!Array.isArray(o.parameters)) { + continue; + } + + for (const param of o.parameters as unknown[]) { + // Biome-ignore lint/suspicious/noExplicitAny: post-processing untyped OpenAPI JSON + const p: any = param; + if ('input' !== p.name) { + continue; + } + const props = p.content?.['application/json']?.schema?.properties; + if (!props) { + continue; + } + + for (const [field, defaultValue] of Object.entries(defaultMap)) { + if (field in props && undefined === props[field].default) { + props[field].default = defaultValue; + } + } + } + } + } + await writeFile(outputPath, `${JSON.stringify(doc, null, 2)}\n`); - console.log(`✓ Wrote OpenAPI spec (${Object.keys(doc.paths ?? {}).length} paths) to ${outputPath}`); + console.log( + `✓ Wrote OpenAPI spec (${Object.keys(doc.paths ?? {}).length} paths) to ${outputPath}`, + ); } main().catch((error) => { diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index da8392a5..40cb0c6b 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -4,14 +4,14 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { addOrEditExpenseApiProcedure, - getAllExpensesProcedure, + getAllExpensesApiProcedure, getBalancesProcedure, getExpenseDetailsProcedure, - getExpensesWithFriendProcedure, - getGroupExpensesProcedure, + getExpensesWithFriendApiProcedure, + getGroupExpensesApiProcedure, } from './routers/expense'; import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; -import { getFriendsProcedure, getOwnExpensesProcedure, meProcedure } from './routers/user'; +import { getFriendsProcedure, getOwnExpensesApiProcedure, meProcedure } from './routers/user'; /** * Public REST API surface, served at `/api/v1` with API-key auth and documented @@ -25,18 +25,18 @@ const _apiRouter = createTRPCRouter({ user: createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, - getOwnExpenses: getOwnExpensesProcedure, + getOwnExpenses: getOwnExpensesApiProcedure, }), group: createTRPCRouter({ getAllGroups: getAllGroupsProcedure, getGroupDetails: getGroupDetailsProcedure, }), expense: createTRPCRouter({ - getAllExpenses: getAllExpensesProcedure, + getAllExpenses: getAllExpensesApiProcedure, getBalances: getBalancesProcedure, getExpenseDetails: getExpenseDetailsProcedure, - getExpensesWithFriend: getExpensesWithFriendProcedure, - getGroupExpenses: getGroupExpensesProcedure, + getExpensesWithFriend: getExpensesWithFriendApiProcedure, + getGroupExpenses: getGroupExpensesApiProcedure, addOrEditExpense: addOrEditExpenseApiProcedure, }), }); diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index bb23e951..7a2ef5e3 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -193,182 +193,7 @@ "type": "object", "properties": { "data": { - "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 - } + "$ref": "#/components/schemas/PaginatedResult" } }, "required": ["data"] @@ -382,7 +207,33 @@ "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 + } + } + } + } + ] } }, "/group.getAllGroups": { @@ -913,40 +764,93 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "expense": { + "$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.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": { - "deletedByUser": { - "oneOf": [ - { - "type": "object", - "properties": { - "name": { - "type": ["string", "null"] - }, - "id": { - "type": "number" - }, - "email": { - "type": ["string", "null"] - }, - "image": { - "type": ["string", "null"] - } + "friendId": { + "type": "number" + }, + "currencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "currency": { + "type": "string" }, - "required": ["name", "id", "email", "image"], - "additionalProperties": false + "amount": { + "type": "integer", + "format": "bigint" + } }, - { - "type": "null" - } - ] + "required": ["currency", "amount"], + "additionalProperties": false + } }, - "paidByUser": { + "friend": { "type": "object", "properties": { "name": { @@ -957,213 +861,17 @@ }, "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 - } - } - }, - "required": ["data"] - } - }, - "required": ["result"] - } - } - } - }, - "default": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/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" - } - ] + }, + "emailVerified": { + "oneOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] }, "image": { "type": ["string", "null"] @@ -2105,334 +1813,7 @@ "type": "object", "properties": { "data": { - "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 - } + "$ref": "#/components/schemas/PaginatedResult3" } }, "required": ["data"] @@ -2460,6 +1841,14 @@ "properties": { "friendId": { "type": "number" + }, + "limit": { + "type": "number", + "default": 20 + }, + "offset": { + "type": "number", + "default": 0 } }, "required": ["friendId"], @@ -2474,435 +1863,69 @@ "/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": { - "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 - } - } - }, - "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.addOrEditExpense": { - "post": { - "operationId": "expense.addOrEditExpense", + "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": { @@ -3039,34 +2062,238 @@ }, "components": { "schemas": { - "SerializedDefaultSplitConfig": { + "PaginatedResult": { "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": { + "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" } }, @@ -3245,6 +2472,963 @@ ], "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": { diff --git a/src/server/api/pagination.ts b/src/server/api/pagination.ts new file mode 100644 index 00000000..7261e894 --- /dev/null +++ b/src/server/api/pagination.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +/** + * Shared pagination input schema for the public API. + * Uses Zod `.default()` so the OpenAPI generator emits `default` values. + * + * @example + * // No other input: + * .input(paginationInput.optional()) + * // Combined with other fields: + * .input(z.object({ groupId: z.number() }).extend(paginationInput.shape)) + */ +export const paginationInput = z.object({ + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), +}); + +export type PaginationInput = z.infer; + +export interface PaginatedResult { + items: T[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +/** + * Build a paginated response envelope from raw items + total count. + * Applies the default limit (20) and offset (0) when not supplied. + */ +export const paginatedResult = ( + items: T[], + total: number, + opts?: { limit?: number; offset?: number }, +): PaginatedResult => { + const limit = opts?.limit ?? 20; + const offset = opts?.offset ?? 0; + + return { + 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 f91eed87..9b2f749b 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -20,6 +20,7 @@ import { SplitType } from '@prisma/client'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { getUserMap } from './user'; import { FriendBalance } from '~/components/Friend/FriendBalance'; +import { paginatedResult, paginationInput } from '~/server/api/pagination'; export const getGroupExpensesProcedure = groupProcedure .input(z.object({ groupId: z.number() })) @@ -388,6 +389,112 @@ export const getExpenseDetailsProcedure = protectedProcedure return expense; }); +/* ── API-specific paginated variants (offset-based) ─────────────────────── */ + +export const getAllExpensesApiProcedure = protectedProcedure + .input(paginationInput.optional()) + .query(async ({ input, ctx }) => { + const limit = input?.limit ?? 20; + const offset = input?.offset ?? 0; + const where = { userId: ctx.session.user.id }; + + const [items, total] = await Promise.all([ + db.expenseParticipant.findMany({ + where, + orderBy: { expense: { createdAt: 'desc' } }, + include: { + expense: { + include: { + paidByUser: { select: { name: true, email: true, image: true, id: true } }, + deletedByUser: { select: { name: true, email: true, image: true, id: true } }, + }, + }, + }, + take: limit, + skip: offset, + }), + db.expenseParticipant.count({ where }), + ]); + + return paginatedResult(items, total, input ?? undefined); + }); + +export const getExpensesWithFriendApiProcedure = protectedProcedure + .input(z.object({ friendId: z.number() }).extend(paginationInput.shape)) + .query(async ({ input, ctx }) => { + const limit = input.limit ?? 20; + const offset = input.offset ?? 0; + const 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 } }, + ], + }, + ], + }; + + const [items, total] = await Promise.all([ + db.expense.findMany({ + where, + orderBy: { expenseDate: '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: limit, + skip: offset, + }), + db.expense.count({ where }), + ]); + + return paginatedResult(items, total, input); + }); + +export const getGroupExpensesApiProcedure = groupProcedure + .input(z.object({ groupId: z.number() }).extend(paginationInput.shape)) + .query(async ({ input, ctx }) => { + const limit = input.limit ?? 20; + const offset = input.offset ?? 0; + const where = { + groupId: input.groupId, + deletedBy: null, + OR: [ + { NOT: { splitType: SplitType.CURRENCY_CONVERSION } }, + { NOT: { conversionToId: null } }, + ], + }; + + const [items, total] = await Promise.all([ + ctx.db.expense.findMany({ + where, + orderBy: { expenseDate: 'desc' }, + include: { + expenseParticipants: true, + paidByUser: true, + deletedByUser: true, + conversionTo: true, + }, + take: limit, + skip: offset, + }), + ctx.db.expense.count({ where }), + ]); + + return paginatedResult(items, total, input); + }); + export const expenseRouter = createTRPCRouter({ getCumulatedBalances: protectedProcedure.query(async ({ ctx }) => { const cumulatedBalances = await db.balanceView.groupBy({ diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 072b6f3a..0a2a8df1 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -11,6 +11,7 @@ import { import { simplifyDebts } from '~/lib/simplify'; import { generateApiKey } from '~/server/api/apiKey'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; +import { paginatedResult, paginationInput } from '~/server/api/pagination'; import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; @@ -61,6 +62,27 @@ export const getOwnExpensesProcedure = protectedProcedure.query(async ({ ctx }) return expenses; }); +export const getOwnExpensesApiProcedure = protectedProcedure + .input(paginationInput.optional()) + .query(async ({ input, ctx }) => { + const limit = input?.limit ?? 20; + const offset = input?.offset ?? 0; + const where = { paidBy: ctx.session.user.id, deletedBy: null }; + + const [items, total] = await Promise.all([ + db.expense.findMany({ + where, + orderBy: { expenseDate: 'desc' }, + include: { group: true }, + take: limit, + skip: offset, + }), + db.expense.count({ where }), + ]); + + return paginatedResult(items, total, input ?? undefined); + }); + export const userRouter = createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, From c52bf1cf02aafc767e94e28eaab8e9c01100827f Mon Sep 17 00:00:00 2001 From: Kushan Vora Date: Fri, 17 Jul 2026 12:51:35 +0100 Subject: [PATCH 14/15] feat: add API endpoints for user.getBalancesWithFriend and user.getFriend --- src/server/api/apiRouter.ts | 10 +- src/server/api/openapi.generated.json | 211 +++++++++++++++++++++++++ src/server/api/routers/user.ts | 217 +++++++++++++------------- 3 files changed, 328 insertions(+), 110 deletions(-) diff --git a/src/server/api/apiRouter.ts b/src/server/api/apiRouter.ts index 40cb0c6b..1d42ad85 100644 --- a/src/server/api/apiRouter.ts +++ b/src/server/api/apiRouter.ts @@ -11,7 +11,13 @@ import { getGroupExpensesApiProcedure, } from './routers/expense'; import { getAllGroupsProcedure, getGroupDetailsProcedure } from './routers/group'; -import { getFriendsProcedure, getOwnExpensesApiProcedure, meProcedure } from './routers/user'; +import { + getBalancesWithFriendProcedure, + getFriendProcedure, + getFriendsProcedure, + getOwnExpensesApiProcedure, + meProcedure, +} from './routers/user'; /** * Public REST API surface, served at `/api/v1` with API-key auth and documented @@ -26,6 +32,8 @@ const _apiRouter = createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, getOwnExpenses: getOwnExpensesApiProcedure, + getBalancesWithFriend: getBalancesWithFriendProcedure, + getFriend: getFriendProcedure, }), group: createTRPCRouter({ getAllGroups: getAllGroupsProcedure, diff --git a/src/server/api/openapi.generated.json b/src/server/api/openapi.generated.json index 7a2ef5e3..c78a26ca 100644 --- a/src/server/api/openapi.generated.json +++ b/src/server/api/openapi.generated.json @@ -236,6 +236,217 @@ ] } }, + "/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", diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 0a2a8df1..7df01918 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -83,10 +83,117 @@ export const getOwnExpensesApiProcedure = protectedProcedure return paginatedResult(items, total, input ?? undefined); }); +export const getBalancesWithFriendProcedure = protectedProcedure + .input(z.object({ friendId: z.number() })) + .query(async ({ input, ctx }) => { + const rawBalances = await db.balanceView.findMany({ + where: { + userId: ctx.session.user.id, + friendId: input.friendId, + amount: { not: 0 }, + }, + include: { + group: { + select: { + name: true, + simplifyDebts: true, + }, + }, + }, + }); + + const processedBalances = await Promise.all( + rawBalances.map(async ({ groupId, currency, amount, group }) => { + if (!group?.simplifyDebts || null === groupId) { + return { + friendId: input.friendId, + currency, + amount, + groupId, + groupName: group?.name ?? null, + }; + } + + const allGroupBalances = await db.balanceView.findMany({ + where: { groupId, currency }, + }); + + const simplified = simplifyDebts(allGroupBalances); + + const simplifiedBalance = simplified.find( + (b) => + b.userId === ctx.session.user.id && + b.friendId === input.friendId && + b.currency === currency, + ); + + return { + friendId: input.friendId, + currency, + amount: simplifiedBalance?.amount ?? 0n, + groupId, + groupName: group.name, + }; + }), + ); + + return processedBalances.filter((b) => 0n !== b.amount); + }); + +export const getFriendProcedure = protectedProcedure + .input(z.object({ friendId: z.number() })) + .query(async ({ input, ctx }) => { + const friend = await db.user.findUnique({ + where: { + id: input.friendId, + userBalances: { + some: { + friendId: ctx.session.user.id, + }, + }, + }, + }); + + if (!friend) { + return friend; + } + + const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId); + + const friendDefaultSplit = await db.friendDefaultSplit.findUnique({ + where: { + userAId_userBId: { + userAId, + userBId, + }, + }, + }); + + const defaultSplit = + friendDefaultSplit && + (() => { + const parsedShares = z.record(z.string(), z.string()).safeParse(friendDefaultSplit.shares); + if (!parsedShares.success) { + return null; + } + + return deserializeDefaultSplit({ + splitType: friendDefaultSplit.splitType, + shares: parsedShares.data, + }); + })(); + + return { + ...friend, + defaultSplit: defaultSplit ? serializeDefaultSplit(defaultSplit) : null, + }; + }); + export const userRouter = createTRPCRouter({ me: meProcedure, getFriends: getFriendsProcedure, getOwnExpenses: getOwnExpensesProcedure, + getBalancesWithFriend: getBalancesWithFriendProcedure, inviteFriend: protectedProcedure .input(z.object({ email: z.string(), sendInviteEmail: z.boolean().optional() })) @@ -117,65 +224,6 @@ export const userRouter = createTRPCRouter({ return user; }), - getBalancesWithFriend: protectedProcedure - .input(z.object({ friendId: z.number() })) - .query(async ({ input, ctx }) => { - const rawBalances = await db.balanceView.findMany({ - where: { - userId: ctx.session.user.id, - friendId: input.friendId, - amount: { not: 0 }, - }, - include: { - group: { - select: { - name: true, - simplifyDebts: true, - }, - }, - }, - }); - - const processedBalances = await Promise.all( - rawBalances.map(async ({ groupId, currency, amount, group }) => { - // For non-simplifyDebts groups and non-group balances, use raw balance - if (!group?.simplifyDebts || null === groupId) { - return { - friendId: input.friendId, - currency, - amount, - groupId, - groupName: group?.name ?? null, - }; - } - - // For simplifyDebts groups, fetch all group balances and simplify - const allGroupBalances = await db.balanceView.findMany({ - where: { groupId, currency }, - }); - - const simplified = simplifyDebts(allGroupBalances); - - const simplifiedBalance = simplified.find( - (b) => - b.userId === ctx.session.user.id && - b.friendId === input.friendId && - b.currency === currency, - ); - - return { - friendId: input.friendId, - currency, - amount: simplifiedBalance?.amount ?? 0n, - groupId, - groupName: group.name, - }; - }), - ); - - return processedBalances.filter((b) => 0n !== b.amount); - }), - updateUserDetail: protectedProcedure .input( z.object({ @@ -219,56 +267,7 @@ export const userRouter = createTRPCRouter({ await sendFeedbackEmail(input.feedback, ctx.session.user as User); }), - getFriend: protectedProcedure - .input(z.object({ friendId: z.number() })) - .query(async ({ input, ctx }) => { - const friend = await db.user.findUnique({ - where: { - id: input.friendId, - userBalances: { - some: { - friendId: ctx.session.user.id, - }, - }, - }, - }); - - if (!friend) { - return friend; - } - - const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId); - - const friendDefaultSplit = await db.friendDefaultSplit.findUnique({ - where: { - userAId_userBId: { - userAId, - userBId, - }, - }, - }); - - const defaultSplit = - friendDefaultSplit && - (() => { - const parsedShares = z - .record(z.string(), z.string()) - .safeParse(friendDefaultSplit.shares); - if (!parsedShares.success) { - return null; - } - - return deserializeDefaultSplit({ - splitType: friendDefaultSplit.splitType, - shares: parsedShares.data, - }); - })(); - - return { - ...friend, - defaultSplit: defaultSplit ? serializeDefaultSplit(defaultSplit) : null, - }; - }), + getFriend: getFriendProcedure, upsertFriendDefaultSplit: protectedProcedure .input( From a683ed438972b5d0e856893ddfbf2705c4934e12 Mon Sep 17 00:00:00 2001 From: Kushan Vora Date: Sun, 19 Jul 2026 21:03:30 +0100 Subject: [PATCH 15/15] prettier --- src/components/Account/ApiKeys.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Account/ApiKeys.tsx b/src/components/Account/ApiKeys.tsx index 67ef77e5..4a7ffa2c 100644 --- a/src/components/Account/ApiKeys.tsx +++ b/src/components/Account/ApiKeys.tsx @@ -120,7 +120,8 @@ export const ApiKeys: React.FC = ({ children }) => {
{key.name} - {key.partialKey}… · {key.lastUsedAt ? `used ${key.lastUsedAt.toLocaleDateString()}` : 'never used'} + {key.partialKey}… ·{' '} + {key.lastUsedAt ? `used ${key.lastUsedAt.toLocaleDateString()}` : 'never used'}