Build an Authenticated Task App

Build a React task app backed by a protected NestJS and PostgreSQL API.

Introduction

30 Second Summary

A useful task app remembers yesterday’s work and keeps each person’s list private. Without both promises, it is still only a personal scratchpad.

In this project, you will build a full-stack task app with a React frontend and a protected NestJS REST API. Users will register, sign in with a JSON Web Token (JWT), and manage tasks stored through Prisma ORM in PostgreSQL.

What You'll Build

When you sign in through the finished browser app, you can create, complete, and delete tasks while another account sees a completely separate list.

By the end of this project, you'll have:

  • An authenticated browser workflow where a new user can register, sign in, and sign out without handling tokens manually.
  • A private task dashboard that creates, lists, completes, and deletes the signed-in user’s PostgreSQL records.
  • A protected API contract that rejects missing tokens with 401 and hides another user’s tasks behind 404 responses.
  • Secret Mission: Add completed and incomplete filters to the authenticated dashboard while preserving task ownership.

Are there any prerequisites?

Basic TypeScript, Node.js, and HTTP fundamentals will help. The project includes the React scaffold, dependency installation, database reset, and authentication setup.

Before We Start

This first checkpoint locks in your goal before any setup begins. You are building a persistent NestJS task API whose records survive server restarts because PostgreSQL stores them outside the server process.

Set Up a Reproducible WSL Workspace

A version mismatch can derail this API before you reach a single NestJS concept. Opening the project outside WSL can also separate your files from the tools that run them.

A single Windows Subsystem for Linux (WSL) workspace gives every command the same Linux context. You will pin Node.js there before the framework creates your project.

In this step, get ready to:
  • Verify Node.js plus the required command-line tools inside WSL.
  • Scaffold the ESM task API with pinned NestJS dependencies.
  • Run the starter API from WSL and request it from a second terminal.
Verify the WSL toolchain

Version checks show whether WSL can reach the tools this project expects. Docker plus Docker Compose must resolve inside Linux because PostgreSQL runs there later.

  • Select your installed Linux distribution from the Windows Start menu.
  • Check the required tools from the WSL terminal by running these commands:
node --version
npm --version
docker --version
docker compose version
code --version
curl --version

What do these checks prove?

  • The first two commands confirm that Node.js plus npm are available inside WSL.
  • The next two commands confirm that WSL can reach Docker plus Docker Compose.
  • The final two commands confirm that the VS Code command-line launcher plus curl are available.

Each available tool prints its version details. Use the first line to choose your Node.js setup path.

✔️ I see the required Node.js version

Your terminal reports v24.22.2. Your Node.js runtime is ready for the NestJS scaffold.

ⓧ I see an older Node.js version

The NestJS generator needs a supported Node.js release. Installing nvm gives WSL a reproducible way to select the required version.

  • Install nvm 0.40.8 by running the official installation command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash

What does this command do?

The command downloads the official nvm 0.40.8 installer. Bash runs that installer inside your WSL environment.

  • Close the current WSL terminal window.
  • Return to your Linux distribution from the Windows Start menu.
  • Install the required Node.js release by running this command:
nvm install 24.22.2

What does this command do?

nvm downloads Node.js 24.22.2 inside WSL. It also activates that release for the current shell.

  • Confirm the active Node.js version by running this command:
node --version

What should I see?

The terminal reports v24.22.2. The NestJS generator now has the runtime version it expects.

nvm still unavailable?

Confirm that you closed the original WSL terminal after the installer completed. A fresh shell loads the nvm configuration added by the installer.

If the command is still unavailable, help me troubleshoot the nvm installation inside WSL.

ⓧ Node.js is not installed

nvm can install the required Node.js release without changing the Windows-side runtime. This keeps the project toolchain inside WSL.

  • Install nvm 0.40.8 by running the official installation command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash

What does this command do?

The command downloads the official nvm 0.40.8 installer. Bash runs that installer inside your WSL environment.

  • Close the current WSL terminal window.
  • Return to your Linux distribution from the Windows Start menu.
  • Install Node.js 24.22.2 by running this command:
nvm install 24.22.2

What does this command do?

nvm downloads Node.js 24.22.2 inside WSL. It also activates that release for the current shell.

  • Confirm the installation by running this command:
node --version

What should I see?

The terminal reports v24.22.2. Node.js is now available inside WSL.

Node.js still unavailable?

Make sure the version installation completed before checking Node.js. A network interruption can leave the download unfinished.

If a fresh WSL terminal still cannot find Node.js, help me diagnose the Node.js installation.

The remaining version lines confirm that the project can reach npm, Docker, Docker Compose, the VS Code launcher, and curl from the same Linux environment.

Is another tool unavailable?

Confirm that your existing Docker installation is connected to your WSL distribution. Confirm that VS Code plus curl are installed where WSL can reach them.

For an environment-specific failure, help me diagnose the missing WSL tool.

The Visual Studio Code WSL integration keeps the editor interface on Windows while commands run against Linux files.

  • Move to your Desktop and open that Linux directory in VS Code by running these commands:
cd ~/Desktop
code .

What do these commands do?

  • The first command moves your WSL terminal to the Linux Desktop directory.
  • The second command asks VS Code to open the current Linux directory.

VS Code opens the Desktop directory. Its status area should identify the window as connected to WSL.

✔️ VS Code shows a WSL connection

Great. VS Code now edits the same Linux files that your WSL commands use.

ⓧ VS Code is not connected to WSL

The Microsoft WSL extension gives VS Code access to Linux folders plus terminals. Install the extension before reopening the directory.

  • Install the WSL extension and reopen the current directory by running these commands in the WSL terminal:
code --install-extension ms-vscode-remote.remote-wsl
code .

What do these commands do?

  • The first command installs the Microsoft WSL extension with its official extension identifier.
  • The second command reopens the current Linux directory in VS Code.

The reopened VS Code window should now identify its WSL connection in the status area.

VS Code still opening outside WSL?

Run the launcher from the WSL terminal instead of a Windows command prompt. This gives VS Code the Linux directory as its workspace.

If the window still uses a Windows path, help me reconnect VS Code to WSL.

Scaffold and pin the NestJS project

The Nest CLI creates the project structure plus the development scripts. Its default ESM layout keeps local imports aligned with the NestJS 12.3.2 runtime.

Why run NestJS in WSL?

Running the API directly in WSL keeps watch-mode feedback fast. Docker remains focused on the PostgreSQL database introduced later.

The first scaffold can take a few minutes while npm downloads the project packages. A quiet terminal during installation does not mean the process has stalled.

  • Return to the WSL terminal on your Desktop.
  • Prepare to approve the temporary CLI download if npx requests permission.
  • Choose ESM when the module-system prompt appears.
  • Scaffold nest-prisma-tasks with Nest CLI 12.0.11 by running this command:
npx @nestjs/cli@12.0.11 new nest-prisma-tasks --package-manager npm --language TS --strict --skip-git --skip-tests --no-observe

What does this command create?

  • The CLI creates the nest-prisma-tasks project with TypeScript strict mode enabled.
  • The package-manager option installs dependencies with npm.
  • The skip options omit starter tests plus Git initialization.
  • The final option skips NestJS Observe integration.

The terminal reports that the project was created successfully. The new folder now contains the NestJS starter application plus its npm lockfile.

  • Confirm the scaffolded files by running this command:
ls nest-prisma-tasks

What should I see?

The output includes src, package.json, and package-lock.json. These files confirm that the ESM starter was scaffolded with npm.

Scaffold not completing?

Confirm that the earlier Node.js check reports v24.22.2. An unsupported runtime can stop the Nest CLI before it creates the folder.

If npm reports a download or permission problem, help me troubleshoot the NestJS scaffold.

  • Enter the new project folder and open it as the VS Code workspace by running these commands:
cd ~/Desktop/nest-prisma-tasks
code .

What do these commands do?

  • The first command moves the WSL terminal into nest-prisma-tasks.
  • The second command opens that exact Linux folder as the VS Code workspace.

The VS Code Explorer now shows nest-prisma-tasks as the workspace folder. Your editor plus terminal are working against the same WSL project.

  • Pin the NestJS runtime packages by running this command from nest-prisma-tasks:
npm install @nestjs/common@12.3.2 @nestjs/core@12.3.2 @nestjs/platform-express@12.3.2 reflect-metadata@0.2.2 rxjs@7.8.2 --save-exact

What does this install?

  • The three NestJS packages provide the framework runtime plus the Express HTTP platform.
  • The metadata package supports NestJS decorators.
  • RxJS provides the reactive primitives used by the framework.
  • The exact-save option records each listed version without a version range.

npm updates package.json plus package-lock.json. Its completion summary confirms that the runtime packages were installed.

Runtime packages not installing?

Confirm that the terminal path ends with nest-prisma-tasks. npm needs the scaffolded package.json in the current directory.

If the installation stops with a network or dependency error, help me troubleshoot the runtime package installation.

  • Pin the development packages by running this command:
npm install --save-dev --save-exact @nestjs/cli@12.0.11 @nestjs/schematics@12.0.12 typescript@6.0.2

What does this install?

  • Nest CLI 12.0.11 provides the local project commands.
  • NestJS schematics 12.0.12 provides the code generators used in later steps.
  • TypeScript 6.0.2 compiles the ESM application.
  • The development flag records these packages under devDependencies.

npm updates the dependency records with exact development versions. The lockfile now preserves the complete resolved package tree.

Development packages not installing?

Check that the runtime package installation finished first. Confirm that the active terminal still points to the nest-prisma-tasks folder.

For a version-resolution failure, help me inspect the npm dependency output.

The .nvmrc file records the Node.js release expected by this project. Anyone using nvm can select the same runtime before installing dependencies.

  • Create .nvmrc with the required Node.js version by running this command:
printf '24.22.2\n' > .nvmrc

What does this command do?

The command writes 24.22.2 into .nvmrc. It adds a final newline so the file remains a standard text file.

  • Confirm the saved version by running this command:
cat .nvmrc

What should I see?

The terminal prints 24.22.2. This matches the Node.js runtime active in WSL.

Version file showing the wrong value?

Confirm that the command ran inside nest-prisma-tasks. Running it from the Desktop creates the file beside the project instead.

If the file contains extra text, help me correct the .nvmrc file.

✔️ Awesome, I've got everything!

Your pinned dependencies plus .nvmrc now describe a reproducible NestJS workspace.

ⓧ I'd like to double check the full code

24.22.2
Run and verify the starter API

The scaffold includes a development script that compiles the TypeScript source in watch mode. Keeping that process running lets the API respond while a second WSL terminal sends requests.

  • Start the NestJS development server from nest-prisma-tasks by running this command:
npm run start:dev

What does this command do?

  • npm runs the scaffolded NestJS development script.
  • Watch mode recompiles the application when project files change.
  • The running server keeps control of this terminal until you stop it with Ctrl+C.

The terminal completes its first compilation and reports that the application is listening. The starter API is now available at http://localhost:3000.

Server not reaching the listening state?

Confirm that the command ran inside the folder containing package.json. Check the terminal output for an unfinished package installation.

If compilation reports an environment-specific failure, help me troubleshoot the NestJS development server.

  • Select the plus icon in the VS Code terminal panel to open a second WSL terminal.

Before you send the request, do you expect the starter API to respond from the server running in the first terminal?

  • Request the starter API from the second WSL terminal by running this command:
