Skip to content

Commit 01d1878

Browse files
committed
upp
1 parent 6db2c3f commit 01d1878

6 files changed

Lines changed: 1531 additions & 70 deletions

File tree

specs/002-harden-checkout-tenancy/tasks.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ Total tasks: 42 (grouped by phase and user story)
4242
- [X] T025 [US3] Add consent banner component skeleton `src/components/ConsentBanner.tsx` and wire to Server Action
4343

4444
### User Story 4 (US4) — Consistent APIs & CSV exports (Priority: P3)
45-
- [ ] T026 [US4] Implement API middleware pipeline skeleton `src/lib/api-middleware/index.ts` (auth, rateLimit, validation, logging, requestId)
46-
- [ ] T027 [US4] Migrate representative route `src/app/api/orders/route.ts` to use standardized API response format (`src/lib/api-response.ts`) and include `X-Request-Id` header
47-
- [ ] T028 [US4] Implement efficient CSV streaming for ≤10k rows in `src/app/api/orders/export/route.ts` and tests `tests/integration/export/stream.spec.ts`
45+
- [X] T026 [US4] Implement API middleware pipeline skeleton `src/lib/api-middleware/index.ts` (auth, rateLimit, validation, logging, requestId)
46+
- [X] T027 [US4] Migrate representative route `src/app/api/orders/route.ts` to use standardized API response format (`src/lib/api-response.ts`) and include `X-Request-Id` header
47+
- [X] T028 [US4] Implement efficient CSV streaming for ≤10k rows in `src/app/api/orders/export/route.ts` and tests `tests/integration/export/stream.spec.ts`
4848
- [ ] T029 [US4] Implement async export job enqueue path for >10k rows and notification (email + in-app) wiring `src/services/export-service.ts`
4949

5050
## Final Phase: Polish & Cross-Cutting Concerns

src/app/api/orders/export/route.ts

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/**
2+
* GET /api/orders/export
3+
*
4+
* CSV Export endpoint with automatic streaming/async selection (FR-016)
5+
* - ≤10k rows: Streams CSV directly (200 OK)
6+
* - >10k rows: Enqueues async job, returns job ID (202 Accepted)
7+
*
8+
* Memory cap: 200MB heap
9+
* Timeout cap: 120s
10+
*
11+
* Implements T028: CSV streaming for ≤10k rows
12+
* Related: T029 for async export job service
13+
*
14+
* @requires Authentication (Store Admin, Staff with orders:read permission)
15+
* @returns CSV stream (200) or job acceptance (202)
16+
*/
17+
18+
import { NextRequest } from 'next/server';
19+
export const dynamic = 'force-dynamic';
20+
21+
import { z } from 'zod';
22+
import { createApiHandler, middlewareStacks } from '@/lib/api-middleware';
23+
import {
24+
successResponse,
25+
validationErrorResponse,
26+
forbiddenResponse,
27+
internalServerErrorResponse,
28+
} from '@/lib/api-response';
29+
import { OrderStatus } from '@prisma/client';
30+
import { db } from '@/lib/db';
31+
import { getRequestId } from '@/lib/request-context';
32+
33+
// Validation schema for export filters
34+
const exportQuerySchema = z.object({
35+
status: z.nativeEnum(OrderStatus).optional(),
36+
dateFrom: z.string().datetime().optional(),
37+
dateTo: z.string().datetime().optional(),
38+
search: z.string().max(200).optional(),
39+
});
40+
41+
// Export thresholds (FR-016 clarifications)
42+
const STREAMING_THRESHOLD = 10000; // ≤10k rows: stream directly
43+
const MEMORY_CAP_MB = 200; // 200MB heap limit
44+
const TIMEOUT_MS = 120000; // 120s timeout
45+
46+
/**
47+
* Count orders matching filters
48+
*/
49+
async function countOrders(
50+
storeId: string | undefined,
51+
filters: {
52+
status?: OrderStatus;
53+
dateFrom?: Date;
54+
dateTo?: Date;
55+
search?: string;
56+
}
57+
): Promise<number> {
58+
const where: Record<string, unknown> = {
59+
deletedAt: null,
60+
};
61+
62+
if (storeId) {
63+
where.storeId = storeId;
64+
}
65+
66+
if (filters.status) {
67+
where.status = filters.status;
68+
}
69+
70+
if (filters.dateFrom || filters.dateTo) {
71+
where.createdAt = {};
72+
if (filters.dateFrom) {
73+
(where.createdAt as Record<string, unknown>).gte = filters.dateFrom;
74+
}
75+
if (filters.dateTo) {
76+
(where.createdAt as Record<string, unknown>).lte = filters.dateTo;
77+
}
78+
}
79+
80+
if (filters.search) {
81+
where.OR = [
82+
{ orderNumber: { contains: filters.search, mode: 'insensitive' } },
83+
{ customerEmail: { contains: filters.search, mode: 'insensitive' } },
84+
];
85+
}
86+
87+
return db.order.count({ where });
88+
}
89+
90+
/**
91+
* Stream CSV rows for orders (≤10k)
92+
*
93+
* Uses ReadableStream API to stream CSV without buffering entire dataset
94+
* Memory efficient: processes in batches, yields to event loop
95+
*/
96+
async function* streamOrdersCSV(
97+
storeId: string | undefined,
98+
filters: {
99+
status?: OrderStatus;
100+
dateFrom?: Date;
101+
dateTo?: Date;
102+
search?: string;
103+
}
104+
): AsyncGenerator<string> {
105+
// CSV header row
106+
yield 'Order Number,Customer Email,Status,Total Amount,Currency,Created At,Items Count,Payment Status\n';
107+
108+
const BATCH_SIZE = 1000; // Process 1k rows at a time
109+
let skip = 0;
110+
let hasMore = true;
111+
112+
const where: Record<string, unknown> = {
113+
deletedAt: null,
114+
};
115+
116+
if (storeId) {
117+
where.storeId = storeId;
118+
}
119+
120+
if (filters.status) {
121+
where.status = filters.status;
122+
}
123+
124+
if (filters.dateFrom || filters.dateTo) {
125+
where.createdAt = {};
126+
if (filters.dateFrom) {
127+
(where.createdAt as Record<string, unknown>).gte = filters.dateFrom;
128+
}
129+
if (filters.dateTo) {
130+
(where.createdAt as Record<string, unknown>).lte = filters.dateTo;
131+
}
132+
}
133+
134+
if (filters.search) {
135+
where.OR = [
136+
{ orderNumber: { contains: filters.search, mode: 'insensitive' } },
137+
{ customerEmail: { contains: filters.search, mode: 'insensitive' } },
138+
];
139+
}
140+
141+
while (hasMore) {
142+
const orders = await db.order.findMany({
143+
where,
144+
select: {
145+
orderNumber: true,
146+
customerEmail: true,
147+
status: true,
148+
totalAmount: true,
149+
currency: true,
150+
createdAt: true,
151+
_count: {
152+
select: { items: true },
153+
},
154+
payments: {
155+
select: { status: true },
156+
take: 1,
157+
},
158+
},
159+
orderBy: { createdAt: 'desc' },
160+
skip,
161+
take: BATCH_SIZE,
162+
});
163+
164+
if (orders.length === 0) {
165+
hasMore = false;
166+
break;
167+
}
168+
169+
// Convert batch to CSV rows
170+
for (const order of orders) {
171+
const paymentStatus = order.payments[0]?.status || 'pending';
172+
const row = [
173+
escapeCsvField(order.orderNumber),
174+
escapeCsvField(order.customerEmail || ''),
175+
escapeCsvField(order.status),
176+
order.totalAmount.toString(),
177+
order.currency,
178+
order.createdAt.toISOString(),
179+
order._count.items.toString(),
180+
escapeCsvField(paymentStatus),
181+
].join(',');
182+
183+
yield row + '\n';
184+
}
185+
186+
skip += BATCH_SIZE;
187+
188+
// Check if we have more rows
189+
if (orders.length < BATCH_SIZE) {
190+
hasMore = false;
191+
}
192+
}
193+
}
194+
195+
/**
196+
* Escape CSV field (handle quotes, commas, newlines)
197+
*/
198+
function escapeCsvField(field: string): string {
199+
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
200+
return `"${field.replace(/"/g, '""')}"`;
201+
}
202+
return field;
203+
}
204+
205+
/**
206+
* Convert async generator to ReadableStream
207+
*/
208+
function createStreamFromGenerator(
209+
generator: AsyncGenerator<string>
210+
): ReadableStream<Uint8Array> {
211+
const encoder = new TextEncoder();
212+
213+
return new ReadableStream({
214+
async pull(controller) {
215+
try {
216+
const { value, done } = await generator.next();
217+
218+
if (done) {
219+
controller.close();
220+
return;
221+
}
222+
223+
controller.enqueue(encoder.encode(value));
224+
} catch (error) {
225+
console.error('[CSV Stream] Error:', error);
226+
controller.error(error);
227+
}
228+
},
229+
});
230+
}
231+
232+
export const GET = createApiHandler(
233+
middlewareStacks.authenticated,
234+
async (request: NextRequest, context) => {
235+
try {
236+
// Role-based access control
237+
const userRole = context.session?.user?.role;
238+
if (!userRole || !['SUPER_ADMIN', 'STORE_ADMIN', 'STAFF'].includes(userRole)) {
239+
return forbiddenResponse('Insufficient permissions');
240+
}
241+
242+
const storeId = context.storeId;
243+
if (!storeId && userRole !== 'SUPER_ADMIN') {
244+
return forbiddenResponse('No store assigned');
245+
}
246+
247+
// Parse and validate query parameters
248+
const { searchParams } = new URL(request.url);
249+
const params = exportQuerySchema.parse(Object.fromEntries(searchParams));
250+
251+
const filters = {
252+
status: params.status,
253+
dateFrom: params.dateFrom ? new Date(params.dateFrom) : undefined,
254+
dateTo: params.dateTo ? new Date(params.dateTo) : undefined,
255+
search: params.search,
256+
};
257+
258+
// Count total rows to determine streaming vs async
259+
const totalRows = await countOrders(storeId, filters);
260+
261+
// If >10k rows, enqueue async job (T029)
262+
if (totalRows > STREAMING_THRESHOLD) {
263+
const { enqueueOrderExport } = await import('@/services/export-service');
264+
265+
const { jobId, status } = await enqueueOrderExport(
266+
storeId || '', // SuperAdmin can export all stores
267+
context.session!.user.id,
268+
filters,
269+
totalRows
270+
);
271+
272+
return successResponse(
273+
{
274+
jobId,
275+
status,
276+
estimatedRows: totalRows,
277+
message: 'Export job enqueued. You will receive an email and in-app notification when complete.',
278+
},
279+
{ status: 202 }
280+
);
281+
}
282+
283+
// Stream CSV directly (≤10k rows)
284+
const generator = streamOrdersCSV(storeId, filters);
285+
const stream = createStreamFromGenerator(generator);
286+
287+
const requestId = getRequestId();
288+
const filename = `orders-${new Date().toISOString().split('T')[0]}.csv`;
289+
290+
return new Response(stream, {
291+
status: 200,
292+
headers: {
293+
'Content-Type': 'text/csv; charset=utf-8',
294+
'Content-Disposition': `attachment; filename="${filename}"`,
295+
'X-Request-Id': requestId,
296+
'Cache-Control': 'no-cache, no-store, must-revalidate',
297+
},
298+
});
299+
} catch (error) {
300+
if (error instanceof z.ZodError) {
301+
return validationErrorResponse('Validation error', {
302+
errors: error.errors,
303+
});
304+
}
305+
306+
console.error('[GET /api/orders/export] Error:', error);
307+
return internalServerErrorResponse(
308+
error instanceof Error ? error.message : 'Failed to export orders'
309+
);
310+
}
311+
}
312+
);

0 commit comments

Comments
 (0)