When working with APIs, it’s important to know what endpoints exist, what kind of request they expect, and where the code for them should be written. In NestJS:
- Controllers define the API routes (
POST, PATCH, GET, etc.) and shape the response.
- Services hold the actual logic (validation, updates, database operations).
In our case, tracking reading progress involves two main routes:
PATCH /reading/progress → update or create a reading session for a user and book
GET /reading/progress/:bookId → fetch all progress records for a specific book
Now, let’s see how the reading progress is actually updated. This happens in two main parts: the controller and the service.
1. The Controller Receives the Request
The controller listens for PATCH requests to the /reading/progress endpoint and passes the data to the service. Tracking progress is one part of the equation — but what if we want to retrieve progress data? Maybe we want to show how many users are currently reading Dune and how far each of them has progressed. That’s where the new GET route in our controller comes in handy.
// src/reading/reading.controller.ts
import { Controller, Patch, Body, Get, Param } from '@nestjs/common';
import { ReadingService } from './reading.service';
import { UpdateProgressDto } from './dto/update-progress.dto';
@Controller('reading')
export class ReadingController {
constructor(private readonly readingService: ReadingService) {}
@Patch('progress')
updateProgress(@Body() updateProgressDto: UpdateProgressDto) {
const session = this.readingService.updateProgress(updateProgressDto);
return { success: true, data: session };
}
@Get('progress/:bookId')
getProgress(@Param('bookId') bookId: string) {
const progress = this.readingService.getProgressByBook(bookId);
return { success: true, data: progress };
}
}
This new route allows clients to fetch reading progress for all users reading a specific book. It accepts a bookId as a route parameter and returns a list of user names with their current page numbers. This is especially useful for visualizations, leaderboards, or analytics dashboards in a real application.
Update or Create Progress
PATCH /reading/progress
Content-Type: application/json
{
"userId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"bookId": "e3f4a5b6-7890-12cd-ef34-567890abcdef",
"currentPage": 75
}
Response
{
"success": true,
"data": {
"userId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"bookId": "e3f4a5b6-7890-12cd-ef34-567890abcdef",
"currentPage": 75
}
}
Let’s now look at the corresponding service logic.
2. The Service Validates and Updates the Progress
The service does the real work. It checks if the user and book exist, then updates or creates a reading session. In addition to updateProgress, our service now has a getProgressByBook method, which aggregates all reading sessions for a given book and enriches each entry with user data:
// src/reading/reading.service.ts
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { UsersService } from '../users/users.service';
import { BooksService } from '../books/books.service';
import { UpdateProgressDto } from './dto/update-progress.dto';
@Injectable()
export class ReadingService {
constructor(
private readonly db: DatabaseService,
private readonly usersService: UsersService,
private readonly booksService: BooksService,
) {}
/**
* Update or create a reading session for a user and book.
*/
updateProgress(dto: UpdateProgressDto) {
// Ensure user and book exist
this.usersService.findOne(dto.userId);
this.booksService.findOne(dto.bookId);
let session = this.db
.getReadingSessions()
.find((s) => s.userId === dto.userId && s.bookId === dto.bookId);
if (session) {
session.currentPage = dto.currentPage;
} else {
session = { ...dto };
this.db.getReadingSessions().push(session);
}
return session;
}
/**
* Get reading progress for a specific book across all users.
*/
getProgressByBook(bookId: string) {
this.booksService.findOne(bookId);
const sessions = this.db.getReadingSessions().filter((s) => s.bookId === bookId);
const results = [] as any[];
for (const s of sessions) {
const user = this.db.findUserById(s.userId);
if (!user) continue; // skip orphaned sessions
results.push({ user, currentPage: s.currentPage });
}
return results;
}
}
What happens here?
This method first checks that the book exists. Then it filters the reading sessions for that book and returns an array of enriched results. Each item includes:
- A user object (retrieved from the
UsersService)
- The user's current reading page (
currentPage).
Here’s a sample GET request response for /reading/progress/a1b2c3d4-5678-90ab-cdef-1234567890ab:
{
"success": true,
"data": [
{
"user": {
"id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"name": "Alice"
},
"currentPage": 75
}
]
}
If you try to update progress for a user or book that doesn’t exist, you’ll get an error like:
{
"statusCode": 404,
"message": "User with ID aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee not found.",
"error": "Not Found"
}
When to Use Each Endpoint
At this point, you have two powerful endpoints under your /reading API:
PATCH /reading/progress: When a user wants to update their current page in a book
GET /reading/progress/:bookId: When we want to fetch the current page progress of all users reading that book
These two routes, backed by a clean service architecture and proper DTO validation, give your app dynamic tracking functionality without needing a full database yet.