curl http://localhost:3000

What does this request prove?

curl sends a GET request to the local NestJS server. A response proves that WSL can run the application plus reach its HTTP port.

You will see the Nest starter message in the second terminal. That is the setup complete: your pinned WSL workspace now serves a working API at http://localhost:3000.

Your reproducible NestJS workspace is ready. Next, you will replace the starter route with the first in-memory task endpoints.

Build an In-Memory Task API

Your NestJS starter proves that the server runs inside WSL. It does not manage tasks yet.

You will replace the starter behavior with a task-focused REST API. This first version keeps records in process memory so you can test whether that storage survives a restart.

In this step, get ready to:
  • Create a task feature with a module, controller, and service.
  • Use TypeScript to catch an invalid completion value.
  • Create a task before testing what happens after a server restart.
Build the tasks feature

A feature module groups the controller and service for one part of the application. The controller handles incoming requests while the service owns the temporary data.

  • Use the terminal menu in VS Code to create a second WSL terminal.
  • Confirm the terminal prompt is inside the existing nest-prisma-tasks folder.
  • Generate the task module, controller, and service by running these commands:
npx nest generate module tasks --no-spec
npx nest generate controller tasks --no-spec
npx nest generate service tasks --no-spec

What do these commands create?

  • The module groups the task feature behind one NestJS boundary.
  • The controller maps incoming HTTP requests to application methods.
  • The service becomes a provider that the controller receives through dependency injection.
  • Expand the src/tasks folder in the VS Code Explorer sidebar.
  • Confirm the folder contains tasks.module.ts, tasks.controller.ts, and tasks.service.ts.

Task files missing?

Check that the second terminal is inside the nest-prisma-tasks folder. Running the generators from another folder can place files outside your application.

Confirm the scaffold dependencies are installed before trying the commands again. Help me troubleshoot the NestJS task generators.

The temporary TaskRecord type defines the shape of every record. Its read-only numeric ID prevents application code from changing an identity after creation.

  • Select src/tasks/tasks.service.ts in the Explorer sidebar.
  • Replace the generated service with this in-memory implementation:
import { Injectable } from '@nestjs/common';

export type TaskRecord = {
  readonly id: number;
  title: string;
  description?: string;
  completed: boolean;
};
@Injectable()
export class TasksService {
  // Keep task data inside this server process for the first API version.
  private readonly tasks: TaskRecord[] = [];
  private nextId = 1;

  create(input: Pick<TaskRecord, 'title' | 'description'>): TaskRecord {
    // Assign each task a numeric ID and a default completion state.
    const task: TaskRecord = {
      id: this.nextId++,
      title: input.title,
      description: input.description,
      completed: false,
    };
    this.tasks.push(task);
    return task;
  }

  // Return the current process-memory snapshot.
  findAll(): TaskRecord[] { return this.tasks; }
}

What does this service do?

  • The tasks array holds records inside the running server process.
  • The nextId counter gives each new record a numeric identity.
  • The create() method stores a task with completed set to false.
  • The findAll() method returns the current array.
  • Save src/tasks/tasks.service.ts.
  • Return to the development server terminal from earlier.
  • Confirm the watch process recompiles without a TypeScript error.

Service not compiling?

Check that TaskRecord includes each field used inside the task object. Confirm every property ends with the same punctuation shown above.

Make sure Injectable is imported from @nestjs/common. Help me fix this task service error.

The controller gives the service an HTTP boundary. Its route prefix makes both methods available beneath /tasks.

  • Select src/tasks/tasks.controller.ts in the Explorer sidebar.
  • Replace the generated controller with these create and list routes:
import {
  Body,
  Controller,
  Get,
  Post,
} from '@nestjs/common';
import { TasksService, type TaskRecord } from './tasks.service.js';

@Controller('tasks')
export class TasksController {
  constructor(private readonly tasksService: TasksService) {}

  @Post()
  create(
    @Body() input: Pick<TaskRecord, 'title' | 'description'>,
  ) {
    // Pass the request body to the service that owns task data.
    return this.tasksService.create(input);
  }

  @Get()
  findAll() {
    // Return every task held by the current server process.
    return this.tasksService.findAll();
  }
}

How do these routes work?

  • The @Controller('tasks') decorator places both handlers beneath /tasks.
  • The @Post() handler passes the request body into create().
  • The @Get() handler asks the service for its current records.
  • The constructor lets NestJS inject the shared TasksService provider.
  • Save src/tasks/tasks.controller.ts.
  • Return to the development server terminal.
  • Confirm the controller compiles without an error.

Controller import failing?

Check that the local service import ends with .js. The ESM project uses JavaScript extensions for local imports even though you edit TypeScript files.

Confirm TaskRecord is exported from the service. Help me diagnose this controller import.

The root module decides which feature modules NestJS loads. Keeping only TasksModule removes the starter route from the running application.

  • Select src/app.module.ts in the Explorer sidebar.
  • Replace its contents with this task-focused root module:
import { Module } from '@nestjs/common';
import { TasksModule } from './tasks/tasks.module.js';

@Module({
  // Load the task feature instead of the starter controller.
  imports: [TasksModule],
})
export class AppModule {}

What does the root module change?

The imports array tells NestJS to load the generated task module. That module already registers TasksController and TasksService.

  • Save src/app.module.ts.
  • Confirm the development server recompiles successfully.
  • Request the empty task collection from the second WSL terminal by running this command:
curl http://localhost:3000/tasks

What does this request check?

curl sends a GET request because the command does not specify another method. The request reaches TasksController.findAll() through the /tasks route.

You will see [] because the new server process has an empty task array. Your first task route is live.

Tasks route not responding?

Confirm the development server terminal still shows a running watch process. Check that TasksModule appears in the imports array in src/app.module.ts.

Verify the request uses port 3000 and the plural path /tasks. Help me diagnose the task route.

✔️ Awesome, I've got everything!

Great. Your task feature compiles while the existing Node.js runtime pin remains in place.

ⓧ I'd like to double check the full code

24.22.2
Test the TaskRecord type

A TypeScript type turns the task shape into a compiler-checked contract. The completed field accepts boolean values such as true or false.

  • Return to src/tasks/tasks.service.ts.
  • Find this line inside the task object:
completed: false,

What is the current value?

The unquoted false value is a boolean. It matches the field declared by TaskRecord.

  • Replace the boolean assignment with this deliberate string assignment:
completed: 'false', // Deliberately use text to test the type.

What changed?

The quotation marks turn 'false' into text. The TaskRecord contract still requires a boolean.

Before you save, do you think the compiler will accept the quoted value?

  • Save src/tasks/tasks.service.ts to trigger the watch compiler.
  • Return to the development server terminal to inspect the result.

The terminal shows a TypeScript error at completed because text cannot fill a boolean field. This failure is intentional evidence that the type contract is active.

  • Replace the deliberate string assignment with the correct boolean line:
completed: false,

Why does this fix compile?

The corrected value matches the boolean field in TaskRecord. The service can create records without weakening the type.

  • Save src/tasks/tasks.service.ts.
  • Confirm the development server terminal returns to a successful compiled state.

Compiler error still present?

Check that the corrected value has no quotation marks. Confirm the line ends with a comma inside the task object.

Look for another unsaved copy of tasks.service.ts in the editor. Help me fix the remaining TaskRecord error.

Create, list, and restart

The routes now give you a complete create-and-read loop. The next requests prove that one process can store a task before you test the same data across a restart.

  • Create a task from the second WSL terminal by running this request:
curl --request POST http://localhost:3000/tasks \
  --header "Content-Type: application/json" \
  --data '{"title":"Write project notes","description":"Capture the API design"}'

What does this request send?

  • The request uses POST to call the controller's create handler.
  • The content header identifies the request body as JSON.
  • The service adds a numeric ID and the default completion value before storing the task.

That is your first stored task. The response includes its title, optional description, numeric ID, and completed value.

  • List the current in-memory tasks by running this command:
curl http://localhost:3000/tasks

What does the list prove?

The GET request reads the same array that received the POST request. You will see the task you just created inside the returned list.

The create-and-read loop works while this server process remains active.

  • Return to the development server terminal.
  • Press Ctrl+C to stop the current process.
  • Restart NestJS in watch mode by running this command:
npm run start:dev

What does this restart do?

The command starts the NestJS development server again. The application keeps the same source files and listens at http://localhost:3000.

  • Wait for the server terminal to confirm the application is running.

Before you make the final request, do you think the task array survived the server restart?

  • Return to the second WSL terminal.
  • Request the task list again by running this command:
curl http://localhost:3000/tasks

What did the restart reveal?

You will see [] because the new process created a new empty array. Process memory cannot preserve task records across application restarts.

You proved the storage gap by hitting it yourself. The API routes still work, but their records need a source of truth outside the NestJS process.

Task still appears after the restart?

Confirm you stopped the development server with Ctrl+C before starting it again. Saving a file triggers recompilation inside the same watch process, so it does not perform this restart test.

Check that the task service still initializes tasks as an empty array. Help me verify the restart test.

Your task API now exposes the exact weakness that persistent storage must solve. Next, you will give these records a PostgreSQL home and define their contract with Prisma.

Create the PostgreSQL Database and Prisma Schema

Your restart test proved that the NestJS task array loses every record when the application process stops. A durable task API needs a separate source of truth.

PostgreSQL stores task records beyond the lifetime of the server process. Its tables keep the same data while you restart the application.

Docker Compose runs the database in an isolated local container. Prisma ORM turns your task structure into a versioned database contract.

In this step, get ready to:
  • Start the PostgreSQL database with Docker Compose.
  • Install the pinned Prisma packages.
  • Model the Task table through an applied SQL migration.
Start PostgreSQL in Docker

The database container needs a repeatable definition for its image, credentials, network port, and storage. The named volume keeps database files outside the container lifecycle.

  • In the VS Code Explorer sidebar from earlier, click the New File icon beside package.json.
  • Enter compose.yaml as the file name.

You will see compose.yaml appear in the top-level project folder.

  • Add the database service to compose.yaml by pasting this configuration:
services:
  db:
    image: postgres:18.6-alpine3.24
    environment:
      POSTGRES_USER: task_user
      POSTGRES_PASSWORD: task_password
      POSTGRES_DB: task_api
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql

volumes:
  postgres_data:

What does this configuration do?

  • The db service runs the postgres:18.6-alpine3.24 image.
  • The three environment values create the local database user, password, and database.
  • The port mapping exposes PostgreSQL on local port 5432.
  • The postgres_data volume stores database files at /var/lib/postgresql.
  • Save compose.yaml.
  • Start the database service from the WSL project terminal by running this command:
docker compose up --detach

What does this command do?

Docker Compose reads compose.yaml and starts the db service in the background. The terminal remains available for the readiness check.

The first start can take a minute while Docker downloads the PostgreSQL 18.6 image. The command finishes faster on later starts because Docker keeps the image locally.

Before you check it, do you expect PostgreSQL to accept connections immediately or need a short startup window?

  • Confirm that the database is ready by running this command:
docker compose exec db pg_isready -U task_user -d task_api

What does this readiness check prove?

  • Docker Compose runs the readiness command inside the active db container.
  • The -U task_user option checks the configured database user.
  • The -d task_api option checks the configured database.

