RizTech Academy logo
RizTech Academy
Kirana Store: Catalogue and SearchLesson 2 of 535 min

The catalogue API

The data exists. This lesson builds the endpoints that serve it, applying module 7's design rules to a real catalogue.

The endpoints

From the screens, as module 7 argued:

GET /api/categories              the filter bar
GET /api/products                the grid, filtered and paged
GET /api/products/:slug          the product page

Three endpoints, one per screen.

The module

cd apps/api
nest g module products
nest g controller products
nest g service products

The mapper

Before anything else, because it is the boundary:

// apps/api/src/products/product.mapper.ts
import type { Prisma } from "@prisma/client";
import type { ProductDetail, ProductSummary, VariantSummary } from "@kirana/shared";

type VariantRow = Prisma.VariantGetPayload<{}>;
type ProductRow = Prisma.ProductGetPayload<{
  include: { category: true; variants: true };
}>;

function toVariant(row: VariantRow): VariantSummary {
  return {
    id: row.id,
    sku: row.sku,
    label: row.label,
    unit: row.unit,
    quantity: Number(row.quantity),
    pricePaise: row.pricePaise,
    mrpPaise: row.mrpPaise,
    // Never expose the count — decision from module 7.
    inStock: row.stock > 0,
  };
}

export function toProductSummary(row: ProductRow): ProductSummary {
  const active = row.variants.filter((v) => v.isActive);
  const cheapest = [...active].sort((a, b) => a.pricePaise - b.pricePaise)[0];

  return {
    id: row.id,
    slug: row.slug,
    name: row.name,
    brand: row.brand,
    imageUrl: row.imageUrl,
    category: { slug: row.category.slug, name: row.category.name },
    cheapestVariant: toVariant(cheapest),
  };
}

export function toProductDetail(row: ProductRow): ProductDetail {
  return {
    ...toProductSummary(row),
    description: row.description,
    variants: row.variants
      .filter((v) => v.isActive)
      .sort((a, b) => a.pricePaise - b.pricePaise)
      .map(toVariant),
  };
}

Three things worth noting.

Number(row.quantity) — Prisma returns Decimal for that column, which is an object, not a number. It does not survive JSON serialisation as you expect, and it is not assignable to the shared type. Converting here is the boundary doing its job.

inStock: row.stock > 0 — the commercial decision from module 7, enforced in one place. Nothing downstream can accidentally expose the count.

Variants are filtered and sorted here, so every caller gets the same order and no caller has to remember.

The service

// apps/api/src/products/products.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import {
  DEFAULT_PAGE_SIZE,
  MAX_PAGE_SIZE,
  type Paginated,
  type ProductDetail,
  type ProductSummary,
} from "@kirana/shared";

import { PrismaService } from "../prisma/prisma.service";
import { QueryProductsDto } from "./dto/query-products.dto";
import { toProductDetail, toProductSummary } from "./product.mapper";

const WITH_RELATIONS = { category: true, variants: true } as const;

@Injectable()
export class ProductsService {
  constructor(private readonly prisma: PrismaService) {}

