# Ecobazar 



Building My First Full-Stack E-Commerce Store (Next.js 16, MongoDB, NextAuth v5)

> A step-by-step teardown of a real Bangladeshi organic-grocery store I built from scratch.  
> #[Live Demo](https://eco.shanto.dev)  
> #[Repo Link](https://github.com/Shanto-prime/eco-bazar-e-commerce)
> 
> This is my **first full-stack project** and I wanted to write down every single thing I built and learned the tech stack, every feature, every design decision, and the parts that broke and how I fixed them. If you're a beginner learning full-stack, I hope this saves you a few weeks. If you're a hiring manager reading this, everything below is real, deployed, and answering HTTP 200 as I type this.

* * *

## What is Ecobazar?

Ecobazar is a **full-stack e-commerce store for Bangladeshi organic groceries**. It has two faces running on the **same domain**:

*   **Storefront** the public shop. Browse categories, search, filter by price + star rating, view product details with an image gallery, add to cart, checkout as a guest or signed-in user, track your order, request a return within 15 days of delivery, write a review after your order arrives.
    
*   **Dashboard** (`/dashboard`) a **role-based admin area**. Depending on whether you're a **CUSTOMER**, **MODERATOR**, or **ADMIN**, you see a different dashboard with different powers. Admins can create/edit/delete products, manage users, promote or demote roles, upload promo banners, set up time-limited Hot Deals offers, approve profile-change requests, and read the audit log.
    

The store is Bangladesh-oriented prices in Taka (৳), districts and thanas in the checkout form (no US zip codes), Bangla product names alongside English (Deshi Aloo, Kacha Morich, Ilish Mach, Sundarban Honey…).

**Everything is real code, running now.** No mock APIs, no lorem-ipsum products. 59 products in the catalogue, 12 categories, image gallery on the biggest product, full order lifecycle from cart → checkout → PAID → SHIPPED → DELIVERED → optional 15-day return with restock.

* * *

## The numbers, at a glance

Real counts, straight from `grep`:

| What | Count |
| --- | --- |
| **Source files** (`.js` + `.jsx`) under `app/`, `components/`, `lib/` | **137** |
| **Total lines of code** (source only) | **16,055** |
| **Route pages** (`page.js` / `page.jsx`) | **26** |
| **Prisma models** (in `schema.prisma`) | **19** |
| **Server actions files** (`"use server"`) | **12** |
| **Zod schemas** (files importing zod) | **14** |
| **Products in the seeded catalogue** | **59** |
| **E2E test spec files** (Playwright) | **18** |
| `useState` **calls** | **149** |
| `useEffect` **calls** | **30** |
| `useTransition` **calls** | **36** |
| `useRouter` **calls** | **31** |
| `useRef` **calls** | **23** |
| `useCallback` **calls** | **15** |
| `useContext` **calls** | **8** |
| `useMemo` **calls** | **7** |
| `useSearchParams` **calls** | **6** |
| `usePathname` **calls** | **4** |
| `useReducer` **calls** | **2** |

Plus **two custom hooks**: `useT()` for i18n (85 uses across the app) and `useCart()` for the cart context (22 uses). More on these below.

* * *

## Full tech stack

Every piece of this stack was a deliberate choice with a reason. Nothing is here "because tutorials use it".

### Frontend

*   **Next.js 16 (App Router)** file-based routing, server components by default. Server-first rendering means most of the app doesn't ship JavaScript for its rendered HTML, only for the interactive bits. Turbopack is the bundler (default in 16).
    
*   **React 19** server components + client components + server actions.
    
*   **Tailwind CSS v4** utility-first CSS. v4 keeps its config *in CSS* (`app/globals.css`), not in a `tailwind.config.js`.
    
*   **JavaScript** this project is **plain JS, not TypeScript**. First full-stack project meant one less thing to fight with. I might migrate to TS later.
    

### Backend

*   **Prisma 5.22** ORM for the database. Type-safe queries, migrations (well, `db push` for Mongo since it has no migrations), model definitions in `prisma/schema.prisma`.
    
*   **MongoDB 8.0** as a **single-node replica set** MongoDB itself, but running in replica-set mode. Why? Prisma's MongoDB connector needs a replica set to run `$transaction`, and my checkout logic *needs* transactions to prevent overselling. More on that later.
    
*   **NextAuth v5 (Auth.js)** authentication. Credentials (username/email + password), plus optional Google/Facebook OAuth that auto-mounts only when the env vars are set.
    
*   **Zod 3.23** runtime input validation. Every server action and API route parses inputs through a Zod schema before hitting the database.
    
*   **bcryptjs** password hashing (pure-JS bcrypt so it works everywhere).
    

### Deployment / DevOps