You will see accepting connections. That response proves the PostgreSQL server is ready for Prisma.

Database not accepting connections?

Confirm that Docker is running inside WSL. Wait a few seconds before repeating the readiness command if the image has only just started.

A different local database may already own port 5432. Stop that process before starting this Compose service again. Help me diagnose why my PostgreSQL Compose service is not accepting connections.

Install and configure Prisma

Prisma separates its command-line tool from the client that your application will use. Pinning both packages to 7.10.1 keeps the schema, migrations, and generated types on the same release.

  • Check whether Prisma packages already exist in the project by running this command:
npm ls prisma @prisma/client

What does this package check show?

The command reads the current project dependency tree. Its output shows whether the Prisma CLI and Prisma Client are missing, current, or on another version.

✔️ I see version 7.10.1

Your Prisma CLI and Prisma Client already match the project pin. Add the PostgreSQL adapter and environment loader to complete the package set.

  • Install the remaining pinned packages by running these commands:
npm install --save-dev dotenv@18.0.9
npm install @prisma/adapter-pg@7.10.1

What do these packages provide?

  • The dotenv package loads the database URL from .env.
  • The @prisma/adapter-pg package connects Prisma Client to PostgreSQL.

ⓧ I see another version

Your installed Prisma packages do not match the project pin. The exact install replaces their accepted versions with 7.10.1.

The installation can take a minute while npm resolves the updated dependency tree.

  • Install the complete pinned package set by running these commands:
npm install --save-dev prisma@7.10.1 dotenv@18.0.9
npm install @prisma/client@7.10.1 @prisma/adapter-pg@7.10.1

What do these installs change?

  • The first command pins the Prisma CLI and environment loader as development dependencies.
  • The second command pins Prisma Client and its PostgreSQL adapter as application dependencies.

ⓧ Prisma is not listed

The project does not have Prisma yet. The installation can take a minute while npm downloads the CLI, client, adapter, and environment loader.

  • Install the complete pinned package set by running these commands:
npm install --save-dev prisma@7.10.1 dotenv@18.0.9
npm install @prisma/client@7.10.1 @prisma/adapter-pg@7.10.1

What do these installs add?

  • The first command adds the Prisma CLI and environment loader to the development toolchain.
  • The second command adds the generated client runtime and PostgreSQL adapter.

Good progress. Your project now has the exact Prisma toolchain needed to create a PostgreSQL schema contract.

Package installation not completing?

Confirm that the terminal is inside the folder containing package.json. Check that WSL still uses Node.js v24.22.2 from your .nvmrc file.

A network or registry problem can also interrupt npm. Help me troubleshoot this Prisma package installation.

  • Initialize Prisma for PostgreSQL by running this command:
npx prisma init --datasource-provider postgresql --output ../src/generated/prisma

What does Prisma initialize?

  • The command creates prisma/schema.prisma for the data model.
  • It creates prisma7.config.ts for schema, migration, and connection settings.
  • It creates .env for the database connection URL.
  • The output option directs generated client files into src/generated/prisma.
  • Confirm that prisma appears in the VS Code Explorer sidebar.
  • Confirm that prisma7.config.ts appears beside package.json.

Prisma files missing?

Confirm that the initialization command finished inside the folder containing package.json. The files belong in the existing nest-prisma-tasks project.

Check the installation tab that matched your package state if the Prisma command could not run. Help me find why Prisma initialization did not create its files.

The connection string uses local development credentials from compose.yaml. Keep these values inside this local project because they are unsuitable for an exposed database.

  • Select .env in the Explorer sidebar.
  • Replace its contents with this local database connection:
DATABASE_URL="postgresql://task_user:task_password@localhost:5432/task_api?schema=public"

How does the connection URL work?

  • The URL identifies task_user as the PostgreSQL user.
  • It connects through local port 5432.
  • It selects the task_api database.
  • It places tables in the public schema.
  • Save .env.
  • Confirm that its first line starts with DATABASE_URL=.
  • Select prisma7.config.ts in the Explorer sidebar.
  • Replace its generated contents with this configuration:
import 'dotenv/config';
import { defineConfig } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: {
    path: 'prisma/migrations',
  },
  datasource: {
    url: process.env['DATABASE_URL'],
  },
});

How does Prisma find its files?

  • The dotenv import loads DATABASE_URL before Prisma reads the configuration.
  • The schema setting points to prisma/schema.prisma.
  • The migration setting stores database history in prisma/migrations.
  • The datasource setting passes the loaded connection URL to Prisma.
  • Save prisma7.config.ts.
  • Confirm that the configuration points to prisma/schema.prisma.
  • Confirm that the configuration points to prisma/migrations.
Model and migrate the Task table

A Prisma schema describes the fields that PostgreSQL must store. A SQL migration records the database change so another environment can reproduce it.

  • Select prisma/schema.prisma in the Explorer sidebar.
  • Replace its contents with the Task model below:
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model Task {
  id          Int     @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean @default(false)
}

What does the Task model define?

  • The id field becomes an auto-incrementing integer primary key.
  • The title field stores required text.
  • The description field stores optional text.
  • The completed field defaults to false.
  • Save prisma/schema.prisma.

Before you create the migration, which four columns do you expect Prisma to add to PostgreSQL?

  • Create the initial migration by running this command:
npx prisma migrate dev --name init

What does the migration command do?

  • Prisma compares the Task model with the current PostgreSQL database.
  • It creates the initial SQL migration in prisma/migrations.
  • It applies the migration to the local task_api database.

You will see the init migration apply successfully. PostgreSQL now has a Task table that matches your schema.

  • Expand prisma/migrations in the Explorer sidebar.
  • Select prisma/migrations/init/migration.sql.
  • Compare the generated SQL with this database contract:
CREATE TABLE "Task" (
    "id" SERIAL NOT NULL,
    "title" TEXT NOT NULL,
    "description" TEXT,
    "completed" BOOLEAN NOT NULL DEFAULT false,

    CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);

How does the SQL match the model?

  • The SERIAL column generates numeric task IDs.
  • The required title column rejects missing database values.
  • The description column permits an empty database value.
  • The completion column stores a boolean with a default value.

Prisma ORM 7 keeps migration and client generation as separate commands. The generated client gives later NestJS code model-aware database types.

Before you generate it, which project folder do you expect Prisma to fill with the Task client?

  • Generate Prisma Client from the migrated schema by running this command:
npx prisma generate

What does client generation create?

Prisma reads the Task model and creates a database client tailored to its fields. The output belongs in src/generated/prisma because that path was set in the generator block.

You will see Prisma Client generation complete. The src/generated/prisma folder now contains generated types for the Task model.

Migration or generation failing?

Repeat the PostgreSQL readiness check if Prisma cannot reach the database. Confirm that DATABASE_URL uses the same user, password, port, and database as compose.yaml.

Compare every field in prisma/schema.prisma with the model above if Prisma reports a schema problem. Help me diagnose this Prisma migration or generation failure.

✔️ Awesome, I've got everything!

Your database, connection configuration, Task schema, initial migration, and generated client are ready.

ⓧ I'd like to double check the full code

DATABASE_URL="postgresql://task_user:task_password@localhost:5432/task_api?schema=public"
24.22.2
services:
  db:
    image: postgres:18.6-alpine3.24
    environment:
      POSTGRES_USER: task_user
      POSTGRES_PASSWORD: task_password
      POSTGRES_DB: task_api
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql

volumes:
  postgres_data:
CREATE TABLE "Task" (
    "id" SERIAL NOT NULL,
    "title" TEXT NOT NULL,
    "description" TEXT,
    "completed" BOOLEAN NOT NULL DEFAULT false,

    CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model Task {
  id          Int     @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean @default(false)
}
import 'dotenv/config';
import { defineConfig } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: {
    path: 'prisma/migrations',
  },
  datasource: {
    url: process.env['DATABASE_URL'],
  },
});

Your PostgreSQL database now has a versioned Task contract. Next, you will inject the generated Prisma Client into NestJS and replace the temporary array with persistent queries.

Inject Prisma and Persist Tasks

Your restart test exposed the weakness in process memory. PostgreSQL is already running with a durable volume.

However, NestJS still reads from the temporary array. A shared Prisma Client provider moves data access into dependency injection so the database becomes the source of truth.

In this step, get ready to:
  • Create a lifecycle-aware Prisma provider.
  • Make the provider available to the task service.
  • Prove a task survives a NestJS restart.
Create the shared Prisma provider

Prisma Client needs the database URL before it can connect. Nest lifecycle hooks give the connection a clear startup point plus a clear shutdown point.

  • In the VS Code Explorer sidebar from earlier, select the src folder.
  • Click the New Folder icon in the Explorer toolbar.
  • Enter prisma as the folder name.

You should see a new src/prisma folder beside the existing task files.

  • Select the new src/prisma folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter prisma.service.ts as the file name.

The new file gives the database connection one home inside the Nest application.

  • Build the shared database provider in src/prisma/prisma.service.ts by pasting this code:
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client.js';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  constructor(configService: ConfigService) {
    const adapter = new PrismaPg({
      connectionString: configService.getOrThrow<string>('DATABASE_URL'),
    });
    super({ adapter });
  }

  async onModuleInit(): Promise<void> {
    await this.$connect();
  }

  async onModuleDestroy(): Promise<void> {
    await this.$disconnect();
  }
}

What does this provider do?

  • Extending PrismaClient gives the service access to generated model operations.
  • PrismaPg connects the generated client to PostgreSQL.
  • ConfigService reads DATABASE_URL from the project environment.
  • onModuleInit() opens the connection when Nest starts.
  • onModuleDestroy() closes the connection during application shutdown.
  • Save src/prisma/prisma.service.ts.

The development terminal should complete another TypeScript compilation without reporting an error in prisma.service.ts.

Seeing a provider compilation error?

Check that the generated import ends with client.js. Confirm that the file sits inside src/prisma.

Compare the lifecycle method names with the reference below. Help me troubleshoot this PrismaService compilation error.

✔️ Awesome, I've got everything!

Good work. Confirm that src/prisma/prisma.service.ts is saved.

ⓧ I'd like to double check the full code

import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client.js';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  constructor(configService: ConfigService) {
    const adapter = new PrismaPg({
      connectionString: configService.getOrThrow<string>('DATABASE_URL'),
    });
    super({ adapter });
  }

  async onModuleInit(): Promise<void> {
    await this.$connect();
  }

  async onModuleDestroy(): Promise<void> {
    await this.$disconnect();
  }
}

The reference shows the complete provider file. Its connection lifecycle now matches the Nest application lifecycle.

A provider becomes reusable when a module exports it. Importing that module then gives another module access to the same provider instance.

  • Select the src/prisma folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter prisma.module.ts as the file name.

You should see prisma.module.ts beside prisma.service.ts.

  • Define the reusable provider module in src/prisma/prisma.module.ts by pasting this code:
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

What does this module do?

  • The providers array lets Nest construct PrismaService.
  • The exports array makes that provider available to importing modules.
  • PrismaModule keeps database wiring separate from task behavior.
  • Save src/prisma/prisma.module.ts.

The development terminal should compile the new module without reporting an unresolved import.

Module import not resolving?

Confirm that both Prisma files are inside the same src/prisma folder. Check that the local import ends with .js.

Make sure the service file exports PrismaService. Help me fix this PrismaModule import problem.

