diff --git a/AI_LOG.md b/AI_LOG.md index 718f187..a4ed805 100644 --- a/AI_LOG.md +++ b/AI_LOG.md @@ -5,21 +5,23 @@ Please be honest and detailed about your AI usage. Using AI is perfectly fine an ## Tools Used *(e.g., GitHub Copilot, ChatGPT, Claude, Cursor, etc.)* -- -- +- ChatGPT Codex (GPT-5.6) +- Command-line tools: Git, npm, Vitest, ESLint, TypeScript, Prisma and Next.js ## 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?* +- I gave ChatGPT Codex the full challenge brief and repository URL. It inspected the pricing calculator and identified that the Pro/free-shipping branch incorrectly added the standard shipping rate to `discountCents` after shipping was already zeroed. +- AI wrote the small fix and regression tests. I reviewed the expected totals for Pro, standard, and non-free-shipping cases. **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?* +- AI identified the N+1 database pattern: one query loaded all orders and then one additional query ran for every order. It replaced this with one Prisma `findMany` query using a nested `include`, and added a compound `(userId, createdAt DESC)` index matching the dashboard filter and sort. **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?* +- AI implemented both the authenticated API route and client component, plus a bulk cart-store action. The endpoint verifies order ownership and current product activity/stock, returns only fully available order lines, and reports skipped products. +- The implementation was validated with TypeScript, ESLint, Vitest, Prisma validation, and a Next.js production build. The initial npm install required changing the cache directory because the default cache was unavailable in the execution environment; this was an environment issue, not an application-code issue. ## General Comments *(Any other thoughts on how AI helped or hindered you during this assessment?)* + +AI accelerated repository exploration, implementation, test design, and documentation. I used the generated changes as a starting point and relied on automated checks to verify that the final code remained type-safe and buildable. diff --git a/README.md b/README.md index 15b2828..5cbf7a6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,38 @@ Welcome to OrbitStack! This is a modern, Next.js-based e-commerce platform built npm run dev ``` +4. Open [http://localhost:3000](http://localhost:3000). Use one of the seeded user accounts shown by `npm run seed` to sign in. + +## Local database setup + +Start PostgreSQL with Docker, configure the connection, create the schema, and seed sample data: + +```bash +docker compose up -d +cp .env.example .env +npx prisma db push +npm run seed +``` + +Run the verification suite with: + +```bash +npm test +npm run lint +npx tsc --noEmit +npm run build +``` + +## Implemented fixes + +- Corrected stacked free-shipping logic so shipping is waived exactly once and never becomes a product discount. +- Removed the dashboard's N+1 order-item queries by loading orders, items, and products in one Prisma query; added an index matching the user/date query. +- Added an authenticated **Duplicate Order** action. It verifies ownership and current inventory, adds available order lines to the Zustand cart in one update, and clearly reports unavailable lines. + +## Remaining limitations + +- Duplicate Order validates stock at click time. As with any cart, availability can change before checkout, where the existing order endpoint validates stock again. + ## The Challenge There are three tasks to complete. Please review the codebase and implement the fixes and features described below. diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index b72de32..bc3ccf7 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/Button'; const SEEDED_USERS = [ { email: 'alice@orbitstack.dev', name: 'Alice Nakamura', tier: 'Pro' }, diff --git a/app/api/orders/[id]/duplicate/route.ts b/app/api/orders/[id]/duplicate/route.ts new file mode 100644 index 0000000..e6705b3 --- /dev/null +++ b/app/api/orders/[id]/duplicate/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth/session'; +import { prisma } from '@/lib/db/client'; +import type { ApiResponse, CartItem } from '@/types'; + +interface DuplicateOrderResult { + items: CartItem[]; + unavailableItems: string[]; +} + +export async function POST( + _request: Request, + { 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 prisma.order.findFirst({ + where: { id, userId: session.id }, + select: { + items: { + select: { + quantity: true, + product: true, + }, + }, + }, + }); + + if (!order) { + return NextResponse.json>( + { data: null, error: 'Order not found' }, + { status: 404 } + ); + } + + const availableItems: CartItem[] = []; + const unavailableItems: string[] = []; + + for (const item of order.items) { + if (item.product.active && item.product.stock >= item.quantity) { + availableItems.push({ product: item.product, quantity: item.quantity }); + } else { + unavailableItems.push(item.product.name); + } + } + + if (availableItems.length === 0) { + return NextResponse.json>( + { data: null, error: 'None of the items in this order are currently available' }, + { status: 409 } + ); + } + + return NextResponse.json>({ + data: { items: availableItems, unavailableItems }, + error: null, + }); + } catch (error) { + console.error('[POST /api/orders/[id]/duplicate]', error); + return NextResponse.json>( + { data: null, error: 'Failed to duplicate order' }, + { status: 500 } + ); + } +} diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts index 7ad2874..619b1a2 100644 --- a/app/api/orders/route.ts +++ b/app/api/orders/route.ts @@ -5,7 +5,7 @@ import { listOrders } from '@/lib/orders/queries'; import { computeOrderTotals } from '@/lib/pricing/calculator'; import type { ApiResponse } from '@/types'; -export async function GET(request: NextRequest) { +export async function GET() { try { const session = await getSession(); if (!session) { diff --git a/app/cart/page.tsx b/app/cart/page.tsx index c2a5dfd..04c65e7 100644 --- a/app/cart/page.tsx +++ b/app/cart/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { CartItem } from '@/components/cart/CartItem'; import { CartSummaryPage } from '@/components/cart/CartSummaryPage'; export const metadata: Metadata = { diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index dd285a0..3934041 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import Link from 'next/link'; import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/session'; import { listOrders } from '@/lib/orders/queries'; @@ -43,9 +44,9 @@ export default async function DashboardPage() {
🌑

