Introduction: Why Handling Friend Requests Matters

Welcome back! In the previous lesson, you learned how to let users send friend requests in your reading tracker app. Now, it’s time to manage those requests. Handling friend requests is a key part of any social feature. It lets users see who wants to connect with them and decide whether to accept or decline those requests.

In this lesson, you will learn how to list incoming friend requests, accept or decline them, and make sure only the right user can take action. By the end, you’ll be able to build endpoints that keep your app’s friendships secure and up to date.

Viewing Incoming Friend Requests

Let’s start by allowing users to see their incoming friend requests. Here we will implement a read endpoint that returns all pending requests where the authenticated user is the recipient. In our service file, we add a method that filters pending requests; in our controller, we expose GET /friends/requests and derive the recipient from the JWT via @CurrentUser().

// src/friends/friends.service.ts (excerpt)
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';

@Injectable()
export class FriendsService {
  constructor(private readonly db: DatabaseService) {}

  getIncomingRequests(userId: string) {
    return this.db
      .getFriendRequests()
      .filter((r) => r.recipientId === userId && r.status === 'pending');
  }

  // ...other methods implemented in this unit below
}
  • Filters the in-memory friendRequests store to pending requests for the given userId.
  • Uses DatabaseService.getFriendRequests() introduced previously.
  • Keeps business logic in the service; no token parsing here (controller handles identity).

In this addition, we keep the read logic simple and easily testable: a pure filter over the in-memory collection. This focuses the controller on extracting the acting user while the service owns query semantics. Because it returns only pending requests, the UI/clients can reliably present actionable items. This foundation also makes it easy to extend filtering (e.g., pagination) later if needed.

// src/friends/friends.controller.ts (excerpt)
import { Controller, Get } from '@nestjs/common';
import { FriendsService } from './friends.service';
import { CurrentUser, TokenUser } from '../common/decorators/current-user.decorator';

@Controller('friends')
export class FriendsController {
  constructor(private readonly friends: FriendsService) {}

  @Get('requests')
  getIncoming(@CurrentUser() user: TokenUser) {
    const list = this.friends.getIncomingRequests(user.userId);
    return { success: true, data: list };
  }

  // ...other endpoints implemented in this unit below
}
  • Adds GET /friends/requests to expose incoming pending requests.
  • Uses @CurrentUser() so identity is taken from the JWT (not the body).
  • Delegates filtering to the service to keep the controller thin.

By extracting the user from the token, we eliminate impersonation risks that would arise from trusting IDs in the request body. The controller remains minimal and composable, while the service consolidates the query logic. This separation also keeps unit tests straightforward: controller tests focus on wiring; service tests focus on filtering.

Accepting or Declining Friend Requests

Now, let’s allow users to accept or decline these requests. Here we will implement state transitions for requests: recipients can accept or decline exactly once. In our DTO file, we validate the requested status; in our service, we enforce existence, pending-only handling, and recipient-only authorization; in our controller, we expose PATCH /friends/requests/:requestId.

// handle-request.dto.ts
import { IsEnum } from 'class-validator';

export class HandleRequestDto {
  @IsEnum(['accepted', 'declined'])
  status!: 'accepted' | 'declined';
}
  • Validates that status is one of 'accepted' | 'declined'.
  • Prevents invalid transitions early at the request boundary.
  • Keeps the controller/service simpler by guaranteeing input shape.

The DTO centralizes input validation, ensuring only valid transitions hit the service layer. By rejecting invalid values early, we keep the business logic focused and reduce branching. The pattern mirrors typical NestJS flows where DTOs act as the first line of defense against bad inputs.

Now let's define a service method which encodes the core invariants for request lifecycle management. By checking existence, pending status, and recipient identity, we prevent unauthorized or duplicate actions. The mutual friendIds update ensures the friendship graph is consistent on both sides. If declined, we persist the decision while leaving friendship data unchanged.

// friends.service.ts
handleRequest(actingUserId: string, requestId: string, status: 'accepted' | 'declined') {
    const req = this.db.getFriendRequests().find((r) => r.id === requestId);
    if (!req) throw new NotFoundException('Friend request not found.');
    if (req.status !== 'pending') throw new BadRequestException('Request already handled.');
    if (req.recipientId !== actingUserId)
        throw new ForbiddenException('Only the recipient can act on this request.');

    req.status = status;

    if (status === 'accepted') {
        const sender = this.users.findOne(req.senderId);
        const recipient = this.users.findOne(req.recipientId);
        if (!sender.friendIds.includes(recipient.id)) sender.friendIds.push(recipient.id);
        if (!recipient.friendIds.includes(sender.id)) recipient.friendIds.push(sender.id);
    }
    return req;
}
  • Existence check → 404 if the request ID is unknown.
  • Single handling → 400 if already handled (idempotency/guarding).
  • Authorization → 403 unless the recipient is acting.
  • On accept, we create a mutual friendship by updating both users’ friendIds.

In our controller, we will add a new handle method which ties together the validated input, typed route params, and token-derived identity. It keeps all business rules inside the service, which simplifies future maintenance (e.g., adding notifications, audit logs). With this endpoint, the request lifecycle becomes complete: create → view → handle.

// friends.controller.ts
@Patch('requests/:requestId')
    handle(
    @CurrentUser() user: TokenUser,
    @Param('requestId', ParseUUIDPipe) requestId: string,
    @Body() dto: HandleRequestDto,
    ) {
        const res = this.friends.handleRequest(user.userId, requestId, dto.status);
        return { success: true, data: res };
    }

What’s happening here:

  • The controller receives the request, extracts the user, request ID, and desired status (accepted or declined).
  • The service checks:
    • Does the request exist?
    • Is it still pending?
    • Is the current user the recipient?
  • If all checks pass, it updates the status.
  • If accepted, both users’ friendIds arrays are updated to include each other.
  • If declined, only the status changes.

Why these checks matter:

  • Only the recipient can accept or decline.
  • Requests can’t be handled twice.
  • Accepting a request creates a real friendship in both users’ data.
Testing and Error Handling with cURL

Let’s see how this works in practice using cURL commands. These examples show both correct and incorrect usage.

1. List incoming requests as Bob:

curl -s $BASE/friends/requests \
  -H "Authorization: Bearer $BOB_TOKEN"

Output:

[
  {
    "id": Bob's id,
    "senderId": Sender's id,
    "recipientId": Recipient's id,
    "status": "pending"
  }
]

2. Accept the request as Bob:

curl -s -X PATCH $BASE/friends/requests/5 \
  -H "Authorization: Bearer $BOB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"accepted"}'

3. Try to decline as Alice (not the recipient):

curl -s -X PATCH $BASE/friends/requests/5 \
  -H "Authorization: Bearer $ALICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"declined"}'

Output:

{
  "statusCode": 403,
  "message": "Only the recipient can act on this request.",
  "error": "Forbidden"
}

4. Try to accept the same request again (double-handling):

curl -s -X PATCH $BASE/friends/requests/5 \
  -H "Authorization: Bearer $BOB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"accepted"}'

Output:

{
  "statusCode": 400,
  "message": "Request already handled.",
  "error": "Bad Request"
}

These examples show how the system enforces the rules and keeps your data safe.

Summary And Practice Preview

In this lesson, you learned how to let users view, accept, or decline incoming friend requests. You saw how to secure these actions so only the right user can handle each request, and how to prevent requests from being handled more than once. You also practiced using cURL to test both successful and failed scenarios.

Next, you’ll get hands-on practice with these endpoints. You’ll also see how confirmed friendships are stored, which will prepare you for the next unit — listing a user’s friends. Keep up the good work!

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