✔️ Awesome, I've got everything!

Great. Confirm that both files in src/prisma are saved.

ⓧ I'd like to double check the full code

import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

The reference module exposes one shared PrismaService provider to the rest of the application.

Wire Prisma into the task module

The provider needs global configuration before it can read the database URL. The task module also needs to import the provider module before its service can request the connection.

  • In the Explorer sidebar, select src/app.module.ts.
  • Replace the contents of src/app.module.ts with this code:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TasksModule } from './tasks/tasks.module.js';

@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true }), TasksModule],
})
export class AppModule {}

How does configuration reach Prisma?

  • ConfigModule.forRoot() loads the values from .env.
  • isGlobal: true makes ConfigService available across the application.
  • TasksModule remains the application entry point for task behavior.
  • Save src/app.module.ts.

The development terminal should compile the updated application module without reporting a configuration import error.

Configuration module not compiling?

Confirm that the import comes from @nestjs/config. Check the brackets around ConfigModule.forRoot({ isGlobal: true }).

Keep TasksModule in the same imports array. Help me diagnose this AppModule configuration error.

✔️ Awesome, I've got everything!

Excellent. Confirm that src/app.module.ts is saved.

ⓧ I'd like to double check the full code

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TasksModule } from './tasks/tasks.module.js';

@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true }), TasksModule],
})
export class AppModule {}

The reference keeps global configuration beside the existing task module import.

The task module defines the provider scope for TasksService. Importing PrismaModule makes the exported database provider injectable there.

  • In the Explorer sidebar, select src/tasks/tasks.module.ts.
  • Replace the contents of src/tasks/tasks.module.ts with this code:
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { TasksController } from './tasks.controller.js';
import { TasksService } from './tasks.service.js';

@Module({
  imports: [PrismaModule],
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule {}

How does the provider reach TasksService?

  • PrismaModule exports the database provider.
  • The task module imports that exported provider into its dependency scope.
  • Nest can now supply PrismaService to the task service constructor.
  • Save src/tasks/tasks.module.ts.

The development terminal should compile the new module graph without reporting a missing module.

PrismaModule not available?

Check that the relative import starts with ../prisma. Confirm that PrismaModule appears in the module's imports array.

Make sure PrismaService remains in the Prisma module's exports array. Help me fix this Nest provider scope problem.

✔️ Awesome, I've got everything!

The module graph is ready. Confirm that src/tasks/tasks.module.ts is saved.

ⓧ I'd like to double check the full code

import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { TasksController } from './tasks.controller.js';
import { TasksService } from './tasks.service.js';

@Module({
  imports: [PrismaModule],
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule {}

The reference module keeps the existing controller plus service. It adds the database provider through PrismaModule.

TasksService still owns the temporary array. The next edits redirect its existing methods to the generated Task delegate.

  • Return to src/tasks/tasks.service.ts in the Explorer sidebar.
  • Add the Prisma service import beneath the existing Nest import by pasting this line:
import { PrismaService } from '../prisma/prisma.service.js';

Why import PrismaService here?

The import gives the task service a concrete provider type. Nest uses that type to resolve the constructor dependency.

  • Add this constructor immediately inside the TasksService class:
constructor(private readonly prisma: PrismaService) {}

What does constructor injection do?

Nest supplies the shared PrismaService instance when it creates TasksService. The private field gives each method access to the generated task operations.

  • Save src/tasks/tasks.service.ts.

The development terminal should rebuild the service without reporting an unresolved constructor dependency.

Nest cannot create TasksService?

Confirm that TasksModule imports PrismaModule. Check that the constructor type is exactly PrismaService.

A provider resolution problem usually points to the module import or export. Help me trace this dependency injection error.

The create path now needs to write the incoming task data through Prisma. The database assigns the numeric ID plus the default completion state.

  • Inside the existing create method, replace the array insertion logic with this return statement:
return this.prisma.task.create({ data });

What changes in the create path?

The generated task.create() operation inserts one row. It returns the stored record with its database-generated fields.

  • Replace the existing findAll method with this code:
findAll() {
  return this.prisma.task.findMany({
    orderBy: { id: 'asc' },
  });
}

What changes in the list path?

  • task.findMany() reads task rows from PostgreSQL.
  • orderBy keeps the generated numeric IDs in ascending order.
  • Save src/tasks/tasks.service.ts.

The development terminal should rebuild with both database operations available.

A single-record lookup gives later routes the same database-backed source of truth. Prisma returns the matching row when the ID exists.

  • Replace the first Nest import in src/tasks/tasks.service.ts with this line:
import { Injectable, NotFoundException } from '@nestjs/common';

Why include NotFoundException?

The lookup needs a clear response when Prisma returns no task. NotFoundException gives the service a standard missing-record result.

  • Add the database-backed findOne method after findAll by pasting this code:
async findOne(id: number) {
  const task = await this.prisma.task.findUnique({
    where: { id },
  });

  if (!task) {
    throw new NotFoundException(`Task ${id} not found`);
  }

  return task;
}

How does the lookup work?

  • task.findUnique() searches by the unique numeric ID.
  • The missing-record guard stops the method when no row matches.
  • The final return sends the stored task back to its caller.
  • Remove the private fields that stored the temporary task array.
  • Save src/tasks/tasks.service.ts.

The development terminal should compile successfully. Every task operation in the service now reads from or writes to PostgreSQL.

Task service still failing to compile?

Keep the existing input type on the create method. Confirm that its parameter is named data because the Prisma call passes that identifier.

Remove references to the deleted array from the method bodies. Help me compare my TasksService data access methods.

Prove the task survives a restart

A successful request proves that Nest can reach Prisma. A successful restart proves that the record belongs to PostgreSQL instead of the server process.

  • Switch back to the second WSL terminal from the earlier API test.
  • Rerun the same POST /tasks request from the previous step.

You should receive a stored task with a generated numeric ID. This response confirms that the create path reached PostgreSQL.

  • List the stored tasks by running this command:
curl http://localhost:3000/tasks

This request calls the database-backed list route. It leaves the stored task unchanged.

You should see the new task in the returned list. Note its numeric ID so you can compare the response after the restart.

  • Return to the first WSL terminal that is running the Nest development server.
  • Press Ctrl+C to stop only the Nest process.

The PostgreSQL container remains running. Its named volume keeps the task row intact.

  • Restart the Nest development server by running this command:
npm run start:dev

Nest recompiles the application. Prisma reconnects during module initialization.

Server not starting after the Prisma change?

Confirm that the database container from the previous step is still running. Check that .env remains beside package.json.

Review the first reported file path in the terminal for an import typo. Help me diagnose this NestJS startup failure.

Before you check the list, do you expect the task to keep the same numeric ID after restarting only NestJS?

  • Return to the second WSL terminal from earlier.
  • Request the task list again by running this command:
curl http://localhost:3000/tasks

This second request reads the rows after the application restart. The server process has no temporary array to restore.

You should see the same task with the same numeric ID. The record survived because PostgreSQL remained the source of truth.

Task missing after the restart?

Confirm that the first create response contained a numeric ID. Make sure you restarted only the Nest process.

Check that both list requests use http://localhost:3000/tasks. Help me trace why my PostgreSQL task did not persist.

That restart is the proof that your database integration works. Your task API now keeps records beyond the lifetime of one Nest process.

Your persistent create plus list paths are working. Next, you will validate incoming data and complete the remaining CRUD routes.

Finish Validated CRUD and Error Handling

Your NestJS API now keeps tasks in PostgreSQL across application restarts. That persistence gives every task a durable home.

The API boundary still needs protection from malformed data. It also needs predictable HTTP responses when a requested task does not exist.

In this step, get ready to:
  • Validate incoming task data with concrete DTO classes.
  • Complete the retrieve, update, and delete routes.
  • Prove the complete task lifecycle with 400 and 404 responses.
Validate incoming task data

A DTO is a concrete class that defines the data accepted by an endpoint. Validation decorators turn that class into an enforceable request contract.

  • Install the pinned validation packages in your existing WSL project by running this command:
npm install class-validator@0.15.1 class-transformer@0.5.1 --save-exact

What does this command do?

  • The command adds the decorator-based validation package used by the DTO classes.
  • It also adds the transformation package required by NestJS validation.
  • The exact-save flag records both package versions without a version range.

The terminal reports that the packages were added. Your project can now enforce validation decorators at runtime.

Package installation not completing?

Confirm that the terminal is inside the existing nest-prisma-tasks folder. The folder should contain package.json.

Check that WSL has network access if the download stalls. Help me troubleshoot this npm installation.

  • Select the src/tasks folder in the VS Code Explorer sidebar.
  • Click the New Folder icon in the Explorer toolbar.
  • Enter dto as the folder name.
  • Select the new src/tasks/dto folder.
  • Click the New File icon in the Explorer toolbar.
  • Enter create-task.dto.ts as the file name.
  • Define the create request contract in create-task.dto.ts by pasting this code:
import { IsOptional, IsString, MinLength } from 'class-validator';

export class CreateTaskDto {
  @IsString()
  @MinLength(3)
  title!: string;

  @IsOptional()
  @IsString()
  description?: string;
}

What does this DTO validate?

  • The concrete CreateTaskDto class gives NestJS runtime metadata for validation.
  • The title field must be a string containing at least three characters.
  • The description field can be omitted. A supplied description must be a string.
  • Save src/tasks/dto/create-task.dto.ts.
  • Confirm that create-task.dto.ts appears inside src/tasks/dto in the Explorer sidebar.

Seeing decorator errors?

Confirm that the import comes from class-validator. Check that every decorator sits directly above its matching property.

Make sure the file name ends with .dto.ts. Help me check this DTO.

  • Select the src/tasks/dto folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter update-task.dto.ts as the file name.
  • Define the update request contract in update-task.dto.ts by pasting this code:
import {
  IsBoolean,
  IsOptional,
  IsString,
  MinLength,
} from 'class-validator';

export class UpdateTaskDto {
  @IsOptional()
  @IsString()
  @MinLength(3)
  title?: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsBoolean()
  completed?: boolean;
}

What does this DTO validate?

  • Every update field is optional because a patch can change one property at a time.
  • A supplied title must satisfy the same rules as a created title.
  • A supplied completed value must be a boolean.
  • Save src/tasks/dto/update-task.dto.ts.
  • Confirm that both DTO files appear inside src/tasks/dto in the Explorer sidebar.

Update DTO not compiling?

Check that IsBoolean is included in the import group. Confirm that every property ends with a TypeScript type.

Make sure the optional properties use a question mark. Help me diagnose this update DTO.

The DTO classes describe valid data. A global ValidationPipe makes NestJS apply that contract to every request.

  • Select src/main.ts in the Explorer sidebar.
  • Replace its contents with this application bootstrap code:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
    }),
  );
  app.enableShutdownHooks();

  await app.listen(process.env.PORT ?? 3000);
}

await bootstrap();

What does this bootstrap code do?

  • The global pipe validates request bodies against concrete DTO classes.
  • The transform option converts supported incoming values to their declared types.
  • The whitelist option removes properties that have no validation decorator.
  • The forbidNonWhitelisted option rejects requests containing unknown properties.
  • Shutdown hooks let the existing Prisma provider disconnect during application termination.
  • Save src/main.ts.
  • Check the watch terminal from earlier for a successful compilation without TypeScript errors.

