RizTech Academy logo
RizTech Academy
Kirana Store: Orders and AdminLesson 3 of 540 min

The shop owner's admin area

The admin area is the screen somebody actually lives in. A customer visits the shop twice a week; the shopkeeper has this page open all day, on a phone, behind a counter, while serving somebody.

That changes what "good" means. It is not about looking impressive. It is about the next thing to do being visible without scrolling.

Authorisation is on the server, twice, and neither is the front end

Start with the part that must be right.

@Controller("admin")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN")
export class AdminController { … }

Order matters. JwtAuthGuard first, because RolesGuard needs a user to check a role on. Get it the wrong way round and a signed-out request produces a confusing 403 — "you are not allowed" — instead of a 401 — "we do not know who you are". Those are different problems and the client handles them differently.

On the controller, not on each method. A guard repeated per route is a guard that will be missing from the route somebody adds in six months.

const user = context.switchToHttp().getRequest<Request & { user?: AuthUser }>().user;

if (!user) throw AppException.unauthenticated();

if (!required.includes(user.role)) {
  throw new AppException(ErrorCode.FORBIDDEN, HttpStatus.FORBIDDEN,
    "You do not have access to the shop admin.");
}

The role comes from the database record, which the session middleware loaded on this request — not from a claim inside the token.

This is worth being stubborn about. A role baked into a JWT keeps working for the lifetime of that token after you remove it. Fifteen minutes is not long. It is long enough for somebody who has just been sacked to cancel a day of orders. The cost is one indexed lookup per request, which is nothing.

The front-end guard is convenience, not security

// apps/web/src/app/admin/layout.tsx
const user = await fetchCurrentUser();

if (!user) redirect("/account/login?next=%2Fadmin");
if (user.role !== "ADMIN") notFound();

In the layout, so no page added below it can be missed.

And a 404, not a redirect, for a signed-in customer. A redirect to the homepage confirms there is an admin area to find. A 404 says nothing at all.

Delete this file entirely and nothing becomes insecure: the pages would render empty and every request behind them would 403. That is the test of whether your front-end guard is doing security work it should not be.

The numbers that make the page worth opening

const [openOrders, ordersToday, revenue, lowStock, outOfStock] =
  await this.prisma.$transaction([…]);

Five counts in one round trip. Five separate awaits on their own lines would cost five times the latency for no benefit, because nothing here depends on anything else here.

Two details in those queries are the difference between a useful number and a flattering one:

this.prisma.order.aggregate({
  _sum: { totalPaise: true },
  where: { createdAt: { gte: startOfToday }, status: { not: "CANCELLED" } },
}),

Cancelled orders are not revenue. A dashboard that counts them is a dashboard the shopkeeper stops trusting the first time the number disagrees with the cash box.

revenueTodayPaise: revenue._sum.totalPaise ?? 0,

_sum is null, not 0, when nothing matched. On a quiet morning the dashboard would render "₹NaN" without that ?? 0, and NaN in a money field is the sort of thing that gets screenshotted.

And the definition of "open":

const OPEN_STATUSES = ["PENDING_PAYMENT", "PLACED", "PACKED", "OUT_FOR_DELIVERY"] as const;

Everything the shop still has to do something about. Not "all orders", which is a number that only grows and tells nobody anything.

select, not include

this.prisma.order.findMany({
  where,
  select: {
    orderNumber: true, status: true, paymentMethod: true,
    deliveryName: true, deliveryPhone: true, deliveryPincode: true,
    totalPaise: true, slotDate: true, slotLabel: true, createdAt: true,
    _count: { select: { items: true } },
  },
  …
});

The admin list needs ten fields per order. include: { items: true } would pull every order line, every address field and every timestamp across the wire to render a table that shows none of them.

_count is the part worth knowing: it asks PostgreSQL for the number of items rather than fetching the items to call .length on them. On a page of twenty orders that is the difference between one query returning ten columns and one query returning several hundred rows.

include is for when you need the objects. select is for when you need some fields. Most list endpoints want select and get include because it is shorter to type.

The buttons come from the state machine

<AdvanceOrder
  orderNumber={order.orderNumber}
  next={[...ORDER_STATUS_FLOW[order.status]]}
