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
- Build the products module with the mapper, service, DTO and controller.
- Call
/api/productswith curl. Confirm the envelope has all seven pagination fields. - Confirm no variant in the response contains a stock count.
- Request
?limit=1000000and confirm you get a 400 naming the limit — the DTO rejects it before the service runs. Then callfindAll({ limit: 1000000 })directly in a test and confirm the service clamps to 100 anyway. - Deactivate every variant of a product. Confirm it vanishes from the list and 404s on its own page.
- Request
?inStock=falseand confirm it does not filter. Remove the@Transformand watch it filter wrongly. - Remove the
idtiebreaker, give two products the same name, and page through looking for a repeat. - Remove
@Public()and confirm the catalogue returns 401 — then put it back. - Add
?sort=price_ascand confirm it falls back to name rather than failing. - Call
/api/categoriesand 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