Bootstrap not compiling?

Confirm that ValidationPipe is imported from @nestjs/common. Check the commas inside the pipe options.

Keep the existing AppModule import ending in .js. Help me fix this bootstrap error.

The create route must use CreateTaskDto before the global pipe can enforce its decorators. The service also uses the DTO type to keep its database input aligned with the request contract.

  • In src/tasks/tasks.controller.ts replace the import section at the top with this final import group:
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Post,
} from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto.js';
import { UpdateTaskDto } from './dto/update-task.dto.js';
import { TasksService } from './tasks.service.js';

What do these imports provide?

  • The NestJS symbols bind request bodies, path parameters, and route methods.
  • The DTO imports connect validation classes to controller method parameters.
  • The service import preserves the controller's existing dependency.
  • In the same controller, replace the existing create() method with this typed version:
  @Post()
  create(@Body() createTaskDto: CreateTaskDto) {
    return this.tasksService.create(createTaskDto);
  }

How does the create route use validation?

The body decorator passes incoming JSON through CreateTaskDto. The global pipe rejects the request before the service runs when validation fails.

  • Save src/tasks/tasks.controller.ts.
  • Check the watch terminal for another successful compilation.

Controller import not resolving?

Confirm that both DTO files are inside src/tasks/dto. Keep .js on each local relative import.

Check that the method parameter uses the exact CreateTaskDto class name. Help me fix this controller import.

  • In src/tasks/tasks.service.ts replace the import section with this final import group:
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
import type { CreateTaskDto } from './dto/create-task.dto.js';
import type { UpdateTaskDto } from './dto/update-task.dto.js';

Why use type imports here?

The service needs each DTO only for static TypeScript checks. Type-only imports keep those classes out of the service's runtime dependencies.

  • In the same service, replace the existing create() method with this typed version:
  create(data: CreateTaskDto) {
    return this.prisma.task.create({ data });
  }

How does this preserve type safety?

The service accepts the validated create shape. Prisma then checks that the same data matches the generated task model.

  • Save src/tasks/tasks.service.ts.
  • Check the watch terminal for a successful application restart.

Before you test the boundary, do you expect a two-character title to reach Prisma or fail inside NestJS?

  • Send an invalid create request from the second WSL terminal by running this command:
curl --request POST http://localhost:3000/tasks \
  --header "Content-Type: application/json" \
  --data '{"title":"Hi"}'

What does this request test?

  • The request sends JSON to POST /tasks.
  • The supplied title has fewer than the required three characters.
  • The response proves that validation runs before the Prisma create operation.

You see a 400 response describing the title validation failure. The malformed task never reaches PostgreSQL.

Invalid request being accepted?

Confirm that create() uses CreateTaskDto. Check that ValidationPipe is registered before app.listen().

Restart the watch process if it did not reload after your edits. Help me diagnose missing request validation.

Return predictable missing-record errors

A database lookup can return no row for a valid numeric ID. The service converts that absence into a clear 404 response that every record-specific route can reuse.

  • In src/tasks/tasks.service.ts replace the existing findOne() method with this version:
  async findOne(id: number) {
    const task = await this.prisma.task.findUnique({
      where: { id },
    });

    if (!task) {
      throw new NotFoundException(`Task ${id} not found`);
    }

    return task;
  }

How does this create a 404 response?

  • The Prisma lookup returns either the matching task or null.
  • The missing-task branch throws NestJS's built-in NotFoundException.
  • The method returns the task only after the existence check passes.
  • Save src/tasks/tasks.service.ts.
  • Check the watch terminal for a successful compilation.

Before you request an unused numeric ID, do you expect an empty response or a 404 response?

  • Request an unused task ID by running this command:
curl http://localhost:3000/tasks/999999

What does this request prove?

The numeric path reaches findOne() after parameter parsing. The missing database row triggers the service's explicit exception.

You see a 404 response containing Task 999999 not found. The API now distinguishes an absent record from a successful lookup.

Missing task not returning 404?

Confirm that NotFoundException is imported from @nestjs/common. Check that the exception appears before the final return statement.

Make sure the request uses a numeric ID that does not exist. Help me troubleshoot this missing-record response.

The update and delete methods can call findOne() first. That shared guard gives both operations the same missing-record behavior.

  • Add the update method below findOne() in src/tasks/tasks.service.ts by pasting this code:
  async update(id: number, data: UpdateTaskDto) {
    await this.findOne(id);

    return this.prisma.task.update({
      where: { id },
      data,
    });
  }

How does the update method work?

  • The initial lookup returns a 404 response when the requested task is absent.
  • The Prisma update changes only the fields supplied by UpdateTaskDto.
  • Add the remove method below update() in the same service by pasting this code:
  async remove(id: number) {
    await this.findOne(id);

    return this.prisma.task.delete({
      where: { id },
    });
  }

How does the remove method work?

  • The shared lookup stops deletion when the ID has no matching task.
  • The Prisma delete operation returns the task it removed.
  • Save src/tasks/tasks.service.ts.
  • Check the watch terminal for a successful compilation without service errors.

Service methods not compiling?

Confirm that UpdateTaskDto is imported with the exact class name. Check that both methods remain inside TasksService.

Make sure each Prisma operation receives the numeric id in a where object. Help me fix these service methods.

The controller now needs routes that expose those service methods. ParseIntPipe rejects non-numeric path values before they reach the service.

  • Add this update route below findOne() in src/tasks/tasks.controller.ts by pasting this code:
  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateTaskDto: UpdateTaskDto,
  ) {
    return this.tasksService.update(id, updateTaskDto);
  }

How does the update route protect its inputs?

  • The path parameter passes through numeric parsing.
  • The request body passes through UpdateTaskDto validation.
  • The controller forwards both validated values to the service.
  • Add this delete route below update() in the same controller by pasting this code:
  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.tasksService.remove(id);
  }

How does the delete route protect its input?

The route accepts only a numeric task ID. It delegates the existence check plus deletion to the service.

  • Save src/tasks/tasks.controller.ts.
  • Check the watch terminal for a successful application restart without route errors.

Controller routes not compiling?

Confirm that Patch and Delete are present in the NestJS import group. Check that UpdateTaskDto uses the local import ending in .js.

Keep both route methods inside TasksController. Help me diagnose these route errors.

Exercise the complete task lifecycle

The API now has five routes sharing one validated data contract. A complete lifecycle proves that they work together against the persistent database.

Before you create a valid task, predict which fields the response will add to the JSON you send.

  • Create a valid task by running this command in the second WSL terminal:
curl --request POST http://localhost:3000/tasks \
  --header "Content-Type: application/json" \
  --data '{"title":"Finish CRUD API","description":"Verify every route"}'

What should this request return?

The request satisfies the create DTO. PostgreSQL stores the row before Prisma returns its generated ID plus default completion state.

You see the title and description from the request. You also see a generated numeric ID with completed set to false.

  • Record the generated ID here: your generated task ID.
  • List every stored task by running this command:
curl http://localhost:3000/tasks

What should the list show?

The response contains the new task in ascending ID order. Tasks saved during the previous persistence test also remain available.

  • Retrieve the new task by its generated ID by running this command:
curl http://localhost:3000/tasks/[[TASK_ID="your generated task ID"]]

What should the lookup show?

The response contains one task whose ID matches the value you recorded. Its completion state is still false.

  • Mark the task as completed by running this command:
curl --request PATCH http://localhost:3000/tasks/[[TASK_ID="your generated task ID"]] \
  --header "Content-Type: application/json" \
  --data '{"completed":true}'

What should the update show?

The response contains the same generated ID. Its completed value is now true.

Your patch worked. The validated boolean has been stored in PostgreSQL without replacing the task's title or description.

  • Delete the completed task by running this command:
curl -X DELETE http://localhost:3000/tasks/[[TASK_ID="your generated task ID"]]

What should the delete show?

The response contains the deleted task. Its returned ID confirms which database row was removed.

Before the final check, do you expect the deleted ID to remain in the list?

  • Verify the final database state by running this command:
curl http://localhost:3000/tasks

What does the final list prove?

The deleted task ID is absent from the response. Any other persistent tasks remain untouched.

Lifecycle request not working?

Confirm that the watch terminal still reports the application running on port 3000. Check that the generated ID is identical in the retrieve, patch, and delete commands.

Make sure the JSON request bodies use double quotes inside single quotes. Help me diagnose this CRUD request.

Use this final reference to compare every file changed in this step.

✔️ Awesome, I've got everything!

Great. Double-check that you saved all five changed source files.

ⓧ I'd like to double check the full code

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
    }),
  );
  app.enableShutdownHooks();

  await app.listen(process.env.PORT ?? 3000);
}

await bootstrap();
import { IsOptional, IsString, MinLength } from 'class-validator';

export class CreateTaskDto {
  @IsString()
  @MinLength(3)
  title!: string;

  @IsOptional()
  @IsString()
  description?: string;
}
import {
  IsBoolean,
  IsOptional,
  IsString,
  MinLength,
} from 'class-validator';

export class UpdateTaskDto {
  @IsOptional()
  @IsString()
  @MinLength(3)
  title?: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsBoolean()
  completed?: boolean;
}
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Post,
} from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto.js';
import { UpdateTaskDto } from './dto/update-task.dto.js';
import { TasksService } from './tasks.service.js';

@Controller('tasks')
export class TasksController {
  constructor(private readonly tasksService: TasksService) {}

  @Post()
  create(@Body() createTaskDto: CreateTaskDto) {
    return this.tasksService.create(createTaskDto);
  }

  @Get()
  findAll() {
    return this.tasksService.findAll();
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.tasksService.findOne(id);
  }

  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateTaskDto: UpdateTaskDto,
  ) {
    return this.tasksService.update(id, updateTaskDto);
  }

  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.tasksService.remove(id);
  }
}
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
import type { CreateTaskDto } from './dto/create-task.dto.js';
import type { UpdateTaskDto } from './dto/update-task.dto.js';

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

  create(data: CreateTaskDto) {
    return this.prisma.task.create({ data });
  }

  findAll() {
    return this.prisma.task.findMany({
      orderBy: { id: 'asc' },
    });
  }

  async findOne(id: number) {
    const task = await this.prisma.task.findUnique({
      where: { id },
    });

    if (!task) {
      throw new NotFoundException(`Task ${id} not found`);
    }

    return task;
  }

  async update(id: number, data: UpdateTaskDto) {
    await this.findOne(id);

    return this.prisma.task.update({
      where: { id },
      data,
    });
  }

  async remove(id: number) {
    await this.findOne(id);

    return this.prisma.task.delete({
      where: { id },
    });
  }
}

You now have a persistent CRUD API with validated request bodies plus predictable 400 and 404 responses. Next, you will give every task an owner and issue a signed token when that owner logs in.

Add User Registration and JWT Login

Your task API now stores reliable records, but every caller still shares the same collection. User accounts give each task a durable owner before the frontend arrives.

JSON Web Tokens (JWTs) let the API issue a signed identity after login. In this step, you will store hashed passwords and return a one-hour access token.

In this step, get ready to:
  • Replace the practice database with an ownership-ready schema.
  • Store user passwords as bcrypt hashes.
  • Register a user and issue a signed access token.
Reset the database for ownership

The practice tasks will be deleted