No orders yet.

- + Start shopping → - +
) : (
    @@ -59,4 +60,3 @@ export default async function DashboardPage() { ); } - diff --git a/app/products/page.tsx b/app/products/page.tsx index 2c3a5a1..3fcffda 100644 --- a/app/products/page.tsx +++ b/app/products/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import Link from 'next/link'; import { prisma } from '@/lib/db/client'; import { ProductGrid } from '@/components/products/ProductGrid'; import type { Product } from '@/types'; @@ -40,16 +41,16 @@ export default async function ProductsPage({ {}
    - All - + {categories.map((cat) => ( - {cat} - + ))}
    diff --git a/components/cart/CartDrawer.tsx b/components/cart/CartDrawer.tsx index 0b5a794..b094f34 100644 --- a/components/cart/CartDrawer.tsx +++ b/components/cart/CartDrawer.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; import { useCartStore } from '@/store/cart'; +import { useHydrated } from '@/lib/utils/useHydrated'; import { CartItem } from './CartItem'; import { CartSummary } from './CartSummary'; import { Button } from '@/components/ui/Button'; @@ -13,14 +13,10 @@ interface CartDrawerProps { } export function CartDrawer({ open, onClose }: CartDrawerProps) { - const [mounted, setMounted] = useState(false); + const mounted = useHydrated(); const { items, itemCount } = useCartStore(); const count = itemCount(); - useEffect(() => { - setMounted(true); - }, []); - const displayedItems = mounted ? items : []; const displayedCount = mounted ? count : 0; diff --git a/components/cart/CartSummaryPage.tsx b/components/cart/CartSummaryPage.tsx index 22a6786..3b47074 100644 --- a/components/cart/CartSummaryPage.tsx +++ b/components/cart/CartSummaryPage.tsx @@ -1,20 +1,16 @@ 'use client'; -import { useState, useEffect } from 'react'; import { useCartStore } from '@/store/cart'; +import { useHydrated } from '@/lib/utils/useHydrated'; import { CartItem } from './CartItem'; import { CartSummary } from './CartSummary'; import { Button } from '@/components/ui/Button'; import Link from 'next/link'; export function CartSummaryPage() { - const [mounted, setMounted] = useState(false); + const mounted = useHydrated(); const { items, clearCart } = useCartStore(); - useEffect(() => { - setMounted(true); - }, []); - const displayedItems = mounted ? items : []; if (displayedItems.length === 0) { diff --git a/components/checkout/CheckoutForm.tsx b/components/checkout/CheckoutForm.tsx index 11937a6..d2ca2c5 100644 --- a/components/checkout/CheckoutForm.tsx +++ b/components/checkout/CheckoutForm.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useCartStore } from '@/store/cart'; import { CartSummary } from '@/components/cart/CartSummary'; @@ -8,20 +9,17 @@ import { DiscountInput } from './DiscountInput'; import { Button } from '@/components/ui/Button'; import { formatCents } from '@/lib/utils/format'; import type { PricingResult } from '@/types'; +import { useHydrated } from '@/lib/utils/useHydrated'; export function CheckoutForm() { const router = useRouter(); - const [mounted, setMounted] = useState(false); + const mounted = useHydrated(); const { items, clearCart } = useCartStore(); const [pricing, setPricing] = useState(null); const [appliedCode, setAppliedCode] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - setMounted(true); - }, []); - const handleDiscountApplied = (code: string, result: PricingResult) => { setAppliedCode(code); setPricing(result); @@ -61,7 +59,7 @@ export function CheckoutForm() { if (!mounted || items.length === 0) { return (
    - Your cart is empty. Browse products + Your cart is empty. Browse products
    ); } diff --git a/components/dashboard/DuplicateOrderButton.tsx b/components/dashboard/DuplicateOrderButton.tsx new file mode 100644 index 0000000..2fda7fa --- /dev/null +++ b/components/dashboard/DuplicateOrderButton.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; +import { useCartStore } from '@/store/cart'; +import { Button } from '@/components/ui/Button'; +import type { ApiResponse, CartItem } from '@/types'; + +interface DuplicateOrderButtonProps { + orderId: string; +} + +interface DuplicateOrderResult { + items: CartItem[]; + unavailableItems: string[]; +} + +export function DuplicateOrderButton({ orderId }: DuplicateOrderButtonProps) { + const addItems = useCartStore((state) => state.addItems); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(null); + + async function duplicateOrder() { + setLoading(true); + setMessage(null); + + try { + const response = await fetch(`/api/orders/${orderId}/duplicate`, { method: 'POST' }); + const result = (await response.json()) as ApiResponse; + + if (!response.ok || !result.data) { + throw new Error(result.error ?? 'Unable to duplicate order'); + } + + addItems(result.data.items); + const skipped = result.data.unavailableItems.length; + setMessage( + skipped > 0 + ? `Added available items; ${skipped} unavailable item${skipped === 1 ? '' : 's'} skipped.` + : 'Order added to cart.' + ); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Unable to duplicate order'); + } finally { + setLoading(false); + } + } + + return ( +
    + + {message &&

    {message}

    } +
    + ); +} diff --git a/components/dashboard/OrderCard.tsx b/components/dashboard/OrderCard.tsx index 586f046..4419f25 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/components/layout/Header.tsx b/components/layout/Header.tsx index 1e72512..60286ab 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -1,20 +1,17 @@ 'use client'; import Link from 'next/link'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useCartStore } from '@/store/cart'; import { CartDrawer } from '@/components/cart/CartDrawer'; +import { useHydrated } from '@/lib/utils/useHydrated'; export function Header() { const [cartOpen, setCartOpen] = useState(false); - const [mounted, setMounted] = useState(false); + const mounted = useHydrated(); const { itemCount } = useCartStore(); const count = itemCount(); - useEffect(() => { - setMounted(true); - }, []); - return ( <>
    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/lib/utils/useHydrated.ts b/lib/utils/useHydrated.ts new file mode 100644 index 0000000..63c9d91 --- /dev/null +++ b/lib/utils/useHydrated.ts @@ -0,0 +1,9 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +const subscribe = () => () => {}; + +export function useHydrated() { + return useSyncExternalStore(subscribe, () => true, () => false); +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52d4e04..b9a2610 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,8 +67,7 @@ model Order { discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id]) items OrderItem[] - @@index([userId]) - @@index([createdAt]) + @@index([userId, createdAt(sort: Desc)]) } model OrderItem { diff --git a/prisma/seed.ts b/prisma/seed.ts index bb7a368..ab02f0f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -53,7 +53,7 @@ async function main() { console.log('✓ Users seeded'); // ── Discount codes ────────────────────────────────────────────────────────── - const [, proship15] = await Promise.all([ + await Promise.all([ prisma.discountCode.upsert({ where: { code: 'NEWUSER10' }, update: {}, diff --git a/store/cart.ts b/store/cart.ts index 77094e3..c07a437 100644 --- a/store/cart.ts +++ b/store/cart.ts @@ -6,6 +6,7 @@ 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; @@ -35,6 +36,21 @@ export const useCartStore = create()( }); }, + addItems: (newItems: CartItem[]) => { + set((state) => { + const items = [...state.items]; + for (const newItem of newItems) { + const existing = items.find((item) => item.product.id === newItem.product.id); + if (existing) { + existing.quantity += newItem.quantity; + } else { + items.push({ ...newItem }); + } + } + return { items }; + }); + }, + removeItem: (productId: string) => { set((state) => ({ items: state.items.filter((i) => i.product.id !== productId), diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts new file mode 100644 index 0000000..970bded --- /dev/null +++ b/tests/pricing.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { computeOrderTotals } from '@/lib/pricing/calculator'; +import { STANDARD_SHIPPING_RATE } from '@/lib/pricing/types'; + +const freeShippingCode = { + discountType: 'FIXED' as const, + value: 0, + stackableWithFreeShipping: true, +}; + +describe('computeOrderTotals', () => { + it('does not convert an already-free Pro shipping tier into a discount', () => { + const result = computeOrderTotals({ + subtotalCents: 600_000, + discountCode: freeShippingCode, + userTier: 'pro', + }); + + expect(result).toEqual({ + subtotalCents: 600_000, + discountCents: 0, + shippingCents: 0, + totalCents: 600_000, + }); + }); + + it('applies free shipping once to a standard account', () => { + const result = computeOrderTotals({ + subtotalCents: 100_000, + discountCode: freeShippingCode, + userTier: 'standard', + }); + + expect(result.shippingCents).toBe(0); + expect(result.discountCents).toBe(0); + expect(result.totalCents).toBe(100_000); + }); + + it('charges standard shipping when no free-shipping rule applies', () => { + const result = computeOrderTotals({ + subtotalCents: 100_000, + discountCode: null, + userTier: 'standard', + }); + + expect(result.shippingCents).toBe(STANDARD_SHIPPING_RATE); + expect(result.totalCents).toBe(100_000 + STANDARD_SHIPPING_RATE); + }); +});