diff --git a/AI_LOG.md b/AI_LOG.md index 718f187..c5cf7f7 100644 --- a/AI_LOG.md +++ b/AI_LOG.md @@ -1,25 +1,44 @@ -# AI Usage Log +# 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.)* -- -- +- Codex (GPT-5.6 Sol, high reasoning) ## How did you use AI for this assessment? +I had already gone through the challenge requirements and understood the three problem areas before using Codex for the implementation work. I mainly used Codex for repository exploration, implementation assistance, and extensive testing and verification. + **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?* +- I used Codex to explore the relevant pricing code and help implement the fix in `computeOrderTotals`. +- The implementation keeps free shipping represented by `shippingCents = 0` without applying the same shipping benefit again through `discountCents`. +- Codex was used heavily for regression testing. Tests were added for the seeded `PROSHIP15` scenario and related combinations involving Pro/standard users, the free-shipping threshold, and normal paid shipping. **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?* +- I used Codex to explore the dashboard/order query flow and help implement the optimized Prisma query. +- The final implementation loads orders with their items/products without per-order item queries, applies the required 30-day order-history window, preserves newest-first ordering, and adds a compound `(userId, createdAt)` index. +- Codex helped create and run a focused test that verifies the query shape, history cutoff, relation loading, ordering, and that the N+1 pattern is removed. **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?* +- I used Codex to help implement both the API route and the client-side dashboard action. +- The API follows the existing authentication and ownership behavior, validates current product availability and stock, and preserves the existing `{ data, error }` response format. +- The client integrates with the existing Zustand cart and merges duplicate-order items into the current cart instead of replacing it. +- Codex was also used to create tests for authentication, ownership checks, successful duplication, unavailable products, and stock handling. + +## Testing and Verification + +Testing was the main area where I relied on Codex. It helped write the regression tests, run the project checks, inspect failures, and verify the final implementation after the changes were committed. + +The final verification included: + +- `npm test` — 9/9 tests passed +- `npx tsc --noEmit` — passed +- focused ESLint checks on the changed source/test files — passed +- `npx prisma validate` — passed +- `npm run build` — production build passed +- `git diff --check` — passed ## General Comments -*(Any other thoughts on how AI helped or hindered you during this assessment?)* + +Codex was most useful for quickly navigating the repository, assisting with implementation, and giving the changes a much more thorough testing pass. I reviewed the resulting changes and test output before keeping them. diff --git a/app/api/orders/[id]/duplicate/route.ts b/app/api/orders/[id]/duplicate/route.ts new file mode 100644 index 0000000..3b8d2e5 --- /dev/null +++ b/app/api/orders/[id]/duplicate/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth/session'; +import { getOrderById } from '@/lib/orders/queries'; +import type { ApiResponse, DuplicateOrderResult, Product } from '@/types'; + +function toClientProduct(product: { + id: string; + name: string; + description: string; + priceCents: number; + stock: number; + category: string; + imageUrl: string | null; + active: boolean; +}): Product { + return { + id: product.id, + name: product.name, + description: product.description, + priceCents: product.priceCents, + stock: product.stock, + category: product.category, + imageUrl: product.imageUrl, + active: product.active, + }; +} + +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 } + ); + } + + const result: DuplicateOrderResult = { + items: [], + skippedItems: [], + }; + + for (const item of order.items) { + const { product } = item; + + if (!product.active) { + result.skippedItems.push({ + productId: product.id, + name: product.name, + reason: 'inactive', + requestedQuantity: item.quantity, + availableQuantity: product.stock, + }); + continue; + } + + if (product.stock < item.quantity) { + result.skippedItems.push({ + productId: product.id, + name: product.name, + reason: 'insufficient_stock', + requestedQuantity: item.quantity, + availableQuantity: product.stock, + }); + continue; + } + + result.items.push({ + product: toClientProduct(product), + quantity: item.quantity, + }); + } + + return NextResponse.json>({ + data: result, + 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..c18b2a1 --- /dev/null +++ b/components/dashboard/DuplicateOrderButton.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { useCartStore } from '@/store/cart'; +import type { ApiResponse, DuplicateOrderResult } from '@/types'; + +interface DuplicateOrderButtonProps { + orderId: string; +} + +export function DuplicateOrderButton({ orderId }: DuplicateOrderButtonProps) { + const addItems = useCartStore((state) => state.addItems); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + async function handleDuplicate() { + setLoading(true); + setMessage(null); + setError(null); + + try { + const response = await fetch(`/api/orders/${orderId}/duplicate`, { + method: 'POST', + }); + const result = (await response.json()) as ApiResponse; + + if (!response.ok || !result.data) { + setError(result.error ?? 'Could not duplicate this order.'); + return; + } + + addItems(result.data.items); + + const addedCount = result.data.items.reduce((sum, item) => sum + item.quantity, 0); + const skippedCount = result.data.skippedItems.length; + + if (addedCount === 0) { + setMessage('None of the items in this order are currently available.'); + } else if (skippedCount > 0) { + setMessage( + `Added ${addedCount} item${addedCount === 1 ? '' : 's'} to cart; ${skippedCount} unavailable product${skippedCount === 1 ? '' : 's'} skipped.` + ); + } else { + setMessage(`Added ${addedCount} item${addedCount === 1 ? '' : 's'} to cart.`); + } + } catch { + setError('Could not duplicate this order. Please try again.'); + } finally { + setLoading(false); + } + } + + return ( +
+ + {message && ( +

+ {message} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/dashboard/OrderCard.tsx b/components/dashboard/OrderCard.tsx index 586f046..bfdc97b 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,9 @@ export function OrderCard({ order }: OrderCardProps) { - {} +
+ +
); } diff --git a/lib/orders/queries.ts b/lib/orders/queries.ts index 472fda4..180ec6c 100644 --- a/lib/orders/queries.ts +++ b/lib/orders/queries.ts @@ -1,23 +1,22 @@ import { prisma } from '@/lib/db/client'; +const DASHBOARD_HISTORY_DAYS = 30; + export async function listOrders(userId: string) { - - const orders = await prisma.order.findMany({ - where: { userId }, - orderBy: { createdAt: 'desc' }, - }); + const historyStart = new Date(Date.now() - DASHBOARD_HISTORY_DAYS * 24 * 60 * 60 * 1000); - const ordersWithItems = await Promise.all( - orders.map(async (order) => { - const items = await prisma.orderItem.findMany({ - where: { orderId: order.id }, + return prisma.order.findMany({ + where: { + userId, + createdAt: { gte: historyStart }, + }, + orderBy: { createdAt: 'desc' }, + 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..2267bad 100644 --- a/lib/pricing/calculator.ts +++ b/lib/pricing/calculator.ts @@ -35,10 +35,8 @@ export function computeOrderTotals(params: ComputeParams): PricingResult { shippingCents = 0; } - if (userTier === PRO_TIER && discountCode?.stackableWithFreeShipping) { - discountCents += STANDARD_SHIPPING_RATE; - } - + // Free shipping is represented only by shippingCents = 0. Crediting the + // shipping rate again through discountCents would apply the same benefit twice. const totalCents = Math.max(0, subtotalCents - discountCents + shippingCents); return { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52d4e04..3ad88c5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,7 +67,7 @@ model Order { discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id]) items OrderItem[] - @@index([userId]) + @@index([userId, createdAt]) @@index([createdAt]) } diff --git a/store/cart.ts b/store/cart.ts index 77094e3..67e4e18 100644 --- a/store/cart.ts +++ b/store/cart.ts @@ -4,12 +4,13 @@ import type { CartItem, Product } from '@/types'; interface CartState { items: CartItem[]; - + addItem: (product: Product, quantity?: number) => void; + addItems: (items: CartItem[]) => void; removeItem: (productId: string) => void; updateQuantity: (productId: string, quantity: number) => void; clearCart: () => void; - + itemCount: () => number; subtotalCents: () => number; } @@ -35,6 +36,35 @@ export const useCartStore = create()( }); }, + addItems: (newItems: CartItem[]) => { + set((state) => { + const additions = new Map(); + + for (const item of newItems) { + const queued = additions.get(item.product.id); + additions.set(item.product.id, { + product: item.product, + quantity: (queued?.quantity ?? 0) + item.quantity, + }); + } + + const merged = state.items.map((item) => { + const addition = additions.get(item.product.id); + if (!addition) return item; + + additions.delete(item.product.id); + return { + product: addition.product, + quantity: item.quantity + addition.quantity, + }; + }); + + return { + items: [...merged, ...additions.values()], + }; + }); + }, + removeItem: (productId: string) => { set((state) => ({ items: state.items.filter((i) => i.product.id !== productId), diff --git a/tests/duplicate-order.test.ts b/tests/duplicate-order.test.ts new file mode 100644 index 0000000..c5124d3 --- /dev/null +++ b/tests/duplicate-order.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { POST } from '@/app/api/orders/[id]/duplicate/route'; +import { getSession } from '@/lib/auth/session'; +import { getOrderById } from '@/lib/orders/queries'; + +vi.mock('@/lib/auth/session', () => ({ + getSession: vi.fn(), +})); + +vi.mock('@/lib/orders/queries', () => ({ + getOrderById: vi.fn(), +})); + +const mockGetSession = vi.mocked(getSession); +const mockGetOrderById = vi.mocked(getOrderById); + +const session = { + id: 'user-1', + email: 'user@example.com', + name: 'User One', + tier: 'pro' as const, + createdAt: '2026-01-01T00:00:00.000Z', +}; + +function request(orderId: string) { + return new NextRequest(`http://localhost/api/orders/${orderId}/duplicate`, { + method: 'POST', + }); +} + +function product(overrides: Partial<{ + id: string; + name: string; + description: string; + priceCents: number; + stock: number; + category: string; + imageUrl: string | null; + active: boolean; +}> = {}) { + return { + id: 'product-1', + name: 'Product One', + description: 'Test product', + priceCents: 100_000, + stock: 10, + category: 'gear', + imageUrl: null, + active: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, + }; +} + +function order(overrides: Record = {}) { + return { + id: 'order-1', + userId: 'user-1', + status: 'delivered' as const, + subtotalCents: 300_000, + discountCents: 0, + shippingCents: 0, + totalCents: 300_000, + discountCodeId: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + discountCode: null, + items: [ + { + id: 'item-1', + orderId: 'order-1', + productId: 'product-1', + quantity: 2, + priceAtPurchase: 100_000, + product: product(), + }, + ], + ...overrides, + }; +} + +describe('POST /api/orders/[id]/duplicate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 401 when the user is not authenticated', async () => { + mockGetSession.mockResolvedValue(null); + + const response = await POST(request('order-1'), { + params: Promise.resolve({ id: 'order-1' }), + }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ data: null, error: 'Unauthorized' }); + }); + + it('returns 403 when the order belongs to another user', async () => { + mockGetSession.mockResolvedValue(session); + mockGetOrderById.mockResolvedValue( + order({ userId: 'user-2' }) as Awaited> + ); + + const response = await POST(request('order-1'), { + params: Promise.resolve({ id: 'order-1' }), + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ data: null, error: 'Forbidden' }); + }); + + it('returns available items and skips inactive or insufficient-stock products', async () => { + mockGetSession.mockResolvedValue(session); + mockGetOrderById.mockResolvedValue( + order({ + items: [ + { + id: 'item-1', + orderId: 'order-1', + productId: 'product-1', + quantity: 2, + priceAtPurchase: 100_000, + product: product(), + }, + { + id: 'item-2', + orderId: 'order-1', + productId: 'product-2', + quantity: 1, + priceAtPurchase: 80_000, + product: product({ id: 'product-2', name: 'Inactive', active: false }), + }, + { + id: 'item-3', + orderId: 'order-1', + productId: 'product-3', + quantity: 3, + priceAtPurchase: 90_000, + product: product({ id: 'product-3', name: 'Low Stock', stock: 2 }), + }, + ], + }) as Awaited> + ); + + const response = await POST(request('order-1'), { + params: Promise.resolve({ id: 'order-1' }), + }); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.error).toBeNull(); + expect(json.data.items).toEqual([ + { + product: { + id: 'product-1', + name: 'Product One', + description: 'Test product', + priceCents: 100_000, + stock: 10, + category: 'gear', + imageUrl: null, + active: true, + }, + quantity: 2, + }, + ]); + expect(json.data.skippedItems).toEqual([ + { + productId: 'product-2', + name: 'Inactive', + reason: 'inactive', + requestedQuantity: 1, + availableQuantity: 10, + }, + { + productId: 'product-3', + name: 'Low Stock', + reason: 'insufficient_stock', + requestedQuantity: 3, + availableQuantity: 2, + }, + ]); + }); +}); diff --git a/tests/orders.test.ts b/tests/orders.test.ts new file mode 100644 index 0000000..2750121 --- /dev/null +++ b/tests/orders.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { prisma } from '@/lib/db/client'; +import { listOrders } from '@/lib/orders/queries'; + +vi.mock('@/lib/db/client', () => ({ + prisma: { + order: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, + orderItem: { + findMany: vi.fn(), + }, + }, +})); + +describe('listOrders', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-13T12:00:00.000Z')); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('loads only the last 30 days with items and products in one database query', async () => { + vi.mocked(prisma.order.findMany).mockResolvedValue([]); + + await listOrders('user-123'); + + expect(prisma.order.findMany).toHaveBeenCalledTimes(1); + expect(prisma.orderItem.findMany).not.toHaveBeenCalled(); + expect(prisma.order.findMany).toHaveBeenCalledWith({ + where: { + userId: 'user-123', + createdAt: { gte: new Date('2026-07-14T12:00:00.000Z') }, + }, + orderBy: { createdAt: 'desc' }, + include: { + items: { + include: { product: true }, + }, + }, + }); + }); +}); diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts new file mode 100644 index 0000000..2a7a8a9 --- /dev/null +++ b/tests/pricing.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { computeOrderTotals } from '@/lib/pricing/calculator'; +import { + FREE_SHIPPING_THRESHOLD_CENTS, + STANDARD_SHIPPING_RATE, +} from '@/lib/pricing/types'; + +const PROSHIP15 = { + discountType: 'PERCENTAGE' as const, + value: 1500, + stackableWithFreeShipping: true, +}; + +describe('computeOrderTotals', () => { + it('does not double-count shipping for a Pro user using PROSHIP15', () => { + const subtotalCents = 649_800; + const expectedDiscount = Math.round((subtotalCents * PROSHIP15.value) / 10_000); + + const result = computeOrderTotals({ + subtotalCents, + discountCode: PROSHIP15, + userTier: 'pro', + }); + + expect(result).toEqual({ + subtotalCents, + discountCents: expectedDiscount, + shippingCents: 0, + totalCents: subtotalCents - expectedDiscount, + }); + }); + + it('keeps a free-shipping code separate from the product discount below the Pro threshold', () => { + const subtotalCents = FREE_SHIPPING_THRESHOLD_CENTS - 1; + const expectedDiscount = Math.round((subtotalCents * PROSHIP15.value) / 10_000); + + const result = computeOrderTotals({ + subtotalCents, + discountCode: PROSHIP15, + userTier: 'pro', + }); + + expect(result.discountCents).toBe(expectedDiscount); + expect(result.shippingCents).toBe(0); + expect(result.totalCents).toBe(subtotalCents - expectedDiscount); + }); + + it('allows a stackable free-shipping code for a standard user without adding shipping to discountCents', () => { + const subtotalCents = 200_000; + const result = computeOrderTotals({ + subtotalCents, + discountCode: { + discountType: 'FIXED', + value: 50_000, + stackableWithFreeShipping: true, + }, + userTier: 'standard', + }); + + expect(result.discountCents).toBe(50_000); + expect(result.shippingCents).toBe(0); + expect(result.totalCents).toBe(150_000); + }); + + it('still charges standard shipping when no free-shipping benefit applies', () => { + const result = computeOrderTotals({ + subtotalCents: 100_000, + discountCode: null, + userTier: 'standard', + }); + + expect(result.discountCents).toBe(0); + expect(result.shippingCents).toBe(STANDARD_SHIPPING_RATE); + expect(result.totalCents).toBe(100_000 + STANDARD_SHIPPING_RATE); + }); +}); diff --git a/types/index.ts b/types/index.ts index 267138b..bec449b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -69,6 +69,19 @@ export interface CartItem { quantity: number; } +export interface DuplicateOrderSkippedItem { + productId: string; + name: string; + reason: 'inactive' | 'insufficient_stock'; + requestedQuantity: number; + availableQuantity: number; +} + +export interface DuplicateOrderResult { + items: CartItem[]; + skippedItems: DuplicateOrderSkippedItem[]; +} + // --------------------------------------------------------------------------- // API envelope — all routes return { data, error } // Never deviate from this shape: Task 1 tests that it's preserved.