Adding a required owner to an existing populated table would leave the old rows without a valid user. This local reset removes only the practice records from earlier steps.

  • Stop the NestJS watch process by pressing Ctrl+C in its WSL terminal.
  • Remove the current PostgreSQL container and practice volume by running:
docker compose down --volumes

What does this reset remove?

This removes the local database volume that holds the earlier task rows. Your source files and Prisma migrations remain intact.

  • Start an empty PostgreSQL database by running:
docker compose up --detach

The first startup can take a few seconds while PostgreSQL initializes the empty volume. You will see the container running in the command output.

Database container not staying up?

Run docker compose ps and confirm that the database service is running. If it exits, compare the credentials in compose.yaml with .env.

Help me diagnose the PostgreSQL restart.

  • Open prisma/schema.prisma in the VS Code Explorer sidebar.
  • Replace the existing models with this ownership schema:
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id           Int    @id @default(autoincrement())
  email        String @unique
  passwordHash String
  tasks        Task[]
}

model Task {
  id          Int     @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean @default(false)
  ownerId     Int
  owner        User    @relation(fields: [ownerId], references: [id], onDelete: Cascade)

  @@index([ownerId])
}

How does ownership work?

  • User.email is unique so one address identifies one account.
  • Task.ownerId is required, so every new task belongs to a user.
  • onDelete: Cascade removes a user’s tasks if that user is deleted.
  • @@index([ownerId]) supports the owner-scoped queries you add next.
  • Save prisma/schema.prisma.
  • Create the ownership migration and regenerate the client by running:
npx prisma migrate dev --name add_user_ownership
npx prisma generate

You should see the existing migrations applied to the empty database, followed by a new ownership migration and a generated Prisma client.

Ownership migration failing?

Confirm that PostgreSQL is running before retrying. If Prisma reports a missing relation field, check that User.tasks and Task.owner both exist.

Help me debug the ownership migration.

Configure password hashing and JWT signing
  • Install the verified authentication packages by running:
npm install @nestjs/jwt@12.0.3 bcrypt@6.0.0 --save-exact
npm install --save-dev @types/bcrypt@6.0.0 --save-exact

What do these packages add?

  • @nestjs/jwt signs and verifies the access token.
  • bcrypt hashes passwords without storing the original value.
  • @types/bcrypt supplies TypeScript declarations for the hashing API.

The bcrypt package may spend a moment selecting or building its native binary. The installation is working while npm continues printing package output.

bcrypt installation failing?

Confirm that you are using the stable Node.js version installed earlier. If npm reports a native build error, retry after updating npm inside WSL.

Help me troubleshoot the bcrypt installation.

  • Generate a local signing secret by running:
openssl rand -base64 32

Copy the generated value without sharing it. This secret signs every token issued by your local API.

  • Open .env from the Explorer sidebar.
  • Add a new line named JWT_SECRET and paste the generated value after the equals sign.
  • Save .env without displaying its contents in a screenshot.

Keep the signing secret private

The signing secret is a live credential even in a local project. Keep .env out of source control and never paste the value into project validations.

Build registration and login
  • Create a src/auth folder from the VS Code Explorer sidebar.
  • Create auth.module.ts inside src/auth.
  • Start the file with the imports and validated credential DTO below:
import { Body, Controller, Injectable, Module, Post, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule, JwtService } from '@nestjs/jwt';
import bcrypt from 'bcrypt';
import { IsEmail, MinLength } from 'class-validator';
import { PrismaModule } from '../prisma/prisma.module.js';
import { PrismaService } from '../prisma/prisma.service.js';

// Validate the credentials accepted by both public routes.
class AuthDto {
  @IsEmail()
  email!: string;

  @MinLength(8)
  password!: string;
}

What does this first chunk prepare?

  • IsEmail rejects malformed account addresses.
  • MinLength(8) rejects short passwords before hashing begins.
  • PrismaService stores users in the same PostgreSQL database as tasks.
  • Add the authentication service below AuthDto in the same file:
@Injectable()
class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly jwt: JwtService,
  ) {}

  async register(input: AuthDto) {
    // Store only a salted password hash.
    const passwordHash = await bcrypt.hash(input.password, 10);
    return this.prisma.user.create({
      data: { email: input.email, passwordHash },
      select: { id: true, email: true },
    });
  }

  async login(input: AuthDto) {
    const user = await this.prisma.user.findUnique({ where: { email: input.email } });
    const valid = user && await bcrypt.compare(input.password, user.passwordHash);
    if (!valid) throw new UnauthorizedException('Invalid email or password');

    // Put the stable user id in the standard subject claim.
    const accessToken = await this.jwt.signAsync({ sub: user.id, email: user.email });
    return { accessToken };
  }
}

How does the service protect credentials?

  • bcrypt.hash() turns the submitted password into a salted one-way hash.
  • bcrypt.compare() checks a login without recovering the original password.
  • sub carries the authenticated user’s numeric ID inside the signed token.
  • Add the controller and module below AuthService in the same file:
@Controller('auth')
class AuthController {
  constructor(private readonly auth: AuthService) {}

  @Post('register')
  register(@Body() input: AuthDto) {
    return this.auth.register(input);
  }

  @Post('login')
  login(@Body() input: AuthDto) {
    return this.auth.login(input);
  }
}

@Module({
  imports: [
    PrismaModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.getOrThrow<string>('JWT_SECRET'),
        signOptions: { expiresIn: '1h' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
  exports: [JwtModule],
})
export class AuthModule {}

What does the module expose?

  • POST /auth/register creates an account without returning the password hash.
  • POST /auth/login returns a signed token after a valid password check.
  • expiresIn: '1h' keeps the local token temporary.
  • Save src/auth/auth.module.ts.
  • Open src/app.module.ts in the Explorer sidebar.
  • Replace its contents with this module registration:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { TasksModule } from './tasks/tasks.module.js';

@Module({
  imports: [
    // Load environment values before authentication starts.
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    TasksModule,
    AuthModule,
  ],
})
export class AppModule {}

How does the app load authentication?

The root module now creates the authentication routes alongside the existing task routes. Global configuration makes JWT_SECRET available to the JWT module.

  • Save src/app.module.ts.
  • Start the API in the WSL terminal by running:
npm run start:dev

You should see a successful compilation and route mappings for /auth/register plus /auth/login.

Authentication module not starting?

A missing-secret error means JWT_SECRET is absent from .env. A Prisma type error means the generated client needs another npx prisma generate run.

Help me debug the authentication module.

Before you send the requests, which response should contain a token: registration or login?

  • Register a safe local test account from a second WSL terminal by running:
curl --request POST http://localhost:3000/auth/register \
  --header "Content-Type: application/json" \
  --data '{"email":"owner@example.com","password":"local-password-123"}'

You should receive only the new numeric user ID and owner@example.com. The password and hash stay out of the response.

  • Log in with the same local credentials by running:
curl --request POST http://localhost:3000/auth/login \
  --header "Content-Type: application/json" \
  --data '{"email":"owner@example.com","password":"local-password-123"}'

That worked. The response now contains an accessToken string signed for the registered user.

Registration or login failing?

A second registration with the same email is rejected by the database’s unique constraint. A login returns 401 when either credential is wrong.

Help me debug registration or login safely.

Your API can now create accounts and issue signed identities without exposing password hashes. Next, you will require that identity on every task route and keep each user’s records separate.

Protect Tasks by User

Registration now proves who a caller claims to be, but the task routes still accept anonymous requests. The access token becomes useful only when the API verifies it before each task operation.

A guard runs before the controller and attaches the verified user to the request. The service then includes that user’s ID in every Prisma query.

In this step, get ready to:
  • Reject task requests without a valid bearer token.
  • Pass the authenticated user ID into every task operation.
  • Prove that one account cannot read another account’s task.
Verify bearer tokens
  • Create jwt-auth.guard.ts inside the existing src/auth folder.
  • Add the bearer-token guard with this code:
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

export type AuthUser = { id: number; email: string };

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly jwt: JwtService) {}

  async canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    if (type !== 'Bearer' || !token) throw new UnauthorizedException();

    try {
      // Verify the signature and expose a small request identity.
      const payload = await this.jwt.verifyAsync<{ sub: number; email: string }>(token);
      request.user = { id: payload.sub, email: payload.email } satisfies AuthUser;
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }
}

How does the guard verify identity?

  • The guard requires the standard Authorization: Bearer <token> header shape.
  • verifyAsync() rejects changed, expired, or incorrectly signed tokens.
  • request.user carries only the verified ID and email into the controller.
  • Save src/auth/jwt-auth.guard.ts.
  • Create current-user.decorator.ts inside src/auth.
  • Add a decorator that reads the verified user:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthUser } from './jwt-auth.guard.js';

// Keep request-shape details out of task route methods.
export const CurrentUser = createParamDecorator(
  (_data: unknown, context: ExecutionContext): AuthUser => {
    return context.switchToHttp().getRequest<{ user: AuthUser }>().user;
  },
);

Why use a parameter decorator?

CurrentUser gives a controller method the user created by the guard. Task routes can now receive a typed identity without reading raw headers.

  • Save src/auth/current-user.decorator.ts.
  • Open src/auth/auth.module.ts.
  • Add this import below the existing local imports:
import { JwtAuthGuard } from './jwt-auth.guard.js';
  • Find the existing provider and export lines:
  providers: [AuthService],
  exports: [JwtModule],
  • Replace them so other modules can inject the guard:
  providers: [AuthService, JwtAuthGuard],
  exports: [JwtAuthGuard],
  • Save src/auth/auth.module.ts.
  • Check the watch terminal for a successful compilation.

Guard dependency not resolving?

Confirm that JwtAuthGuard is both a provider and an export. Keep the import’s .js suffix for the ESM build.

Help me fix the guard registration.

Scope every task query

Filtering only the list route would leave direct ID routes exposed. Every create, read, update, and delete method must carry the same verified owner ID.

  • Open src/tasks/tasks.module.ts in the Explorer sidebar.
  • Replace its contents so the task module can use the guard:
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { TasksController } from './tasks.controller.js';
import { TasksService } from './tasks.service.js';

