From 1e2ebd3e9f3311ba77020269ea4c117f013a54dc Mon Sep 17 00:00:00 2001 From: archit ravikumar Date: Thu, 13 Aug 2026 21:32:46 +0530 Subject: [PATCH 1/2] Fix pricing double-discount bug, dashboard N+1 query, add duplicate order feature --- app/api/orders/[id]/duplicate/route.ts | 108 ++++++++++++++++++ components/dashboard/DuplicateOrderButton.tsx | 89 +++++++++++++++ components/dashboard/OrderCard.tsx | 3 +- lib/orders/queries.ts | 20 +--- lib/pricing/calculator.ts | 4 - next-env.d.ts | 4 +- package-lock.json | 38 +++--- tests/pricing.test.ts | 70 ++++++++++++ 8 files changed, 293 insertions(+), 43 deletions(-) create mode 100644 app/api/orders/[id]/duplicate/route.ts create mode 100644 components/dashboard/DuplicateOrderButton.tsx create mode 100644 tests/pricing.test.ts diff --git a/app/api/orders/[id]/duplicate/route.ts b/app/api/orders/[id]/duplicate/route.ts new file mode 100644 index 0000000..50f8363 --- /dev/null +++ b/app/api/orders/[id]/duplicate/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth/session'; +import { getOrderById } from '@/lib/orders/queries'; +import type { ApiResponse, Product } from '@/types'; + +interface DuplicateOrderItem { + product: Product; + quantity: number; +} + +interface DuplicateOrderResult { + items: DuplicateOrderItem[]; + skipped: { name: string; reason: string }[]; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json>( + { data: null, error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await params; + const order = await getOrderById(id); + + if (!order) { + return NextResponse.json>( + { data: null, error: 'Order not found' }, + { status: 404 } + ); + } + + if (order.userId !== session.id) { + return NextResponse.json>( + { data: null, error: 'Forbidden' }, + { status: 403 } + ); + } + + // Re-fetch current product state — stock/price/availability may have + // changed since the order was originally placed. + const items: DuplicateOrderItem[] = []; + const skipped: { name: string; reason: string }[] = []; + + for (const orderItem of order.items) { + const product = orderItem.product; + + if (!product || !product.active) { + skipped.push({ + name: orderItem.product.name, + reason: 'No longer available', + }); + continue; + } + + if (product.stock <= 0) { + skipped.push({ name: product.name, reason: 'Out of stock' }); + continue; + } + + const quantity = Math.min(orderItem.quantity, product.stock); + if (quantity < orderItem.quantity) { + skipped.push({ + name: product.name, + reason: `Only ${product.stock} left — added what's available`, + }); + } + + items.push({ + product: { + id: product.id, + name: product.name, + description: product.description, + priceCents: product.priceCents, + stock: product.stock, + category: product.category, + imageUrl: product.imageUrl, + active: product.active, + }, + quantity, + }); + } + + if (items.length === 0) { + return NextResponse.json>( + { data: null, error: 'None of the items in this order are still available' }, + { status: 400 } + ); + } + + return NextResponse.json>({ + data: { items, skipped }, + error: null, + }); + } catch (err) { + console.error('[POST /api/orders/[id]/duplicate]', err); + return NextResponse.json>( + { data: null, error: 'Failed to duplicate order' }, + { status: 500 } + ); + } +} diff --git a/components/dashboard/DuplicateOrderButton.tsx b/components/dashboard/DuplicateOrderButton.tsx new file mode 100644 index 0000000..0229767 --- /dev/null +++ b/components/dashboard/DuplicateOrderButton.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import { useCartStore } from '@/store/cart'; +import { Button } from '@/components/ui/Button'; +import type { ApiResponse, Product } from '@/types'; + +interface DuplicateOrderButtonProps { + orderId: string; +} + +interface DuplicateOrderItem { + product: Product; + quantity: number; +} + +interface DuplicateOrderResult { + items: DuplicateOrderItem[]; + skipped: { name: string; reason: string }[]; +} + +export function DuplicateOrderButton({ orderId }: DuplicateOrderButtonProps) { + const { addItem } = useCartStore(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [added, setAdded] = useState(false); + + const handleDuplicate = async () => { + setLoading(true); + setError(null); + setNotice(null); + + try { + const res = await fetch(`/api/orders/${orderId}/duplicate`, { + method: 'POST', + }); + + const json: ApiResponse = await res.json(); + + if (!res.ok || json.error || !json.data) { + setError(json.error ?? 'Could not duplicate this order'); + return; + } + + json.data.items.forEach(({ product, quantity }) => { + addItem(product, quantity); + }); + + if (json.data.skipped.length > 0) { + setNotice( + json.data.skipped.map((s) => `${s.name} (${s.reason})`).join(', ') + ); + } + + setAdded(true); + setTimeout(() => setAdded(false), 2000); + } catch { + setError('Could not duplicate this order. Check your connection.'); + } finally { + setLoading(false); + } + }; + + return ( +
+ + {error && ( +

+ {error} +

+ )} + {notice && ( +

+ Skipped: {notice} +

+ )} +
+ ); +} diff --git a/components/dashboard/OrderCard.tsx b/components/dashboard/OrderCard.tsx index 586f046..58d398d 100644 --- a/components/dashboard/OrderCard.tsx +++ b/components/dashboard/OrderCard.tsx @@ -2,6 +2,7 @@ import { formatCents, formatDate, relativeDate, shortOrderId } from '@/lib/utils import { Badge } from '@/components/ui/Badge'; import { Card } from '@/components/ui/Card'; import { OrderItemRow } from './OrderItemRow'; +import { DuplicateOrderButton } from './DuplicateOrderButton'; import type { Order, OrderStatus } from '@/types'; interface OrderCardProps { @@ -67,7 +68,7 @@ export function OrderCard({ order }: OrderCardProps) { - {} + ); } diff --git a/lib/orders/queries.ts b/lib/orders/queries.ts index 472fda4..7139ef7 100644 --- a/lib/orders/queries.ts +++ b/lib/orders/queries.ts @@ -1,23 +1,15 @@ import { prisma } from '@/lib/db/client'; export async function listOrders(userId: string) { - - const orders = await prisma.order.findMany({ + return prisma.order.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, - }); - - const ordersWithItems = await Promise.all( - orders.map(async (order) => { - const items = await prisma.orderItem.findMany({ - where: { orderId: order.id }, + include: { + items: { include: { product: true }, - }); - return { ...order, items }; - }) - ); - - return ordersWithItems; + }, + }, + }); } export async function getOrderById(orderId: string) { diff --git a/lib/pricing/calculator.ts b/lib/pricing/calculator.ts index ce6e574..cc97231 100644 --- a/lib/pricing/calculator.ts +++ b/lib/pricing/calculator.ts @@ -35,10 +35,6 @@ export function computeOrderTotals(params: ComputeParams): PricingResult { shippingCents = 0; } - if (userTier === PRO_TIER && discountCode?.stackableWithFreeShipping) { - discountCents += STANDARD_SHIPPING_RATE; - } - const totalCents = Math.max(0, subtotalCents - discountCents + shippingCents); return { diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 8f52124..0086076 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -284,28 +285,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2527,6 +2506,7 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2537,6 +2517,7 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2596,6 +2577,7 @@ "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", @@ -3289,6 +3271,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3642,6 +3625,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -4336,6 +4320,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4521,6 +4506,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4901,6 +4887,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6760,6 +6747,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -6821,6 +6809,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6830,6 +6819,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7618,6 +7608,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7829,6 +7820,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7972,6 +7964,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -8703,6 +8696,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts new file mode 100644 index 0000000..d5bff53 --- /dev/null +++ b/tests/pricing.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { computeOrderTotals } from '@/lib/pricing/calculator'; +import { STANDARD_SHIPPING_RATE, FREE_SHIPPING_THRESHOLD_CENTS } from '@/lib/pricing/types'; + +describe('computeOrderTotals', () => { + it('charges standard shipping with no discount code and no free-shipping tier', () => { + const result = computeOrderTotals({ + subtotalCents: 10000, + discountCode: null, + userTier: 'standard', + }); + + expect(result.shippingCents).toBe(STANDARD_SHIPPING_RATE); + expect(result.discountCents).toBe(0); + expect(result.totalCents).toBe(10000 + STANDARD_SHIPPING_RATE); + }); + + it('does not go negative or double-discount when a Pro user (already over the free-shipping threshold) applies a stackable free-shipping code', () => { + const subtotalCents = FREE_SHIPPING_THRESHOLD_CENTS + 10000; + + const result = computeOrderTotals({ + subtotalCents, + discountCode: { + discountType: 'FIXED', + value: 0, + stackableWithFreeShipping: true, + }, + userTier: 'pro', + }); + + // Shipping should simply be free — not "free and then discounted again". + expect(result.shippingCents).toBe(0); + expect(result.discountCents).toBe(0); + expect(result.totalCents).toBe(subtotalCents); + }); + + it('gives a Pro user free shipping via a stackable code even under the threshold', () => { + const result = computeOrderTotals({ + subtotalCents: 5000, + discountCode: { + discountType: 'FIXED', + value: 0, + stackableWithFreeShipping: true, + }, + userTier: 'pro', + }); + + expect(result.shippingCents).toBe(0); + expect(result.totalCents).toBe(5000); + }); + + it('combines a percentage discount with free shipping without double-counting', () => { + const subtotalCents = FREE_SHIPPING_THRESHOLD_CENTS + 10000; + + const result = computeOrderTotals({ + subtotalCents, + discountCode: { + discountType: 'PERCENTAGE', + value: 1000, // 10% in basis points + stackableWithFreeShipping: true, + }, + userTier: 'pro', + }); + + const expectedDiscount = Math.round((subtotalCents * 1000) / 10000); + expect(result.shippingCents).toBe(0); + expect(result.discountCents).toBe(expectedDiscount); + expect(result.totalCents).toBe(subtotalCents - expectedDiscount); + }); +}); From 8335be2927cdc7c5c124d08eac5e1945830e1c12 Mon Sep 17 00:00:00 2001 From: archit ravikumar Date: Thu, 13 Aug 2026 21:37:13 +0530 Subject: [PATCH 2/2] Add AI_LOG.md --- AI_LOG.md | 66 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/AI_LOG.md b/AI_LOG.md index 718f187..59e51b0 100644 --- a/AI_LOG.md +++ b/AI_LOG.md @@ -1,25 +1,45 @@ # AI Usage Log -Please be honest and detailed about your AI usage. Using AI is perfectly fine and expected, but we want to understand your workflow! - -## Tools Used -*(e.g., GitHub Copilot, ChatGPT, Claude, Cursor, etc.)* - -- -- - -## How did you use AI for this assessment? - -**Task 1: Pricing Logic Bug** -- *Did you use AI to find the bug? If so, what prompt did you use?* -- *Did you use AI to write the fix?* - -**Task 2: Dashboard Performance** -- *How did you use AI here? Did you use it to identify the N+1 issue, or just to write the optimized query?* - -**Task 3: Duplicate Order Feature** -- *Did you use AI to write the API route, the client component, or both?* -- *Did AI make any mistakes you had to fix manually?* - -## General Comments -*(Any other thoughts on how AI helped or hindered you during this assessment?)* +## AI Tool Used +Claude (Anthropic) — used via chat to analyze the repo, locate bugs, and implement fixes. + +## Task 1: Pricing Logic Bug +Asked Claude to inspect the pricing computation logic. It found that in +lib/pricing/calculator.ts, when a Pro-tier user already qualified for free +shipping (subtotal over threshold) AND also applied a discount code marked +stackableWithFreeShipping, the code was zeroing shippingCents AND separately +adding STANDARD_SHIPPING_RATE to discountCents — double-counting the shipping +value and driving the total artificially low. Claude removed the redundant +discount addition since shippingCents=0 already reflects free shipping. +Verified with new unit tests in tests/pricing.test.ts covering standard +shipping, Pro + stackable code over/under threshold, and percentage discount +combined with free shipping — all pass. + +## Task 2: Dashboard Performance +Asked Claude to find the bottleneck in the /dashboard order history load. It +identified an N+1 query in lib/orders/queries.ts: listOrders() fetched all +orders in one query, then ran a separate orderItem.findMany() per order +inside Promise.all — scaling linearly with order count. Claude replaced this +with a single prisma.order.findMany() call using nested include for items +and product, matching the pattern already used correctly in getOrderById. + +## Task 3: Duplicate Order Feature +Asked Claude to design and implement a "Duplicate Order" button. It added: +- POST /api/orders/[id]/duplicate — validates session/ownership of the + order, re-checks current stock/availability for each product using the + live join already provided by getOrderById (avoiding an extra query), + skips inactive or out-of-stock items, clamps quantity to available stock, + and returns items to add plus a list of skipped items with reasons. +- components/dashboard/DuplicateOrderButton.tsx — client component that + calls the endpoint and pushes returned items into the existing Zustand + cart store via addItem(), with inline error/skipped-item messaging. +- Wired the button into the previously empty slot in OrderCard.tsx. + +## Remaining / Known Limitations +- Pre-existing lint issues in files unrelated to the three tasks (e.g. + setState-in-effect warnings in CartDrawer.tsx, Header.tsx, and + no-html-link-for-pages in a couple of pages) were left untouched, as + they were out of scope for this assignment. +- Verified locally with `npm run test` and `npm run lint`, and manually + confirmed all three fixes (pricing calculation, single-query dashboard + load, and end-to-end duplicate-order flow) via the dev server.