  async findAll(query: QueryProductsDto): Promise<Paginated<ProductSummary>> {
    const page = Math.max(1, query.page ?? 1);
    const limit = Math.min(MAX_PAGE_SIZE, Math.max(1, query.limit ?? DEFAULT_PAGE_SIZE));
    const where = this.buildWhere(query);

    const [rows, total] = await this.prisma.$transaction([
      this.prisma.product.findMany({
        where,
        include: WITH_RELATIONS,
        orderBy: this.buildOrderBy(query.sort),
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.product.count({ where }),
    ]);

    const totalPages = Math.max(1, Math.ceil(total / limit));

    return {
      items: rows.map(toProductSummary),
      page,
      limit,
      total,
      totalPages,
      hasNext: page < totalPages,
      hasPrevious: page > 1,
    };
  }

  async findBySlug(slug: string): Promise<ProductDetail> {
    const row = await this.prisma.product.findFirst({
      where: { slug, isActive: true },
      include: WITH_RELATIONS,
    });

    if (!row || row.variants.filter((v) => v.isActive).length === 0) {
      throw new NotFoundException(`No product with slug "${slug}"`);
    }

    return toProductDetail(row);
  }

  private buildWhere(query: QueryProductsDto): Prisma.ProductWhereInput {
    // Not optional, so not conditional.
    const where: Prisma.ProductWhereInput = { isActive: true };

    if (query.category) {
      where.category = { slug: query.category };
    }

    if (query.q?.trim()) {
      const q = query.q.trim();
      where.OR = [
        { name: { contains: q, mode: "insensitive" } },
        { brand: { contains: q, mode: "insensitive" } },
        { description: { contains: q, mode: "insensitive" } },
      ];
    }

    const variant: Prisma.VariantWhereInput = { isActive: true };
    if (query.minPaise !== undefined) {
      variant.pricePaise = { ...(variant.pricePaise as object), gte: query.minPaise };
    }
    if (query.maxPaise !== undefined) {
      variant.pricePaise = { ...(variant.pricePaise as object), lte: query.maxPaise };
    }
    if (query.inStock) {
      variant.stock = { gt: 0 };
    }

    // "some" means: has at least one active variant matching.
    where.variants = { some: variant };

    return where;
  }

  private buildOrderBy(sort?: string): Prisma.ProductOrderByWithRelationInput[] {
    switch (sort) {
      case "newest":
        return [{ createdAt: "desc" }, { id: "asc" }];
      case "name_desc":
        return [{ name: "desc" }, { id: "asc" }];
      default:
        return [{ name: "asc" }, { id: "asc" }];
    }
  }
}

Points that matter.

where.variants = { some: variant } is what makes the product list exclude products whose only variants are inactive. Without it, a product with every variant deactivated appears with no price.

findBySlug checks for active variants too, and 404s rather than returning a product the customer cannot buy.

Every orderBy ends with { id: "asc" } — the unique tiebreaker from module 7. Without it, products with equal names have no defined order and can repeat across pages.

The limit is clamped here as well as validated on the DTO. @Max(100) rejects an HTTP request above the cap with a 400; this clamp catches any caller that did not come through a controller. Two layers, because the service is not only reachable over HTTP.

Price sorting, and why it is not here

price_asc is missing from buildOrderBy, deliberately.

Price lives on the variant, so sorting products by price means sorting by a value in a related table — which Prisma cannot express directly in orderBy.

Three options:

Denormalise. Add minPricePaise to Product, maintained whenever a variant changes. Fast, and it is another thing that can go stale.

Sort in application code. Only correct if you have every row, which defeats pagination.

A raw query. Correct and it bypasses the type safety you are paying for.

We denormalise, in module 13, when the admin screens that update prices exist to maintain it. Leaving it out now, with a note, is better than a broken implementation — a price sort that silently sorts wrong is worse than no price sort.

The DTO

// apps/api/src/products/dto/query-products.dto.ts
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator";

export const PRODUCT_SORTS = ["name", "name_desc", "newest"] as const;

export class QueryProductsDto {
  @IsOptional() @IsString() @MaxLength(50)
  category?: string;

  @IsOptional() @IsString() @MaxLength(100)
  @Transform(({ value }) => (typeof value === "string" ? value.trim() : value))
  q?: string;

  @IsOptional() @Type(() => Number) @IsInt() @Min(0)
  minPaise?: number;

  @IsOptional() @Type(() => Number) @IsInt() @Min(0)
  maxPaise?: number;

  @IsOptional()
  @Transform(({ value }) => value === true || value === "true")
  @IsBoolean()
  inStock?: boolean;

  @IsOptional() @IsIn(PRODUCT_SORTS)
  sort?: (typeof PRODUCT_SORTS)[number];

  @IsOptional() @Type(() => Number) @IsInt() @Min(1)
  page?: number;

  @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
  limit?: number;
}

The @Transform on inStock is the "false" trap from module 7 — without it ?inStock=false filters to in-stock items.

The controller

// apps/api/src/products/products.controller.ts
import { Controller, Get, Param, Query } from "@nestjs/common";
import { Public } from "../auth/decorators/public.decorator";
import { ProductsService } from "./products.service";
import { QueryProductsDto } from "./dto/query-products.dto";

@Controller("products")
export class ProductsController {
  constructor(private readonly products: ProductsService) {}

  @Public()
  @Get()
  findAll(@Query() query: QueryProductsDto) {
    return this.products.findAll(query);
  }

  @Public()
  @Get(":slug")
  findOne(@Param("slug") slug: string) {
    return this.products.findBySlug(slug);
  }
}

Two lines each, as module 7 required.

@Public() because the global guard from module 8 protects everything by default. A catalogue that requires a login is a shop nobody can browse — and the point of failing closed is that you notice this immediately.

No @Get("featured") above @Get(":slug") yet — when you add one, it must come first, or featured will be read as a slug.

Categories

@Injectable()
export class CategoriesService {
  constructor(private readonly prisma: PrismaService) {}

  async findAll() {
    const rows = await this.prisma.category.findMany({
      orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
      select: {
        slug: true,
        name: true,
        _count: {
          select: {
            products: { where: { isActive: true, variants: { some: { isActive: true } } } },
          },
        },
      },
    });

    return rows
      .map((c) => ({ slug: c.slug, name: c.name, productCount: c._count.products }))
      .filter((c) => c.productCount > 0);
  }
}

_count with a filter gives the counts for the filter bar in one query.

Empty categories are dropped, because a filter promising results and delivering none is worse than not offering it.

Check your work

Why the mapper converts Decimal to Number: Prisma returns an object for decimal columns, which does not serialise or type-check as a number.

Why inStock is computed in the mapper: one place, so the stock count cannot leak anywhere downstream.

What variants: { some: ... } prevents: products whose variants are all inactive appearing with no price.

Why every orderBy ends with id: a unique tiebreaker, without which equal values have no defined order and rows repeat across pages.

Why price sorting is absent: price lives on the variant, and Prisma cannot order by a related field. Denormalising is the answer, and it belongs with the admin screens that maintain it.

Why @Public() is needed: the global guard protects everything by default, which is what makes forgetting it safe.

Why empty categories are filtered out: a filter that promises results and gives none is worse than not offering it.

Practice

  1. Build the products module with the mapper, service, DTO and controller.
  2. Call /api/products with curl. Confirm the envelope has all seven pagination fields.
  3. Confirm no variant in the response contains a stock count.
  4. Request ?limit=1000000 and confirm you get a 400 naming the limit — the DTO rejects it before the service runs. Then call findAll({ limit: 1000000 }) directly in a test and confirm the service clamps to 100 anyway.
  5. Deactivate every variant of a product. Confirm it vanishes from the list and 404s on its own page.
  6. Request ?inStock=false and confirm it does not filter. Remove the @Transform and watch it filter wrongly.
  7. Remove the id tiebreaker, give two products the same name, and page through looking for a repeat.
  8. Remove @Public() and confirm the catalogue returns 401 — then put it back.
  9. Add ?sort=price_asc and confirm it falls back to name rather than failing.
  10. Call /api/categories and confirm counts match, then deactivate every product in one category and confirm it disappears.

Next: the storefront that consumes this.

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