@Module({
  // Import the exported guard beside the existing database provider.
  imports: [PrismaModule, AuthModule],
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule {}

Why import both modules?

AuthModule supplies the configured guard. PrismaModule continues to supply the shared database client.

  • Save src/tasks/tasks.module.ts.
  • Open src/tasks/tasks.service.ts.
  • Replace create() and findAll() with these owner-scoped methods:
  create(ownerId: number, data: CreateTaskDto) {
    // Attach ownership inside the trusted service boundary.
    return this.prisma.task.create({
      data: { ...data, ownerId },
    });
  }

  findAll(ownerId: number) {
    return this.prisma.task.findMany({
      where: { ownerId },
      orderBy: { id: 'asc' },
    });
  }

How do collection routes enforce ownership?

  • create() ignores any client-supplied owner and uses the verified ID.
  • findAll() returns only rows whose foreign key matches that ID.
  • Replace the existing findOne() method with this owner-aware lookup:
  async findOne(ownerId: number, id: number) {
    // Match the record and its owner in one lookup.
    const task = await this.prisma.task.findFirst({
      where: { id, ownerId },
    });

    if (!task) {
      throw new NotFoundException(`Task ${id} not found`);
    }

    return task;
  }

Why return 404 for both cases?

A missing task and another user’s task now produce the same 404 response. That avoids revealing whether a hidden record exists.

  • Replace update() and remove() with these guarded mutations:
  async update(ownerId: number, id: number, data: UpdateTaskDto) {
    // Prove ownership before changing the row.
    await this.findOne(ownerId, id);
    return this.prisma.task.update({
      where: { id },
      data,
    });
  }

  async remove(ownerId: number, id: number) {
    // Reuse the same ownership check before deletion.
    await this.findOne(ownerId, id);
    return this.prisma.task.delete({
      where: { id },
    });
  }
  • Save src/tasks/tasks.service.ts.
  • Open src/tasks/tasks.controller.ts.
  • Replace the import group with this authenticated version:
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Post,
  UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { AuthUser, JwtAuthGuard } from '../auth/jwt-auth.guard.js';
import { CreateTaskDto } from './dto/create-task.dto.js';
import { UpdateTaskDto } from './dto/update-task.dto.js';
import { TasksService } from './tasks.service.js';
  • Add the guard directly above the existing controller decorator:
@UseGuards(JwtAuthGuard)
@Controller('tasks')
  • Replace the five route methods with these authenticated versions:
  @Post()
  create(@CurrentUser() user: AuthUser, @Body() data: CreateTaskDto) {
    return this.tasksService.create(user.id, data);
  }

  @Get()
  findAll(@CurrentUser() user: AuthUser) {
    return this.tasksService.findAll(user.id);
  }

  @Get(':id')
  findOne(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
    return this.tasksService.findOne(user.id, id);
  }
  @Patch(':id')
  update(
    @CurrentUser() user: AuthUser,
    @Param('id', ParseIntPipe) id: number,
    @Body() data: UpdateTaskDto,
  ) {
    return this.tasksService.update(user.id, id, data);
  }

  @Delete(':id')
  remove(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
    return this.tasksService.remove(user.id, id);
  }

How does the controller pass ownership?

  • @UseGuards() protects every route in the controller.
  • @CurrentUser() injects the identity verified before the method runs.
  • Each service call receives user.id before task data or path IDs.
  • Save src/tasks/tasks.controller.ts.
  • Check the watch terminal for a successful application restart.

Task routes not compiling?

Check every service call for the new user.id first argument. If the guard cannot resolve, confirm that TasksModule imports AuthModule.

Help me debug the owner-scoped task routes.

Prove account isolation

The designed test uses two accounts against the same database. The second account should receive no clues about the first account’s task.

Before you call the protected list without a token, do you expect an empty array or an authentication error?

  • Call the protected route without credentials by running:
curl --include http://localhost:3000/tasks

You should see 401 Unauthorized. The request stops before the task controller runs.

  • Log in as owner@example.com again if you need a fresh token.
  • Store the returned token in the current shell by replacing the plain placeholder below:
TOKEN='paste-your-access-token-here'

Keep the token out of project content

Access tokens are credentials, so this placeholder deliberately does not use a saved project variable. Keep the value in this terminal and out of screenshots.

  • Create a task for the first account by running:
curl --request POST http://localhost:3000/tasks \
  --header "Authorization: Bearer $TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"title":"Private owner task"}'

You should receive a stored task containing an ownerId that matches the authenticated account.

  • Record the new task ID here: private task ID.
  • Register a second account by running:
curl --request POST http://localhost:3000/auth/register \
  --header "Content-Type: application/json" \
  --data '{"email":"second@example.com","password":"second-password-123"}'
  • Log in as the second account by running:
curl --request POST http://localhost:3000/auth/login \
  --header "Content-Type: application/json" \
  --data '{"email":"second@example.com","password":"second-password-123"}'
  • Store the second token by replacing the plain placeholder below:
SECOND_TOKEN='paste-the-second-access-token-here'

Before the second account requests the first account’s task, predict whether the API should reveal it or hide it.

  • Request the first account’s task with the second token by running:
curl --include http://localhost:3000/tasks/[[PRIVATE_TASK_ID="private task ID"]] \
  --header "Authorization: Bearer $SECOND_TOKEN"

You should see 404 Not Found even though the task exists. That is the ownership boundary working.

Seeing the wrong isolation result?

A 401 response means the second token was missing, expired, or pasted incorrectly. A visible task means findOne() is still filtering by ID alone.

Help me debug task isolation safely.

That is the security boundary proven: valid tokens identify callers, and every task query respects that identity. Next, you will replace terminal requests with a React sign-in screen.

Create the React Authentication UI

The API now knows who owns each task, but using shell variables is not the experience an app user expects. A browser form can collect credentials and keep the returned token behind the interface.

You will use React with Vite to build that interface. The frontend runs on port 5173 while NestJS remains on port 3000.

In this step, get ready to:
  • Scaffold a React and TypeScript frontend.
  • Allow the frontend origin through NestJS CORS.
  • Register and sign in through a browser form.
Scaffold the frontend

Why Vite for this frontend?

Vite gives this small client a TypeScript build, development server, and React refresh loop without adding a larger full-stack framework. The API remains a separate NestJS application.

  • Return to a WSL terminal inside the existing nest-prisma-tasks folder.
  • Scaffold the pinned React and TypeScript client by running:
npm create vite@9.3.3 frontend -- --template react-ts

The scaffold creates a new frontend folder without an interactive framework picker. You should see a short list of next commands.

  • Move into the new frontend and install its dependencies by running:
cd frontend
npm install

The installation can take a minute while npm downloads React 19.3.1 and the Vite 8 toolchain. You should see a completed package summary.

  • Confirm the scaffolded files by running:
ls

You should see index.html, package.json, and src in the output.

Frontend scaffold not completing?

Confirm that Node.js 24 from the setup step is active. If npm cannot create the folder, run the scaffold from nest-prisma-tasks and check that no existing frontend folder blocks it.

Help me troubleshoot the Vite scaffold.

Connect the browser to the API

Cross-Origin Resource Sharing (CORS) controls which browser origins can call the API. You will allow only the local Vite origin used by this project.

  • Return to VS Code connected to the nest-prisma-tasks WSL folder.
  • Open the backend file src/main.ts from the Explorer sidebar.
  • Find the existing shutdown-hook line:
  app.enableShutdownHooks();
  • Add the CORS line directly above it:
  // Allow requests from the local Vite development server.
  app.enableCors({ origin: 'http://localhost:5173' });
  app.enableShutdownHooks();
  • Save src/main.ts.
  • Check the NestJS watch terminal for a successful restart.

Browser reporting a CORS error?

Keep the origin exactly http://localhost:5173 without a trailing slash. A different Vite port requires the same value here.

Help me debug the CORS connection.

  • Create frontend/src/api.ts from the VS Code Explorer sidebar.
  • Add the typed authentication helper:
const API_URL = 'http://localhost:3000';

export type Credentials = {
  email: string;
  password: string;
};

type AuthResult = { accessToken: string };

export async function authenticate(
  action: 'register' | 'login',
  credentials: Credentials,
) {
  // Send credentials only when the learner submits the form.
  const response = await fetch(`${API_URL}/auth/${action}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credentials),
  });

  if (!response.ok) throw new Error('Authentication failed');
  return response.json() as Promise<AuthResult>;
}

What does the API helper do?

  • API_URL points the browser at the NestJS process.
  • Credentials describes the shared registration and login body.
  • response.ok turns rejected HTTP responses into a form error.
  • Save frontend/src/api.ts.
  • Confirm that api.ts appears inside frontend/src.
Build the authentication screen
  • Create frontend/src/AuthForm.tsx.
  • Add the controlled registration and login form:
import { useState, type FormEvent } from 'react';
import type { Credentials } from './api';

type Props = {
  onSubmit: (mode: 'register' | 'login', value: Credentials) => Promise<void>;
};

export function AuthForm({ onSubmit }: Props) {
  const [mode, setMode] = useState<'register' | 'login'>('register');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  async function submit(event: FormEvent) {
    event.preventDefault();
    setError('');
    try {
      await onSubmit(mode, { email, password });
    } catch {
      setError('Check your credentials and try again.');
    }
  }

  // Keep form values in React state until submission.
  return (
    <form onSubmit={submit}>
      <h1>{mode === 'register' ? 'Create your account' : 'Sign in'}</h1>
      <input aria-label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
      <input aria-label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
      {error && <p role="alert">{error}</p>}
      <button>{mode === 'register' ? 'Register' : 'Sign in'}</button>
      <button type="button" onClick={() => setMode(mode === 'register' ? 'login' : 'register')}>Switch mode</button>
    </form>
  );
}

How does the form manage input?

  • useState() keeps the mode, fields, and visible error synchronized with the form.
  • submit() sends credentials only after the learner presses the form button.
  • type="password" masks the password while it is typed.
  • Save frontend/src/AuthForm.tsx.
  • Open frontend/src/App.tsx.
  • Replace its contents with the in-memory authentication flow:
import { useState } from 'react';
import { AuthForm } from './AuthForm';
import { authenticate, type Credentials } from './api';
import './App.css';

export default function App() {
  const [token, setToken] = useState('');

  async function handleAuth(mode: 'register' | 'login', value: Credentials) {
    // Registration creates the account, then login issues its token.
    if (mode === 'register') await authenticate('register', value);
    const result = await authenticate('login', value);
    setToken(result.accessToken);
  }

  if (!token) return <AuthForm onSubmit={handleAuth} />;

  return (
    <main>
      <h1>Private task dashboard</h1>
      <p>You are signed in. Your task controls arrive next.</p>
      <button onClick={() => setToken('')}>Sign out</button>
    </main>
  );
}

Why keep the token in memory?

  • token lives only in the mounted React component.
  • Registration immediately follows with login so the new user reaches the signed-in screen.
  • Sign out clears the token without writing it to browser storage.

Refresh requires another login

A page refresh clears this token and returns the learner to sign-in. That trade-off avoids leaving the credential in localStorage for scripts to read.

  • Save frontend/src/App.tsx.
  • Start the frontend from the frontend folder by running:
npm run dev

Vite keeps this terminal occupied and prints http://localhost:5173. Press Ctrl+C when you want to stop it.

  • Open http://localhost:5173 in your browser.
  • Enter browser@example.com in the email field.
  • Enter browser-password-123 in the password field.
  • Click Register.

You should see the Private task dashboard heading and a Sign out button. Your access token stays behind the interface.

Form not reaching the dashboard?

Keep both development servers running. If the form shows its error message, inspect the browser console for CORS failures and the NestJS terminal for registration or validation errors.

Help me debug the browser authentication flow safely.

Your browser now registers, logs in, and logs out without displaying token plumbing. Next, you will turn that placeholder into a working task dashboard.

Build the Task Dashboard

Your browser can now obtain an identity, but the signed-in screen does not use it. The final slice sends that token with every task request and reflects each database change immediately.

Small React components will separate the create form from the task list. The dashboard coordinates them with the protected API.

In this step, get ready to:
  • Add authenticated helpers for every task operation.
  • Render task creation, completion, and deletion controls.
  • Prove that browser changes persist across a new login.
Add authenticated task helpers

Every protected request needs the same bearer header. Centralizing that detail keeps credentials out of the visual components.

  • Open frontend/src/api.ts in the VS Code Explorer sidebar.
  • Add this task type below Credentials:
export type Task = {
  id: number;
  title: string;
  description: string | null;
  completed: boolean;
};
  • Add the protected request helper below authenticate():
async function taskRequest<T>(
  path: string,
  token: string,
  init: RequestInit = {},
) {
  // Attach the in-memory token to every task request.
  const response = await fetch(`${API_URL}${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
  });

  if (!response.ok) throw new Error('Task request failed');
  return response.json() as Promise<T>;
}

Why centralize protected requests?

taskRequest() adds the bearer token in one place. Each operation receives typed JSON or throws a shared error.

  • Add the four task operations below taskRequest():
export const getTasks = (token: string) =>
  taskRequest<Task[]>('/tasks', token);

export const addTask = (token: string, title: string) =>
  taskRequest<Task>('/tasks', token, {
    method: 'POST',
    body: JSON.stringify({ title }),
  });

export const setTaskComplete = (token: string, task: Task) =>
  taskRequest<Task>(`/tasks/${task.id}`, token, {
    method: 'PATCH',
    body: JSON.stringify({ completed: !task.completed }),
  });

export const deleteTask = (token: string, id: number) =>
  taskRequest<Task>(`/tasks/${id}`, token, { method: 'DELETE' });

What does each operation change?

  • getTasks() loads the current owner’s list.
  • addTask() sends a validated title.
  • setTaskComplete() flips the selected task’s boolean state.
  • deleteTask() removes the selected owned task.
  • Save frontend/src/api.ts.
  • Check the Vite terminal for a successful hot update without TypeScript errors.

Task helper not compiling?

Confirm that each operation passes token as its second argument to taskRequest(). Keep the API path plural as /tasks.

Help me debug the task API helpers.

Build the dashboard components
  • Create frontend/src/TaskForm.tsx.
  • Add the controlled task form:
import { useState, type FormEvent } from 'react';

type Props = { onAdd: (title: string) => Promise<void> };

export function TaskForm({ onAdd }: Props) {
  const [title, setTitle] = useState('');

  async function submit(event: FormEvent) {
    event.preventDefault();
    // Clear the field only after the API accepts the task.
    await onAdd(title);
    setTitle('');
  }

  return (
    <form onSubmit={submit}>
      <label htmlFor="title">New task</label>
      <input id="title" value={title} onChange={(event) => setTitle(event.target.value)} minLength={3} required />
      <button>Add task</button>
    </form>
  );
}

What does the task form own?

The form keeps the draft title local. It asks the dashboard to save the task before clearing the input.

  • Save frontend/src/TaskForm.tsx.
  • Create frontend/src/TaskList.tsx.
  • Add the list and row controls:
import type { Task } from './api';

type Props = {
  tasks: Task[];
  onToggle: (task: Task) => Promise<void>;
  onDelete: (id: number) => Promise<void>;
};

export function TaskList({ tasks, onToggle, onDelete }: Props) {
  if (tasks.length === 0) return <p>No tasks yet.</p>;

  // Render controls from the latest API-backed task state.
  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <label>
            <input type="checkbox" checked={task.completed} onChange={() => onToggle(task)} />
            <span className={task.completed ? 'done' : ''}>{task.title}</span>
          </label>
          <button onClick={() => onDelete(task.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

How does the list stay synchronized?

  • task.id gives every rendered row a stable React key.
  • checked mirrors the completion value returned by PostgreSQL.
  • Each button delegates the database change to the dashboard.
  • Save frontend/src/TaskList.tsx.
  • Create frontend/src/Dashboard.tsx.
  • Add the dashboard state and initial task load:
import { useEffect, useState } from 'react';
import { addTask, deleteTask, getTasks, setTaskComplete, type Task } from './api';
import { TaskForm } from './TaskForm';
import { TaskList } from './TaskList';

type Props = { token: string; onSignOut: () => void };

export function Dashboard({ token, onSignOut }: Props) {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [error, setError] = useState('');

  useEffect(() => {
    let ignore = false;
    // Synchronize visible tasks with the signed-in owner.
    getTasks(token)
      .then((data) => !ignore && setTasks(data))
      .catch(() => !ignore && setError('Could not load tasks.'));
    return () => { ignore = true; };
  }, [token]);

Why load tasks in an Effect?

useEffect() loads tasks when the signed-in dashboard mounts. Its cleanup ignores a late response after sign-out.

  • Add these mutation handlers below the Effect in the same component:
  async function create(title: string) {
    const task = await addTask(token, title);
    setTasks((current) => [...current, task]);
  }

  async function toggle(task: Task) {
    const updated = await setTaskComplete(token, task);
    setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
  }

  async function remove(id: number) {
    await deleteTask(token, id);
    setTasks((current) => current.filter((task) => task.id !== id));
  }

Why update state after the response?

Each handler waits for PostgreSQL before changing local state. A rejected request therefore leaves the visible list unchanged.

  • Close the component with this rendered dashboard:
  return (
    <main>
      <header>
        <h1>Private task dashboard</h1>
        <button onClick={onSignOut}>Sign out</button>
      </header>
      <TaskForm onAdd={create} />
      {error && <p role="alert">{error}</p>}
      <TaskList tasks={tasks} onToggle={toggle} onDelete={remove} />
    </main>
  );
}
  • Save frontend/src/Dashboard.tsx.
  • Check the Vite terminal for a successful hot update.

Dashboard not compiling?

Check that the opening and closing braces keep all three handlers inside Dashboard. Confirm that every local import uses the exact filename casing.

Help me debug the dashboard component.

Wire and verify the full browser flow
  • Open frontend/src/App.tsx.
  • Add the dashboard import below the authentication imports:
import { Dashboard } from './Dashboard';
  • Find the signed-in return block:
  return (
    <main>
      <h1>Private task dashboard</h1>
      <p>You are signed in. Your task controls arrive next.</p>
      <button onClick={() => setToken('')}>Sign out</button>
    </main>
  );
  • Replace it with the working dashboard:
  return <Dashboard token={token} onSignOut={() => setToken('')} />;
  • Save frontend/src/App.tsx.
  • Open frontend/src/App.css.
  • Replace its contents with these dashboard styles:
/* Keep the local app readable without a UI dependency. */
:root { font-family: system-ui, sans-serif; color: #172033; background: #f4f6fb; }
body { margin: 0; }
main { width: min(680px, calc(100% - 32px)); margin: 48px auto; }
header, li, form { display: flex; align-items: center; gap: 12px; }
header { justify-content: space-between; }
form { flex-wrap: wrap; margin: 24px 0; }
input { padding: 10px; }
input[type='email'], input[type='password'], input[type='text'] { flex: 1 1 260px; }
button { padding: 10px 14px; cursor: pointer; }
ul { padding: 0; list-style: none; }
li { justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #ccd3e0; }
li label { display: flex; gap: 10px; }
.done { text-decoration: line-through; color: #657089; }
[role='alert'] { color: #a11; }
  • Save frontend/src/App.css.
  • Return to http://localhost:5173 in your browser.
  • Sign in with the browser account from the previous step.

You should see the dashboard heading, a new-task field, and either your owned tasks or the No tasks yet. message.

Before you create a task, predict which controls should still show it after you sign out and back in.

  • Enter Persistent browser task in the New task field.
  • Click Add task.
  • Select the task’s checkbox.
  • Click Sign out.
  • Sign in with the same browser account.

You should see Persistent browser task with a checked box and struck-through title. The browser has reloaded the saved PostgreSQL state through the protected API.

  • Click Delete beside the completed task.

The task disappears from the list after the API confirms deletion. You now control the complete private task lifecycle from the browser.

Dashboard action not updating?

A 401 in the browser Network panel usually means the in-memory token expired or the page refreshed. Sign in again and retry the task action.

If a mutation succeeds but the screen does not change, compare the state update with the returned task ID. Help me debug the dashboard safely.

You built the full loop. A user can register, sign in, and manage a private persistent task list without touching the terminal or exposing the token.

Secret mission

Filter the Authenticated Dashboard

Add all, active, and completed views to the signed-in task dashboard. Keep every filtered Prisma query scoped to the authenticated owner.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your resources running, pause them for later, or delete them completely. Everything runs locally in WSL, so the ongoing cloud cost is $0.

Keep Local Credentials Local

The database password and JWT_SECRET in .env are local credentials. The matching database values remain in compose.yaml.

  • Keep .env inside the local WSL project.
  • Avoid sharing access tokens from terminal or browser responses.
  • Replace every local credential before exposing this stack outside your machine.

Resources you used:

  • A running NestJS API development server.
  • A running Vite frontend development server.
  • A PostgreSQL 18.6 container.
  • A local network managed by Docker Compose.
  • A named volume called postgres_data that stores users and tasks.
  • The local nest-prisma-tasks folder containing the API and frontend source.

Keep everything running

No action is needed. Choose this while you are still testing the browser app or extending its protected routes.

  • Leave the NestJS development server running in its WSL terminal.
  • Leave the Vite development server running in its WSL terminal.
  • Leave the PostgreSQL container running through Docker Compose.
  • Keep the postgres_data volume so your users and tasks remain available.
  • Keep the nest-prisma-tasks folder in your WSL filesystem.

Pause - I'll come back to this later

Shut down the active processes to free memory while preserving the source files and database volume.

  • Press Ctrl+C in the terminal running NestJS.
  • Press Ctrl+C in the terminal running Vite.
  • Return to a terminal inside nest-prisma-tasks.
  • Stop PostgreSQL without deleting its volume by running:
docker compose stop

What stays behind?

This stops the database container while keeping postgres_data intact. Your registered users and tasks remain available.

  • Resume PostgreSQL and NestJS from nest-prisma-tasks by running:
docker compose up --detach
npm run start:dev
  • Open a second terminal inside nest-prisma-tasks.
  • Resume the frontend by running:
cd frontend
npm run dev

Delete - I don't want to use this again

Removing this local stack is permanent. The database volume, user accounts, tasks, signing secret, API source, and frontend source all disappear.

  • Press Ctrl+C in the terminal running NestJS.
  • Press Ctrl+C in the terminal running Vite.
  • Return to a terminal inside nest-prisma-tasks.
  • Remove the PostgreSQL container, Compose network, and named volume by running:
docker compose down --volumes

What does the database cleanup remove?

The command removes the PostgreSQL container and Compose network. The --volumes flag removes postgres_data with every user and task.

  • Move one folder above nest-prisma-tasks and remove the full source project by running:
cd ..
rm -rf nest-prisma-tasks

What does the folder cleanup remove?

This removes the backend, frontend, installed packages, generated Prisma client, migrations, and local .env file.

You have removed the full local stack. No user credentials, tokens, source files, or database records remain in the project folder.

Nice Work!

Nice Work!

You did it! Your React frontend now registers users, signs them in, and manages private tasks through a protected NestJS API backed by PostgreSQL.

You've learned how to:

  • Build a persistent NestJS task API with validated CRUD routes, Prisma migrations, and PostgreSQL storage.
  • Create an account ownership boundary with bcrypt password hashes, signed JWTs, bearer-token guards, and owner-scoped Prisma queries.
  • Connect a React browser workflow that registers users, signs them in, and manages private tasks while keeping the access token in memory.
  • Secret Mission: Add all, active, and completed dashboard views without dropping the authenticated owner filter.

Ready to quiz yourself on authentication, ownership, and the browser-to-database flow?