*   **Ubuntu 24.04 VPS** (OpenVZ container, 1 GB RAM, 1 CPU, 9.8 GB disk) at `eco.shanto.dev`.
    
*   **nginx 1.24** as reverse proxy on ports 80 + 443, with a friendly maintenance page for when the app is down.
    
*   **PM2 7.0** as the Node process manager, with `--max-memory-restart 500M` so a runaway worker triggers a controlled restart before it OOM-kills the whole box.
    
*   **Let's Encrypt** via certbot, auto-renewed by a systemd timer.
    
*   **ufw** firewall 22, 80, 443 in, everything else deny.
    
*   **GitHub Actions** for CI/CD every `git push` to `main` triggers a build on GitHub's runners (7 GB RAM, no OOM risk), which then rsyncs the artifacts to the VPS.
    

### Testing

*   **Playwright** for end-to-end tests. 18 spec files, 70+ test cases.
    

### Tools during dev

*   **ESLint** (flat config, `eslint-config-next`) for linting.
    
*   **sharp** (bundled with Next.js) for image processing I wrote a script that resized all 61 product photos from 156 MB down to 3.4 MB in place.
    

* * *

## Feature list everything I built

### Storefront (customer-facing)

1.  **Home page** hero grid with a main promo image + a live TOP-placement admin banner (falls back to a static image if no banner is set) + a Hot Deals link card, service bar (free shipping / support / secure payment / money-back), category tiles, "Popular Products" best-sellers row, Hot Deals area with a big featured card + smaller card grid, a BELOW\_LIST admin banner slot, "Featured Products" row, and a "Customer Reviews" carousel with 6 seeded reviews (4× 5-star, 2× 4-star I purposely didn't make them all 5 stars because all-5 looks fake).
    
2.  **Nav bar** Home · Shop · Pages · Track Order · About · Contact. "Track Order" wires to `/orders/lookup`, my guest tracker.
    
3.  **Shop page** server-side paginated grid (9 products per page). Live search by name. Category filter in the sidebar. **Price range slider** whose min/max are pulled from the actual cheapest and most-expensive product in the DB (not a hardcoded 0-100). Star rating filter (radios at 5★/4★/3★/2★/1★). Sort by latest / price / name. All filters are URL-shareable via query strings.
    
4.  **Product detail page** image gallery with zoom (supports multi-image galleries drop `<slug>-2.jpeg` next to `<slug>.jpeg` and the seed picks it up). Quantity stepper. Add to cart. Wishlist toggle (signed-in only). Description / Additional info / Reviews tabs. Related products. Soft-404 with "did you mean…?" suggestions from `lib/product-suggest.js`.
    
5.  **Cart** quantity steppers, coupon apply (three coupons: ECO10, ECO20, FREE5), live totals. Cart persists to `localStorage` for guests; mirrored to the DB for signed-in users so it follows them across devices.
    
6.  **Wishlist** **signed-in only**. Middleware redirects anonymous visitors to `/login?next=/wishlist`.
    
7.  **Checkout** Bangladeshi Division/District/Thana selects (not US zip codes), address prefill for signed-in users with saved addresses, **guest checkout allowed** (captures email/name/phone/address, `userId` is null on the order). Five payment methods (COD, PayPal, Amazon, bKash, Nagad the schema fields exist; real gateway integration is TODO). Coupon apply. Thank-you screen with the order number.
    
8.  **Deals landing pages** (`/deals/<slug>`) one per promo banner. Shows only products whose `badge` or `tags` array matches the banner's `targetTag`. Copy-code control at the top.
    
9.  **Order tracker** (`/orders/lookup`) a guest enters their order number + email, gets the status. Signed-in users are redirected to `/dashboard/orders` for the full history.
    
10.  **Multi-language plumbing** (currently English-only; Bangla was removed but the plumbing stayed one JSON file to re-add it).
     
11.  **Dark mode** cookie-based, no flash. The server reads the `ecobazar-theme` cookie and sets `<html class="dark">` before hydration.
     

### Accounts & auth

12.  **Credentials sign-up / sign-in** pick a username, use email + password. Log in with **either the username or the email** (my auth handler lowercases the identifier and does `findFirst({where: {OR: [{username}, {email}]}})`).
     
13.  **Password reset flow** email a one-time token, expires in 1 hour.
     
14.  **Email verification** issued at signup but not enforced at login (design choice; enforcing it before people confirm the mail transport is set up would lock everyone out).
     
15.  **Optional Google + Facebook OAuth** mounts only when the client ID + secret env vars are both non-empty. If not, the buttons don't render and there are no warnings.
     
16.  **First-user-becomes-ADMIN** the very first person to sign up on a fresh install is auto-promoted to admin, marked as super-admin (undeletable/undemotable).
     
17.  **"Already signed in" guardrails** hitting `/login` or `/register` while signed in shows a "you're already signed in as X" panel with a "continue to dashboard" link and a "sign out" link. `/unauthorized` branches too: anonymous visitors see log-in / create-account CTAs; signed-in users with the wrong role see "you don't have access to this page" with a dashboard link. No misleading "please log in" for people who are already logged in.
     
18.  **Guest → user order linking** this is a small but nice one. If a customer checks out as a guest, then later opens an account with the same email, all their prior guest orders are automatically attached to the new account on signup. They see their history in the dashboard on first login. Runs for both credentials and OAuth signups.
     

### Order lifecycle

19.  **Server-side cart** Cart model, one row per user, keyed on `userId`. On login, the local guest cart is *merged* into the saved cart. On logout, the local cart is cleared. On subsequent reloads, the DB is authoritative (no accidental double-counting).
     
20.  **Guarded atomic stock decrement** the whole reason I need a replica set. More on this in §8.
     
21.  **Order status timeline** every status change (PENDING → PAID → SHIPPED → DELIVERED, or CANCELLED) is recorded as an append-only `OrderStatusEvent` with a timestamp, actor, and optional note. The customer sees the timeline in the order details modal.
     
22.  **Order details modal** customer and admin both see the item list (product, quantity, unit price, line total) + status timeline + totals block (subtotal, discount, shipping, total). Item names come from the DB but *prices are snapshotted* a product price change tomorrow doesn't retroactively change what's shown in yesterday's order.
     
23.  **Terminal statuses** DELIVERED and CANCELLED are terminal. Neither admin nor moderator can un-cancel or un-deliver an order. The one allowed exception: a customer's own return.
     
24.  **15-day return window** if a customer's order is DELIVERED and less than 15 days have passed since the DELIVERED event, they see a "Request return" button in the order details. Clicking it (with confirmation) flips the order to CANCELLED, restocks the items back to inventory, writes a timeline event noting "Return requested by customer", appends an audit log row. After 15 days the button disappears; the order stays DELIVERED forever.
     
25.  **Write review after delivery** customer sees a "Write review" CTA per item on a delivered order, which expands into a star rating + text form. Submitting creates an approved `Review` row (schema-enforced unique per (product, user)) and recomputes `Product.rating` as the running average. If they already reviewed the product, the CTA is replaced by "You reviewed this".
     

### Dashboard (role-based)

26.  **Role routing** `/dashboard` reads the session role and renders `AdminDashboard`, `ModeratorDashboard`, or `CustomerDashboard`. Same URL, three different pages.
     
27.  **Orders management** everyone sees `/dashboard/orders` but the data is scoped server-side: customers see only their own; moderators see all (read-only, no status change); admins see all with an inline status dropdown.
     
28.  **Product management** moderator can create products but can only edit/delete products where `Product.createdById === user.id` (enforced in the server action, not the schema). Admin can edit anything.
     
29.  **Image uploads** three endpoints with different rules: `/api/upload` for product images (admin/mod, 4 MB), `/api/upload/avatar` for user avatars (any signed-in, 2 MB), and `/api/upload/banner` for promo banners (admin only, 6 MB). All validate the file magic bytes (not just MIME type), assign a hashed filename `<timestamp>-<sha1>.<ext>`, and pick the extension from the *validated* type so a `filename="x.html"` with `Content-Type: image/png` can't sneak into `/public` and get served as HTML from my origin.
     
30.  **User management** (admin only) promote/demote roles. Super-admin is untouchable.
     
31.  **Promo banners** (admin only) upload banner artwork, set placement (TOP / BELOW\_LIST), set a `targetTag`, get a `/deals/<slug>` landing page for free.
     
32.  **Hot Deals offers** (admin only) pick a product, set a percentage off (1-90%), set an end time. While the offer is live, the discounted price is applied *everywhere* shop grid, product page, cart, checkout. The `Product.price` field is never rewritten, so expiry doesn't need a cleanup job.
     
33.  **Profile change requests** for the two "recovery channel" fields (email + phone), a change goes into an approval queue instead of applying instantly. Admin approves or rejects.
     
34.  **Audit log** (admin only) every privileged write appends a row with actor, action name, entity, entity ID, and JSON metadata.
     
35.  **Settings page** profile (name, username, avatar), password change, saved addresses (with default flag), appearance toggle. Currency section was here I removed it when I decided the store would be Taka-only.
     

* * *

## Architecture how the pieces fit together

```plaintext
Browser
   │
   ▼
Next.js server (Node 22, PM2)
   │
   ├─ App Router: server components render HTML on the server
   ├─ Server actions ("use server"): mutate data, called from client without a manual API
   ├─ Middleware (Edge): gates /dashboard/*, /wishlist* auth-check only, no DB access
   │
   ▼
Prisma Client
   │
   ▼
MongoDB (single-node replica set on localhost)
```

**Three enforcement layers for authorization.** This is the load-bearing pattern:

1.  **Middleware** (`middleware.js`) runs on the Edge. Checks *signed in or not*. Anonymous `/dashboard` visitors bounce to `/unauthorized`; anonymous `/wishlist` visitors bounce to `/login`. Middleware never touches Prisma or bcrypt (they can't run on the Edge runtime), it only reads the JWT.
    
2.  **Server components / pages** call `requireAuth()` or `requireRole()` from `lib/auth-helpers.js` to enforce the actual role per route.
    
3.  **Server actions & API routes** re-check the role *again*, even though the route is already protected. Defense in depth. Self-service actions read `session.user.id` from the server session, never from client input.
    

Why three layers? Because layer #1 can only know "signed in or not" (no DB access on the Edge), layer #2 enforces the specific role per page but doesn't help if someone hits your action from the outside, and layer #3 is your only real backstop against a bug in either of the first two.

**File extension convention.** `.js` = server component or module. `.jsx` = client component (`"use client"` at the top). `"use client"` and `"use server"` are load-bearing.

**Two data sources don't confuse them.**

*   `lib/products-db.js` Prisma reads for customer pages (`listProducts`, `getProductBySlug`, `queryProducts`, `getPriceBounds`, etc.). Source of truth for the running app.
    
*   `prisma/seed-data.js` the seed catalogue (12 categories + 59 products). Consumed by both `prisma/seed.js` (dev) and `prisma/seed.prod.js` (production). Not read at runtime by pages.
    

* * *

## React hooks used count + how each one is used

I use **every core React hook except** `useLayoutEffect`. Here's what each one is for in this codebase, with a short example.

### `useState` 149 uses

The workhorse. Anywhere I need local component state. Modals, form fields, dropdown open/close, current tab, current page number, filter values, error/notice messages.

```jsx
const [open, setOpen] = useState(false);
const [rating, setRating] = useState(5);
const [error, setError] = useState(null);
```

### `useEffect` 30 uses

Anywhere I need to synchronize with something outside React usually URL query strings, body scroll lock when a modal opens, or a debounced fetch for the shop's live filters.

```jsx
// Debounced fetch of the current page from /api/products
useEffect(() => {
  const id = setTimeout(async () => {
    const res = await fetch(`/api/products?${params}`);
    setItems((await res.json()).items);
  }, 250);
  return () => clearTimeout(id);
}, [query, activeCat, maxPrice, minRating, sort, page]);
```

### `useTransition` 36 uses

For calling server actions without blocking the UI. React 19 makes this pretty clean.

```jsx
const [pending, startTransition] = useTransition();

startTransition(async () => {
  const res = await submitReviewAction({ orderId, productId, rating, body });
  if (!res.ok) setError(res.error);
  else router.refresh();
});
```

The button's `disabled={pending}` and any inline spinner reads from `pending`.

### `useReducer` 2 uses

Just one important one: the cart. Cart state has many actions (add, remove, updateQty, setCoupon, clear, replace, merge) and living inside a `useState` object with lots of manual spread updates would be miserable. `useReducer` gives me one place where every mutation lives:

```jsx
function cartReducer(state, action) {
  switch (action.type) {
    case "add":       return { ...state, items: mergeItems(state.items, action.item) };
    case "remove":    return { ...state, items: state.items.filter(i => i.slug !== action.slug) };
    case "updateQty": return { ...state, items: setQty(state.items, action.slug, action.qty) };
    // …
  }
}
```

### `useRef` 23 uses

For values that need to persist across renders without triggering a re-render. My favourite example is the "out-of-order fetch guard" on the shop page:

```jsx
const reqId = useRef(0);
const mine = ++reqId.current;
const res = await fetch(...);
if (mine !== reqId.current) return; // a newer request superseded this one
setItems(await res.json());
```

Also used for the "first render seeded by server props, skip client fetch on mount" pattern:

```jsx
const first = useRef(true);
useEffect(() => {
  if (first.current) { first.current = false; return; }
  // now fetch fresh
}, [query, ...]);
```

### `useMemo` 7 uses

Sparingly. Only when a computation is genuinely expensive AND runs on every render. In this codebase, I use it for filtering the current category list, resolving the theme's palette, and deriving product info from the cart items.

### `useCallback` 15 uses

Same principle as `useMemo` sparingly. Mostly used when passing callbacks down to memoized child components, or to stabilize a function reference used as a `useEffect` dependency.

### `useContext` 8 uses (via 4 custom providers)

Four context providers wrap the app in `app/layout.js`:

*   `ThemeProvider` dark mode.
    
*   `LanguageProvider` i18n (currently English-only).
    
*   `CurrencyProvider` currency display (currently pinned to BDT, but the plumbing is still there).
    
*   `CartProvider` the big one. Cart + wishlist + toast system.
    

Each has a hook: `useTheme()`, `useT()` (i18n used **85 times**), `useCart()` (**22 uses**).

```jsx
const t = useT();
const { addItem, removeItem, items, coupon, toast } = useCart();
```

### `useRouter` / `usePathname` / `useSearchParams` 31 / 4 / 6 uses

From `next/navigation`. `useRouter().refresh()` re-runs the server component after a server action mutates data. `usePathname()` for highlighting the active nav item. `useSearchParams()` for reading URL state that the server also uses.

* * *

## Authentication & authorization deep dive

Everything I know about auth I learned building this.

### Sign-in flow (credentials)

1.  User types their identifier (**username OR email**) + password on `/login`.
    
2.  `LoginForm` calls `signIn("credentials", ...)`.
    
3.  NextAuth calls my `Credentials.authorize()` in `lib/auth.js`:
    
    *   Normalize the identifier (`trim().toLowerCase()`).
        
    *   **Rate-limit check first**, before bcrypt. Per-account: 10 attempts / 15 min. Per-IP: 30 attempts / 15 min. If tripped, return the same generic failure a wrong password would.
        
    *   `prisma.user.findFirst({where: {OR: [{username}, {email}]}})`.
        
    *   `bcrypt.compare(password, user.passwordHash)`.
        
    *   If ok: return `{id, email, name, image, role}`.
        
4.  NextAuth signs a JWT with those claims.
    
5.  Subsequent requests read the JWT **no DB hit** for role checks on a normal request.
    
6.  Every 5 minutes (`ROLE_TTL_MS`) the JWT re-verifies the role against the DB, so a demoted user's privileges expire within 5 min without needing to log them out.
    

### Sessions (JWT, not DB)

JWT strategy, not `SessionProvider`. Session lives in a cookie. Sessions expire on *inactivity*: 6 hours for customers, 12 hours for admins/moderators (`lib/session-policy.js`). The JWT carries a rolling `lastActivityAt`; past the limit, the token's identity is stripped and the middleware treats it as anonymous.

### Password hashing

**bcrypt cost 12** at signup, reset, and seed. There's an inconsistency in the settings password-change flow (cost 10 there) that I need to fix.

### Tokens

Password reset and email verification tokens are stored in `VerificationToken` with a composite identifier of `<purpose>:<email>`. Single-use, expire in 1 hour. Issuing a new one deletes the prior one so a user asking for two reset emails can't use both.

### Authorization helpers

Every server component and every server action calls one of these from `lib/auth-helpers.js`:

```js
export async function requireAuth(nextPath = "/dashboard") {
  const user = await getCurrentUser();
  if (!user) redirect(`/unauthorized?next=${encodeURIComponent(nextPath)}`);
  return user;
}

export async function requireRole(roleOrRoles, nextPath = "/dashboard") {
  const user = await requireAuth(nextPath);
  const allowed = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
  if (!allowed.includes(user.role)) redirect("/unauthorized");
  return user;
}
```

Usage:

```js
// In a server action:
const actor = await requireRole(["ADMIN", "MODERATOR"], "/dashboard/orders");

// In a server component:
const user = await requireAuth("/dashboard/settings");
```

* * *

## The checkout critical path (the scary one)

This is the one you cannot get wrong.

**The problem:** two customers hit "Place order" at exactly the same time, both wanting the last unit of Ilish fish. If I use naive read-then-write, both reads say `stock=1`, both writes decrement to `stock=0`, both orders succeed, and someone gets an angry email.

**The fix:** a **guarded atomic update** inside a `prisma.$transaction`. This is why the whole app needs a MongoDB replica set Prisma's Mongo connector can only run transactions against replica sets.

```js
await prisma.$transaction(async (tx) => {
  for (const l of lines) {
    const upd = await tx.product.updateMany({
      where: { id: l.productId, stock: { gte: l.qty } },  // ← guard
      data:  { stock: { decrement: l.qty } },
    });
    if (upd.count === 0) {
      throw new Error(`Out of stock: ${l.name}`);  // → transaction rolls back
    }
  }
  // ... create the order + items + PENDING status event
});
```

The magic is `where: { stock: { gte: qty } }`. If two writers race, MongoDB serializes them: whichever gets there first succeeds; the second one sees `stock=0`, its `where` clause doesn't match, `count === 0`, throws, transaction rolls back → no order, no stock change, cart is *not* cleared.

Other things the checkout does:

*   **Prices are recomputed from the DB.** The client sends only `{slug, qty}`. If a malicious client tries to send `{slug, qty, price: 1}` for a `৳1400` Hilsa, the server ignores the price and uses the DB value. Anti-tampering by design.
    
*   **Live Hot Deals offers are applied** during price recomputation, so the discounted price shown in the cart is what the customer is charged.
    
*   **Line items are snapshotted.** `OrderItem` stores `productName` and `unitPrice` as of the order a product renamed or repriced tomorrow doesn't change yesterday's order.
    
*   **Guest checkout allowed** `userId` is nullable on `Order`. Guests get a thank-you screen with their order number and can look it up at `/orders/lookup`.
    
*   **Order numbers** are `ECO-` + 8 CSPRNG characters. Collision → retry.
    

* * *

## Money how integer math beat floating point

**Rule: never store money in a float.** `0.1 + 0.2 !== 0.3` in JavaScript, and after a few million multiplications you have real bugs.

MongoDB's Prisma connector doesn't have a `Decimal` type. So I store **integer poisha** (1/100 of a Taka). A ৳14.99 product is `1499` in the DB.

`lib/money.js` is the one place I convert:

```js
export const toCents   = (taka)  => Math.round(Number(taka) * 100);
export const toDollars = (cents) => cents == null ? null : cents / 100;
export function formatMoney(cents) { /* returns "৳1,400.00" */ }
```

Rule of thumb: DB + server-side arithmetic in poisha; UI/forms/cart in Taka; only convert at the boundary.

The store is **BDT-only**. I had a multi-currency setup (BDT/USD/AED with admin-managed exchange rates and a `CurrencyProvider`), but it added complexity I didn't neethe shop is Bangladesh-only. I ripped out the switcher UI and made `getActiveCurrency()` return BDT unconditionally. The plumbing is dormant, not deleted one file flips it back on if I ever need it.

* * *

## Post-delivery flow returns & reviews

Both of these are customer-callable server actions in `app/dashboard/orders/_customer-actions.js`, guarded by `requireAuth()` (not `requireRole()`).

### The 15-day return window

`lib/order-return.js` has the eligibility check:

```js
export function canRequestReturn({ viewerId, order, now }) {
  if (!order)                           return { ok: false, reason: "notFound" };
  if (order.userId !== viewerId)        return { ok: false, reason: "notOwner" };
  if (order.status !== "DELIVERED")     return { ok: false, reason: "notDelivered" };

  const deadline = returnDeadline(order.history, now);
  if (!deadline)                        return { ok: false, reason: "notDelivered" };
  if (now > deadline.getTime())         return { ok: false, reason: "windowClosed", deadline };

  return { ok: true, deadline };
}
```

The `deadline` is `deliveredAt + 15 days` where `deliveredAt` is the first DELIVERED event on the order's timeline. Same helper is used in TWO places: the server component computes it once to decide whether to render the button; the server action re-checks it before mutating (defense in depth never trust the client that it should be allowed).

When a customer clicks Request Return:

1.  Confirm dialog on the client.
    
2.  Server action re-validates via `canRequestReturn`.
    
3.  In one transaction: flip `Order.status` to `CANCELLED`, restock all items via the same `restockCancelledOrder` helper that admin cancellations use, write an `OrderStatusEvent` with note `"Return requested by customer"` and `actorId = user.id`, append an `AuditLog` row.
    
4.  `revalidatePath("/dashboard/orders")` so the UI updates without a hard refresh.
    

After the 15-day window closes, the button hides on the client (`canReturn` from the server is false), and the server action rejects with `windowClosed` if someone tries to call it via curl.

### Reviews

`Review.@@unique([productId, userId])` in the schema means a user can only review a given product once. My `submitReviewAction`:

```js
if (order.userId !== user.id)          return { ok: false, error: "This order isn't yours." };
if (order.status !== "DELIVERED")      return { ok: false, error: "You can only review delivered orders." };
if (order.items.length === 0)          return { ok: false, error: "That product wasn't in this order." };

const existing = await prisma.review.findUnique({
  where: { productId_userId: { productId, userId: user.id } },
});
if (existing) return { ok: false, error: "You've already reviewed this product." };

// Create + recompute Product.rating as running average
```

After write, I recompute `Product.rating` as the average of all approved reviews, rounded to one decimal (matches the display format in `ProductCard`). Auto-approved for now a moderation flow is a TODO.

* * *

## Guest → user account: linking orders on signup

The nicest little feature. A customer checks out as a guest at `/checkout` with email `asha@example.com`. Order gets saved with `userId: null` and `email: "asha@example.com"`.

Three weeks later, she decides to create an account with the same email. `lib/user-service.js`:

```js
export async function claimGuestOrdersForUser(userId, email) {
  if (!userId || !email) return 0;
  try {
    const res = await prisma.order.updateMany({
      where: {
        userId: null,
        email:  { equals: email, mode: "insensitive" },
      },
      data: { userId },
    });
    return res.count;
  } catch {
    return 0;
  }
}
```

Called from BOTH the credentials signup route (`app/api/auth/signup/route.js`) and the OAuth `createUser` event (`lib/auth.js`). The `mode: "insensitive"` is important if she typed `ASHA@example.com` at checkout but `asha@example.com` at signup, we still catch it. Never throws a failure here doesn't block the signup response.

First login: she sees her three weeks' worth of guest orders in her dashboard. No support ticket, no manual link.

* * *

## Deploying to a 1 GB VPS: what went wrong and how I fixed it

This section is the honest one.

I picked a small **OpenVZ VPS** 1 GB RAM, 1 CPU, 9.8 GB disk, Ubuntu 24.04. Cheap, fine for a demo. Setup went smoothly:

*   Installed Node.js 22, PM2, MongoDB 8.0.
    
*   Configured MongoDB as a single-node replica set on `127.0.0.1:27017` (no auth, localhost-only) with WiredTiger cache capped at 256 MB.
    
*   Set up nginx as a reverse proxy on port 80, then upgraded to Let's Encrypt on 443 with certbot (once DNS pointed at the box).
    
*   Created a non-root `ecobazar` user, cloned the repo, `npm ci`, `npm run build` first deploy worked.
    

**Then I tried to redeploy.** `npm ci` OOMed. Kernel killed random processes. Eventually killed sshd. Box became unreachable for 20 minutes until I could reboot from the provider's console.

**Root cause:** OpenVZ containers have a `privvmpages` limit that blocks `fork()` under memory pressure even with visible free RAM in `free -h`. `npm ci` spawns dozens of processes for `npm-cli-install-scripts`. On a 1 GB container with MongoDB already using ~250 MB, the fork storm exceeds the limit. Plus swap is disallowed on OpenVZ (`swapon` returns "Operation not permitted").

**Failed fix #1:** stop MongoDB during npm install. Still OOMed the fork limit isn't about free RAM, it's about virtual pages committed.

**Failed fix #2:** local build on Windows, ship `.next/` and `node_modules/` via tar. Two problems:

*   Windows npm creates `.bin/next` as a **bash wrapper script**, not a Linux symlink. When PM2 tried to run it, Node parsed it as JavaScript and threw `SyntaxError: missing ) after argument list`.
    
*   Turbopack's built `.next/` references `@prisma/client-<hash>` where the hash is computed against the local `node_modules` layout. A Linux `node_modules` computes a different hash → `Cannot find module '@prisma/client-2c3a283f134fdcb6'`.
    

Both problems come from Windows and Linux producing different node\_modules layouts. Local build → remote deploy only works if both machines match.

**The real fix:** build on GitHub Actions.

* * *

## The GitHub Actions auto-deploy pipeline

`.github/workflows/deploy.yml` runs on every push to `main`:

1.  `ubuntu-24.04` runner (matches VPS exactly).
    
2.  `npm ci --legacy-peer-deps` (the `--legacy-peer-deps` is because next-auth's beta declares `peerDependencies: next@^14||15`; we're on next 16, works fine, npm just needs to be told to accept the mismatch).
    
3.  `npm run build` runners have 7 GB RAM, no OOM risk. I set dummy env vars for build-time because `lib/prisma.js` throws at import if `DATABASE_URL` is malformed:
    
    ```yaml
    env:
      DATABASE_URL: "mongodb://build.local:27017/build?replicaSet=rs0"
      NEXTAUTH_SECRET: "build-time-placeholder-not-used-at-runtime"
    ```
    
    Every route is dynamic (`ƒ`), so no actual DB call fires during build. The URL just has to pass the shape check.
    
4.  `npm prune --omit=dev` strip test tooling from `node_modules`.
    
5.  Install SSH key from `secrets.DEPLOY_SSH_KEY` (with a `tr -d '\r'` scrub because Windows-copied secrets sometimes have CRLF that breaks OpenSSH).
    
6.  **Rsync** `.next/` **+** `node_modules/` **+** `public/` **\+ source tree to the VPS as the** `ecobazar` **user.** `-a` preserves symlinks (critical for `.bin/next`). `--delete-after` only prunes removed files after a successful transfer a mid-stream disconnect keeps the old tree intact.
    
7.  `pm2 restart ecobazar --update-env` (or first-time start with `--max-memory-restart 500M` so a runaway worker triggers a controlled restart before it OOMs the box).
    
8.  Smoke test: curl `https://eco.shanto.dev/` and expect HTTP 200 within 25 s. Job fails red if not.
    

Four repo secrets needed: `DEPLOY_SSH_KEY`, `DEPLOY_KNOWN_HOSTS`, `DEPLOY_HOST`, `DEPLOY_USER`.

I also added a nginx maintenance-page snippet: on `502/503/504` from the upstream, serve a friendly "we'll be right back" page instead of raw nginx errors. It sits dormant during normal operation and only appears when PM2 is down.

**The wiring took SEVEN failed deploys** sed separators, SSH tilde expansion, `set -e` vs pipefail, missing `-i deploy_key` on the verify SSH but the final pipeline runs in ~4-6 minutes per deploy and I haven't had to think about it since.

* * *

## What I learned

**Concrete technical stuff:**

*   Server components are the default in Next 16. Client components (`"use client"`) are the exception, not the rule. Most of my pages ship near-zero JavaScript for their rendered HTML.
    
*   Server actions (`"use server"`) let you call server code directly from client components without writing an API route. They're validated with Zod at the boundary and re-check auth every time.
    
*   The three-layer authorization pattern (middleware → server component → server action) beats any single layer. If one has a bug, the others catch it.
    
*   Race conditions are subtle. A `where: { stock: { gte: qty } }` predicate is the difference between "sometimes oversells" and "never oversells".
    
*   Money is integer minor units, always. `1499` not `14.99`.
    
*   Client-tampering is a real thing. If your checkout accepts a price from the client, you have a bug.
    
*   Every image path from a form is a possible XSS vector. Validate MIME + magic bytes + pick the extension from the validated type, never from the filename.
    

**Deploy stuff I didn't know before:**

*   1 GB VPS is fine to *run* a Next.js app on. It is not fine to *build* on.
    
*   Windows and Linux produce different `node_modules`. Shipping one to the other is a recipe for pain (bin-wrapper shells, Turbopack hash mismatches, Prisma engine mismatches).
    
*   Build on the same OS as the runtime. GitHub Actions runners are the free easy way.
    
*   Let's Encrypt auto-renewal via `certbot.timer` is a joy after ever setting up manual cert rotation.
    
*   PM2 `--max-memory-restart 500M` is a good guardrail when you're near the RAM ceiling.
    
*   Nginx `error_page 502 503 504 = @maintenance;` gives you a real "we'll be right back" page for the cost of two lines.
    

**Meta stuff:**

*   Writing docs as you go beats writing them at the end. I keep `README.md`, `DOCUMENTATION.md` and `CLAUDE.md` in sync with the code. Any feature I add without a doc note is a feature future-me will forget how to change.
    
*   Test what you fix. E2E tests broke every time I rebranded the catalogue, but they also caught the currency-symbol bug on the shop slider that I would have missed in prod.
    
*   The scariest bug is the one that fails silently. `rsync | tail` was silently swallowing rsync's non-zero exit for two failed deploys before I added `set -o pipefail`.
    

* * *

## What's next

Things I know are on the roadmap:

*   **Payment integration** the schema has bKash, Nagad, PayPal, Amazon, COD fields. Only COD actually works. Wiring bKash next.
    
*   **Review moderation UI** reviews auto-approve today. Admin approve/reject queue.
    
*   **Real transactional email** `lib/mailer.js` logs to console. Swap for Resend/SES.
    
*   **Shared-store rate limiting** currently in-memory (`lib/rate-limit.js`), so it doesn't coordinate across app instances. Move to Redis.
    
*   **Consolidate coupons** the coupon table is duplicated between `CartContext.jsx` and `order-actions.js`. One shared constant.
    
*   **TypeScript migration** probably. I'm ready.
    
*   **Distinct** `RETURNED` **status** currently returns are recorded as `CANCELLED` with a note. A distinct enum value would be cleaner.
    

* * *

## Wrapping up

This was my first full-stack project and I ended up with a real, deployed, working store 16,055 lines of code, 137 source files, 19 database models, 26 routes, 18 E2E test files, and a story I can actually tell about every decision. It works today at [eco.shanto.dev](https://eco.shanto.dev) go put something in the cart.

If you're building your first full-stack app: don't be afraid of the deploy step. The build I was so proud of on my laptop was totally different from the build that actually needs to run on a Linux box. Skip that lesson by building on GitHub Actions from day one.

If this write-up helped, share it, or drop a line at [eco.shanto.dev/contact](https://eco.shanto.dev/contact). And if you're a hiring manager reading this hello 👋.

Shanto
