Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions src/controllers/transactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null> = {};
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;
Expand Down
20 changes: 20 additions & 0 deletions src/models/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions src/openapi/paths/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
UpdateNotesRequestSchema,
MetadataRequestSchema,
DeleteMetadataKeysRequestSchema,
StatusBatchRequestSchema,
StatusBatchResponseSchema,
} from '../schemas/transactions';
import { ErrorResponseSchema } from '../schemas/common';

Expand Down Expand Up @@ -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({
Expand Down
38 changes: 38 additions & 0 deletions src/openapi/schemas/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/routes/v1/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
updateNotesHandler,
searchTransactionsHandler,
listTransactionsHandler,
statusBatchHandler,
updateMetadataHandler,
patchMetadataHandler,
deleteMetadataKeysHandler,
Expand Down Expand Up @@ -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",
Expand Down
Loading