Introduction: Why Track Reading Progress?

Welcome back! In the last lessons, you learned how to create and manage resources like users and books in your API using NestJS. Now, let’s take the next step and add a feature that makes our application more useful: tracking reading progress.

Imagine you are using a reading tracker app. You want to know how far you’ve read in each book and maybe even pick up right where you left off. This is a common feature in many reading and learning apps. When you stop at page 52, you want the app to remember that exact page, so next time you resume reading — boom, you're right there. That’s exactly what our ReadingSession is doing in this lesson. You will learn how to build this feature by connecting users, books, and their reading sessions together.

By the end of this lesson, you will know how to update and track a user’s reading progress for a specific book using modules and DTOs in NestJS.

Quick Recap: Project Structure and Data

Before we dive in, let’s quickly remind ourselves of the project setup. You already have modules for users and books, and a simple mock database to store data. Here’s a summary of the main app module and the mock data structure:

// src/app.module.ts
import { Module, Global } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { DatabaseService } from './database/database.service';
import { BooksModule } from './books/books.module';
import { ReadingModule } from './reading/reading.module';

@Global()
@Module({
  imports: [UsersModule, BooksModule, ReadingModule],
  controllers: [AppController],
  providers: [AppService, DatabaseService],
  exports: [DatabaseService],
})
export class AppModule {}
// src/database/mock-db.ts
export interface User { id: string; name: string; }

export interface Book { id: string; title: string; author: string; }

export interface ReadingSession { userId: string; bookId: string; currentPage: number; }

import { v4 as uuidv4 } from 'uuid';

export const users: User[] = [
  { id: uuidv4(), name: 'Alice' },
  { id: uuidv4(), name: 'Bob' },
];

export const books: Book[] = [
  { id: uuidv4(), title: 'The Hobbit', author: 'J.R.R. Tolkien' },
  { id: uuidv4(), title: 'Dune', author: 'Frank Herbert' },
];

export const readingSessions: ReadingSession[] = [
  { userId: users[0].id, bookId: books[0].id, currentPage: 50 },
];

This setup allows us to keep track of users, books, and reading sessions. The ReadingSession ties a user to a book and records their current page.

How the Reading Module Connects Everything

To track reading progress, we need a way to connect users and books through their reading sessions. This is where the ReadingModule comes in.

The ReadingModule imports both the UsersModule and the BooksModule. This allows it to use their services to check if a user or book exists before updating progress. Here’s how the module is set up:

// src/reading/reading.module.ts
import { Module } from '@nestjs/common';
import { ReadingService } from './reading.service';
import { ReadingController } from './reading.controller';
import { UsersModule } from 'src/users/users.module';
import { BooksModule } from 'src/books/books.module';

@Module({
  imports: [UsersModule, BooksModule], // Import modules to use their services
  controllers: [ReadingController],
  providers: [ReadingService],
})
export class ReadingModule {}

By importing the other modules, the ReadingModule can access user and book data, making it possible to validate and update reading progress for any user and book combination.

This gives the ReadingService access to methods like usersService.findOne() and booksService.findOne(). Otherwise, NestJS will throw a runtime error because the provider (service) was not visible in the current module’s context.

Using DTOs to Update Progress

When updating reading progress, we want to make sure the data sent to our API is correct. This is where a DTO (Data Transfer Object) comes in. A DTO helps us define and validate the shape of the data.

Here’s the DTO used for updating reading progress:

// src/reading/dto/update-progress.dto.ts
import { IsInt, IsPositive, IsUUID } from 'class-validator';

export class UpdateProgressDto {
  @IsUUID()
  userId!: string;

  @IsUUID()
  bookId!: string;

  @IsInt()
  @IsPositive()
  currentPage!: number;
}

This DTO ensures that:

  • userId and bookId must be valid UUIDs (not integers).
  • currentPage must be a positive integer.

For example, when a user wants to update their progress, they might send a request like this:

{
  "userId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  "bookId": "e3f4a5b6-7890-12cd-ef34-567890abcdef",
  "currentPage": 75
}

If the data doesn’t match the DTO rules (for example, if currentPage is negative), the request will be rejected. This helps keep your data clean and reliable.

Updating Reading Progress: Controller and Service in Action

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.

Summary And Practice Preview

In this lesson, you learned how to track and update reading progress by connecting users and books through reading sessions. You saw how the ReadingModule brings everything together, how DTOs help validate incoming data, and how the controller and service work to update or create reading sessions.

Next, you’ll get to practice these concepts by updating and managing reading progress yourself. Try sending different requests, updating progress for different users and books, and seeing how the system responds. This hands-on practice will help you solidify your understanding and prepare you for building even more features in the future.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal