From 60d6f01ecd6c32dc02fc8020cc6c5219172322b7 Mon Sep 17 00:00:00 2001 From: Paranoa-dev <287413997+Paranoa-dev@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:47:02 +0100 Subject: [PATCH] feat(api): add POST /transactions/status-batch endpoint - Add TransactionModel.findByIds(ids, userId?) with single WHERE id = ANY() query - Add statusBatchHandler accepting up to 100 UUIDs, returning { id: status | null } - Register route POST /api/v1/transactions/status-batch with requireAuth - Register OpenAPI StatusBatchRequest, StatusBatchResponse schemas and path --- PR_DESCRIPTION.md | 30 ++++++++++++++++++ src/controllers/transactionController.ts | 39 ++++++++++++++++++++++++ src/models/transaction.ts | 20 ++++++++++++ src/openapi/paths/transactions.ts | 27 ++++++++++++++++ src/openapi/schemas/transactions.ts | 38 +++++++++++++++++++++++ src/routes/v1/transactions.ts | 11 +++++++ 6 files changed, 165 insertions(+) create mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..1a51bf6a --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,30 @@ +# Bulk Transaction Status Query API + +## Summary + +Adds a `POST /api/v1/transactions/status-batch` endpoint that accepts up to 100 transaction IDs and returns their statuses in a single response, using a single SQL `WHERE id = ANY(...)` query for optimal performance. + +## Acceptance Criteria + +- ✅ Endpoint accepts an array of up to 100 transaction IDs in the request body +- ✅ Single SQL query fetches all requested transactions using `WHERE id = ANY(...)` +- ✅ Response is a map of `{ transactionId: status }` for quick lookup +- ✅ Transaction IDs not belonging to the requesting org return `null` in the map (not an error) + +## Implementation + +### Model (`src/models/transaction.ts`) +- Added `findByIds(ids, userId?)` — single query with `WHERE id = ANY($1)`, scoped to `userId` when provided, returns `{ id, status }[]` + +### Controller (`src/controllers/transactionController.ts`) +- Added `statusBatchHandler` — validates request body with Zod (`array(uuid).min(1).max(100)`), queries via model, builds `{ [id]: status | null }` response map +- IDs not found or not owned by the requesting user return `null` (not an error) + +### Route (`src/routes/v1/transactions.ts`) +- `POST /status-batch` — requires auth, quick timeout, v1 API version + +### OpenAPI +- Registered `StatusBatchRequest` and `StatusBatchResponse` schemas +- Registered the `POST /api/v1/transactions/status-batch` path with full documentation + +closes #112 diff --git a/src/controllers/transactionController.ts b/src/controllers/transactionController.ts index af494792..100fec2e 100644 --- a/src/controllers/transactionController.ts +++ b/src/controllers/transactionController.ts @@ -1043,6 +1043,45 @@ export const listTransactionsHandler = async (req: Request, res: Response) => { } }; +const statusBatchSchema = z.object({ + ids: z + .array(z.string().uuid()) + .min(1, "At least one transaction ID is required") + .max(100, "Maximum 100 transaction IDs allowed"), +}); + +export const statusBatchHandler = async (req: Request, res: Response) => { + try { + const userId = req.jwtUser?.userId; + const { ids } = statusBatchSchema.parse(req.body); + + const results = await transactionModel.findByIds(ids, userId); + + const resultMap: Record = {}; + const resultStatuses = new Map( + results.map((r) => [r.id, r.status]), + ); + + for (const id of ids) { + resultMap[id] = resultStatuses.get(id) ?? null; + } + + return res.json({ statuses: resultMap }); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ + error: "Validation error", + details: err.issues, + }); + } + if (err && (err as any).code) throw err; + console.error("Failed to fetch batch transaction statuses:", err); + throw createError(ERROR_CODES.INTERNAL_ERROR, null, { + error: "Failed to fetch batch transaction statuses", + }); + } +}; + export const listAmlAlertsHandler = async (req: Request, res: Response) => { try { const { status, userId, startDate, endDate } = req.query; diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 0d299395..8e880fc0 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -832,6 +832,26 @@ export class TransactionModel { return mapTransactionRow(result.rows[0]); } + async findByIds( + ids: string[], + userId?: string, + ): Promise<{ id: string; status: TransactionStatus }[]> { + const capped = ids.slice(0, 100); + let q = `SELECT id, status FROM transactions WHERE id = ANY($1)`; + const params: any[] = [capped]; + + if (userId) { + q += ` AND user_id = $2`; + params.push(userId); + } + + const res = await queryRead(q, params); + return res.rows.map((row) => ({ + id: String(row.id), + status: row.status as TransactionStatus, + })); + } + async findByStatusAndProvider( status: TransactionStatus, provider: string, diff --git a/src/openapi/paths/transactions.ts b/src/openapi/paths/transactions.ts index cb4671a1..6b7bed68 100644 --- a/src/openapi/paths/transactions.ts +++ b/src/openapi/paths/transactions.ts @@ -12,6 +12,8 @@ import { UpdateNotesRequestSchema, MetadataRequestSchema, DeleteMetadataKeysRequestSchema, + StatusBatchRequestSchema, + StatusBatchResponseSchema, } from '../schemas/transactions'; import { ErrorResponseSchema } from '../schemas/common'; @@ -91,6 +93,31 @@ registry.registerPath({ }, }); +// ─── Batch status query ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: 'post', + path: '/api/v1/transactions/status-batch', + tags: [TAG], + summary: 'Batch query transaction statuses', + description: 'Accepts up to 100 transaction IDs and returns their statuses. IDs not found or not owned by the requesting organization return null.', + security: SECURITY, + request: { + body: { + content: { 'application/json': { schema: StatusBatchRequestSchema } }, + required: true, + }, + }, + responses: { + 200: { + description: 'Map of transaction IDs to statuses', + content: { 'application/json': { schema: StatusBatchResponseSchema } }, + }, + 400: { description: 'Validation error', content: { 'application/json': { schema: ErrorResponseSchema } } }, + 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema } } }, + }, +}); + // ─── Get transaction ────────────────────────────────────────────────────────── registry.registerPath({ diff --git a/src/openapi/schemas/transactions.ts b/src/openapi/schemas/transactions.ts index ce4bc294..bbb27b62 100644 --- a/src/openapi/schemas/transactions.ts +++ b/src/openapi/schemas/transactions.ts @@ -85,6 +85,44 @@ export const TransactionListResponseSchema = registry.register( .openapi('TransactionListResponse'), ); +export const StatusBatchRequestSchema = registry.register( + 'StatusBatchRequest', + z + .object({ + ids: z + .array(z.string().uuid()) + .min(1) + .max(100) + .openapi({ + example: [ + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + ], + description: 'Array of transaction UUIDs (1-100)', + }), + }) + .openapi('StatusBatchRequest'), +); + +export const StatusBatchResponseSchema = registry.register( + 'StatusBatchResponse', + z + .object({ + statuses: z.record( + z.string().nullable(), + z.string().nullable(), + ).openapi({ + example: { + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890': 'completed', + 'b2c3d4e5-f6a7-8901-bcde-f12345678901': 'pending', + 'nonexistent-id-0000-0000-0000-000000000000': null, + }, + description: 'Map of transaction ID to status (null if not found or not owned by org)', + }), + }) + .openapi('StatusBatchResponse'), +); + export const UpdateNotesRequestSchema = registry.register( 'UpdateNotesRequest', z diff --git a/src/routes/v1/transactions.ts b/src/routes/v1/transactions.ts index 0ce07fe9..ae751e28 100644 --- a/src/routes/v1/transactions.ts +++ b/src/routes/v1/transactions.ts @@ -9,6 +9,7 @@ import { updateNotesHandler, searchTransactionsHandler, listTransactionsHandler, + statusBatchHandler, updateMetadataHandler, patchMetadataHandler, deleteMetadataKeysHandler, @@ -69,6 +70,16 @@ transactionRoutesV1.get( listTransactionsHandler, ); +// Batch status query +transactionRoutesV1.post( + "/status-batch", + requireAuth, + TimeoutPresets.quick, + haltOnTimedout, + setApiVersion("v1"), + statusBatchHandler, +); + // Get specific transaction transactionRoutesV1.get( "/aml/alerts",