/>
if (next.length === 0) {
  return <p className="text-xs text-gray-400">Nothing left to do.</p>;
}

One button per legal move, and an honest sentence when there are none.

The alternative — a <select> of all six statuses — lets the shopkeeper pick something the API refuses, and the refusal looks like the app is broken rather than like a rule of the shop. Do not render controls for things that cannot happen.

The cancel button is styled differently from the others, because it is the one that undoes work:

status === "CANCELLED"
  ? "border border-red-300 text-red-700 …"
  : "bg-gray-900 text-white …"

Destructive actions should not look like the thing you tap by reflex.

<Link href={filter.status ? `/admin?status=${filter.status}` : "/admin"}
      aria-current={active ? "page" : undefined}>

The same reasoning as the catalogue filters in module 11: each filter is a URL, so the back button works, the shopkeeper can bookmark "packed orders", and the page stays a server component.

aria-current="page" on the active one, so the selected filter is announced and not merely coloured differently.

What the shop sees that the customer does not

<p>{order.customerName} · {order.customerPhone} · {order.pincode}</p>

The phone number is on the list, not behind a click. The commonest thing the shopkeeper does with an order is ring the customer about it — "your building gate is locked", "we are out of coriander, is that all right?". Making that a tap costs one screen and saves a hundred.

This is the general point about an internal tool: optimise for the thing that happens fifty times a day, not the thing that looks tidy in a screenshot.

Revalidation has to reach further than you think

export async function setOrderStatus(orderNumber, status, note?) {
  return run(
    () => apiRequest(`/admin/orders/${…}/status`, { method: "PATCH", body: { … } }),
    ["/admin", "/orders", "/products"],
  );
}

Three paths. The admin list, obviously. The customer's order page, because the status changed. And the catalogue — because cancelling an order puts stock back on the shelf, and a sold-out badge left on an item the shop can now sell is a lost sale that nobody will ever report.

Ask, for every write: what did this change that is displayed somewhere else?

Check your work

Why JwtAuthGuard comes before RolesGuard: the role check needs a user, and the wrong order turns a 401 into a misleading 403.

Why the role is read from the database: a role in a token keeps working until the token expires, which is long enough to do damage.

Why the front-end guard is in the layout: no page below it can be added without it.

Why a signed-in customer gets 404: a redirect confirms the admin area exists.

Why the front-end guard is not security: deleting it breaks nothing, because the API checks every request.

Why five counts share one transaction: nothing depends on anything else, so serial awaits are five times the latency for nothing.

Why cancelled orders are excluded from revenue: a flattering number is a number the shopkeeper stops trusting.

Why ?? 0 on _sum: it is null, not zero, when nothing matched — and NaN in a money field is memorable for the wrong reasons.

Why select rather than include: the list shows ten fields, and include would fetch every order line to render none of them.

Why _count: the database counts, instead of shipping rows so JavaScript can call .length.

Why buttons come from ORDER_STATUS_FLOW: an interface that cannot express an illegal move beats one that apologises afterwards.

Why the phone number is on the list: ringing the customer is the commonest thing the shopkeeper does with an order.

Why status changes revalidate /products: cancelling returns stock, and a stale sold-out badge is a lost sale.

Practice

  1. Sign in as the customer and open /admin. Confirm a 404, and check the network tab to see that no admin data was fetched.
  2. Call GET /api/admin/stats directly with the customer's cookie. Confirm 403 and code FORBIDDEN.
  3. Call it signed out and confirm 401, not 403.
  4. Swap the guard order to @UseGuards(RolesGuard, JwtAuthGuard) and repeat. Note which status you now get signed out.
  5. Change the customer's role to ADMIN directly in SQL and reload without signing out again. Confirm access changes immediately — that is the point of reading the role from the database.
  6. Cancel an order and confirm the revenue figure does not include it.
  7. Empty the orders table and confirm the dashboard shows ₹0.00, not NaN.
  8. Replace select with include: { items: true } and compare the response size in the network tab.
  9. Advance an order and watch /admin, /orders and /products all update. Then remove /products from the revalidate list and cancel an order that emptied a variant.
  10. Open /admin at 375px wide and decide whether the shopkeeper could use it one-handed.

Next: the inventory screen, and what a price change has to be careful about.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship