Skip to content

Commit f25c364

Browse files
Copilotsyed-reza98
andcommitted
Address code review: replace CSV parser with papaparse, improve type safety and CUID validation
Co-authored-by: syed-reza98 <71028588+syed-reza98@users.noreply.github.com>
1 parent a5b94b5 commit f25c364

File tree

8 files changed

+101
-81
lines changed

8 files changed

+101
-81
lines changed

docs/api/PRODUCTS-API.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ Soft deletes a product by setting `deletedAt` timestamp. The product is also arc
356356
POST /api/products/import
357357
```
358358

359-
Bulk imports products from a CSV file.
359+
Bulk imports products from a CSV file using [papaparse](https://www.papaparse.com/) for robust CSV parsing that handles edge cases like multiline quoted fields, different line endings, and escaped characters.
360360

361361
#### Request
362362

@@ -365,7 +365,7 @@ Bulk imports products from a CSV file.
365365
| Field | Type | Required | Description |
366366
|-------|------|----------|-------------|
367367
| `file` | File | Yes | CSV file (max 10MB) |
368-
| `storeId` | string | Yes | Store ID |
368+
| `storeId` | string | Yes | Store ID (verified against user's organization membership) |
369369

370370
#### CSV Format
371371

@@ -380,15 +380,18 @@ Optional columns:
380380
- `brandId`
381381
- `inventoryQty`
382382
- `status`
383-
- `images` (comma-separated URLs)
383+
- `images` (comma-separated URLs or JSON array)
384384

385385
**Example CSV:**
386386
```csv
387387
name,sku,price,description,categoryId,inventoryQty,status
388388
"Product 1",SKU001,29.99,"Description here",clxcat123,100,DRAFT
389389
"Product 2",SKU002,49.99,"Another product",clxcat456,50,ACTIVE
390+
"Product with ""quotes""",SKU003,19.99,"Description with, comma",clxcat789,25,DRAFT
390391
```
391392

393+
> **Note:** The CSV parser handles quoted fields, escaped quotes (doubled `""`), and commas within quotes correctly.
394+
392395
#### Response
393396

394397
```json
@@ -599,6 +602,13 @@ Variant options are stored as JSON:
599602

600603
## Changelog
601604

605+
### v1.1.0 (Code Review Improvements)
606+
- **CSV Import**: Replaced custom CSV parser with [papaparse](https://www.papaparse.com/) library for robust handling of edge cases (multiline quoted fields, different line endings, escaped characters)
607+
- **Type Safety**: Improved Zod error handling using proper `ZodIssue` type
608+
- **Store ID Validation**: Enhanced CUID format validation for store IDs with support for both CUID and CUID2 formats
609+
- **Documentation**: Added transaction guarantee documentation for variant/attribute updates
610+
- **Migration**: Added documentation for Product_sku_idx index removal (replaced by multi-tenant composite unique constraint)
611+
602612
### v1.0.0 (Initial Release)
603613
- Product CRUD operations
604614
- Variant management (0-100 variants per product)

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"next-auth": "^4.24.13",
6565
"next-themes": "^0.4.6",
6666
"nodemailer": "^7.0.10",
67+
"papaparse": "^5.5.3",
6768
"prisma": "^6.19.0",
6869
"react": "19.2.0",
6970
"react-dom": "19.2.0",
@@ -78,6 +79,7 @@
7879
"devDependencies": {
7980
"@tailwindcss/postcss": "^4",
8081
"@types/node": "^20",
82+
"@types/papaparse": "^5.5.0",
8183
"@types/react": "^19",
8284
"@types/react-dom": "^19",
8385
"babel-plugin-react-compiler": "1.0.0",
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
1-
-- DropIndex
1+
-- Note: Product_sku_idx index was dropped because multi-tenant SKU uniqueness
2+
-- is now enforced via @@unique([storeId, sku]) constraint on the Product model.
3+
-- This ensures SKUs are unique per store, not globally, which is the correct
4+
-- behavior for a multi-tenant e-commerce platform.
25
DROP INDEX "Product_sku_idx";

src/app/api/products/import/route.ts

Lines changed: 24 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { getServerSession } from 'next-auth/next';
66
import { authOptions } from '@/lib/auth';
77
import { verifyStoreAccess } from '@/lib/get-current-user';
88
import { ProductService } from '@/lib/services/product.service';
9-
import { z } from 'zod';
9+
import { z, ZodIssue } from 'zod';
10+
import Papa from 'papaparse';
1011

1112
// Zod schema for CSV record validation
1213
const csvRecordSchema = z.object({
@@ -29,64 +30,6 @@ const csvRecordSchema = z.object({
2930
images: z.string().optional(),
3031
}).passthrough(); // Allow additional columns
3132

32-
// Simple CSV parser (handles quoted fields and commas within quotes)
33-
function parseCSV(text: string): Array<Record<string, string>> {
34-
const lines = text.split('\n').filter(line => line.trim());
35-
if (lines.length < 2) {
36-
return [];
37-
}
38-
39-
// Parse header
40-
const headers = parseCSVLine(lines[0]);
41-
42-
// Parse data rows
43-
const records: Array<Record<string, string>> = [];
44-
for (let i = 1; i < lines.length; i++) {
45-
const values = parseCSVLine(lines[i]);
46-
if (values.length > 0) {
47-
const record: Record<string, string> = {};
48-
headers.forEach((header, index) => {
49-
record[header.trim()] = values[index]?.trim() || '';
50-
});
51-
records.push(record);
52-
}
53-
}
54-
55-
return records;
56-
}
57-
58-
// Parse a single CSV line, handling quoted fields
59-
function parseCSVLine(line: string): string[] {
60-
const result: string[] = [];
61-
let current = '';
62-
let inQuotes = false;
63-
64-
for (let i = 0; i < line.length; i++) {
65-
const char = line[i];
66-
67-
if (char === '"') {
68-
if (inQuotes && line[i + 1] === '"') {
69-
// Escaped quote
70-
current += '"';
71-
i++;
72-
} else {
73-
// Toggle quote mode
74-
inQuotes = !inQuotes;
75-
}
76-
} else if (char === ',' && !inQuotes) {
77-
result.push(current);
78-
current = '';
79-
} else {
80-
current += char;
81-
}
82-
}
83-
84-
// Add last field
85-
result.push(current);
86-
87-
return result;
88-
}
89-
9033
// POST /api/products/import - Bulk import products from CSV
9134
export async function POST(request: NextRequest) {
9235
try {
@@ -136,9 +79,27 @@ export async function POST(request: NextRequest) {
13679
);
13780
}
13881

139-
// Read and parse CSV
82+
// Read and parse CSV using papaparse (handles edge cases like multiline quoted fields, different line endings, escaped characters)
14083
const text = await file.text();
141-
const records = parseCSV(text);
84+
const parseResult = Papa.parse<Record<string, string>>(text, {
85+
header: true,
86+
skipEmptyLines: true,
87+
transformHeader: (header: string) => header.trim(),
88+
transform: (value: string) => value.trim(),
89+
});
90+
91+
// Check for parsing errors
92+
if (parseResult.errors && parseResult.errors.length > 0) {
93+
const criticalErrors = parseResult.errors.filter(e => e.type === 'Quotes' || e.type === 'FieldMismatch');
94+
if (criticalErrors.length > 0) {
95+
return NextResponse.json({
96+
error: 'CSV parsing failed',
97+
details: criticalErrors.map(e => ({ row: e.row, message: e.message })),
98+
}, { status: 400 });
99+
}
100+
}
101+
102+
const records = parseResult.data;
142103

143104
if (records.length === 0) {
144105
return NextResponse.json(
@@ -178,10 +139,8 @@ export async function POST(request: NextRequest) {
178139
if (result.success) {
179140
validatedRecords.push(result.data);
180141
} else {
181-
// Zod 4 uses issues instead of errors
182-
const errorMessages = result.error.issues
183-
? result.error.issues.map((issue: { message: string }) => issue.message).join(', ')
184-
: 'Validation failed';
142+
// Use proper ZodIssue type from Zod for type-safe error handling
143+
const errorMessages = result.error.issues.map((issue: ZodIssue) => issue.message).join(', ');
185144
validationErrors.push({
186145
row: i + 1,
187146
error: errorMessages,

src/app/api/products/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,19 @@ export async function POST(request: NextRequest) {
9292

9393
const body = await request.json();
9494

95-
// Derive storeId from authenticated user's session or context
96-
// Security: storeId is NOT accepted from client input to prevent tenant spoofing
97-
// If user has a default store, use it; otherwise, look up storeId via membership/org
98-
const storeId = session.user.storeId; // Replace with correct property or lookup
95+
// Get storeId from request body
96+
// Security: storeId is accepted from client but VERIFIED against user's organization membership
97+
// via verifyStoreAccess() to prevent tenant spoofing
98+
const storeId = body.storeId as string | undefined;
9999

100100
if (!storeId) {
101101
return NextResponse.json(
102-
{ error: 'No store associated with your account. Cannot create product.' },
102+
{ error: 'storeId is required' },
103103
{ status: 400 }
104104
);
105105
}
106106

107-
// Verify user has access to this store
107+
// Verify user has access to this store (prevents tenant spoofing)
108108
const hasStoreAccess = await verifyStoreAccess(storeId);
109109
if (!hasStoreAccess) {
110110
return NextResponse.json(

src/app/api/products/upload/route.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,25 @@ const ALLOWED_TYPES = [
2424
];
2525

2626
// Validate storeId to prevent directory traversal attacks
27+
// Primary validation: CUID format (25 chars, starts with 'c', lowercase alphanumeric)
28+
// Fallback validation: general safe alphanumeric for backward compatibility
2729
function isValidStoreId(storeId: string): boolean {
28-
// storeId should be alphanumeric with optional hyphens (cuid format)
29-
return /^[a-zA-Z0-9-_]+$/.test(storeId) && storeId.length <= 100;
30+
// Standard CUID format: exactly 25 chars, starts with 'c', lowercase alphanumeric
31+
const cuidRegex = /^c[a-z0-9]{24}$/;
32+
33+
// CUID2 format: 21-24 lowercase alphanumeric characters
34+
const cuid2Regex = /^[a-z0-9]{21,24}$/;
35+
36+
// Fallback: safe alphanumeric pattern for legacy/custom IDs
37+
// Allows lowercase, digits, hyphens, and underscores (10-50 chars)
38+
// Does NOT allow uppercase, dots, slashes, or other special characters to prevent path traversal
39+
const safeIdRegex = /^[a-z0-9_-]{10,50}$/;
40+
41+
return (
42+
cuidRegex.test(storeId) ||
43+
cuid2Regex.test(storeId) ||
44+
safeIdRegex.test(storeId)
45+
);
3046
}
3147

3248
// Generate a unique filename

src/lib/services/product.service.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,14 @@ export class ProductService {
649649
throw new Error(`Duplicate variant SKUs in request: ${[...new Set(duplicates)].join(', ')}`);
650650
}
651651

652-
// Use a transaction to handle variant and attribute updates atomically
652+
/**
653+
* Transactional Guarantee:
654+
* Variant and attribute updates for a product are performed within a single transaction to ensure atomicity.
655+
* This means that either all changes to variants and attributes are applied together, or none are applied if any part fails.
656+
* This prevents inconsistent product state (e.g., variants updated but attributes not, or vice versa) which could lead to data integrity issues.
657+
* If any operation within the transaction fails (such as a constraint violation or a database error), all changes are rolled back.
658+
* Keeping these updates atomic is critical for e-commerce correctness, especially in multi-tenant environments.
659+
*/
653660
await prisma.$transaction(async (tx) => {
654661
// Delete variants that are no longer in the list
655662
if (variantIdsToDelete.length > 0) {
@@ -1483,8 +1490,9 @@ export class ProductService {
14831490
else if (statusUpper === 'ARCHIVED') status = ProductStatus.ARCHIVED;
14841491
}
14851492

1486-
// Build product data - schema will apply defaults for missing optional fields
1487-
const productData = {
1493+
// Build product data object with all required fields explicitly defined
1494+
// The createProductSchema has defaults, but we provide them here for type safety
1495+
const productData: CreateProductData = {
14881496
name: record.name,
14891497
sku: record.sku,
14901498
price,
@@ -1494,9 +1502,13 @@ export class ProductService {
14941502
inventoryQty: isNaN(inventoryQty) ? 0 : inventoryQty,
14951503
status,
14961504
images,
1505+
// Provide defaults for fields that have schema defaults
1506+
trackInventory: true,
1507+
lowStockThreshold: 5,
1508+
isFeatured: false,
14971509
};
14981510

1499-
await this.createProduct(storeId, productData as CreateProductData);
1511+
await this.createProduct(storeId, productData);
15001512
return { success: true, row: rowNumber };
15011513
} catch (error) {
15021514
return {

0 commit comments

Comments
 (0)