Repository: hngprojects/hng_boilerplate_nestjs

Issue:
[FEAT] Implement Endpoints to Get Language by user ID
## Description
Create an API endpoint to retrieve details of a specific language using its unique identifier.

## Acceptance Criteria
1. If the language ID does not exist, the response should be a Not Found status with a message indicating that the language was not found and if successful a status with the language details in the response body.
2. The endpoint must be secure against unauthorized access and optimized for performance.

### Purpose
Allow users to retrieve a specific language's details by its unique ID.

### Requirements	
1. The endpoint should accept the language ID as a URL parameter and return language details.
2. Fetch the language details from the database using the provided ID.
3. Check if the provided ID exists in the database.
4. Ensure the endpoint is secured and accessible only to authorized users, if necessary.
5. Create unit and integration tests to cover, successful retrieval of language details, Handling of invalid or non-existent IDs, Internal error scenarios.
6. Optimize the endpoint for efficiency to handle requests promptly.
7. Ensure that the response time is acceptable even under normal load conditions. 

### Expected Outcome
1. Endpoint successfully returns the language details for a valid ID.
2. Accurate and meaningful responses are provided for invalid or non-existent IDs.
3. The system performs efficiently, with secure access and proper error handling.

https://github.com/hngprojects/hng_boilerplate_csharp_web/pull/345

### 


Discussion:

Solution patch:
diff --git a/.gitignore b/.gitignore
index 2f6db146..185d5c4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -407,6 +407,8 @@ todo.txt
 .vscode/
 
 
+package-lock.json
 docker-compose.yml
 data/
-.dev.env
\ No newline at end of file
+.dev.env
+
diff --git a/src/modules/languages/entities/language.entity.ts b/src/modules/languages/entities/language.entity.ts
index 7e2977b4..e25ab6e1 100644
--- a/src/modules/languages/entities/language.entity.ts
+++ b/src/modules/languages/entities/language.entity.ts
@@ -1,5 +1,6 @@
-import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
+import { Entity, Column, PrimaryGeneratedColumn, ManyToMany, JoinTable } from 'typeorm';
 import { AbstractBaseEntity } from '../../../entities/base.entity';
+import { User } from '@modules/user/entities/user.entity';
 
 @Entity()
 export class Language extends AbstractBaseEntity {
@@ -11,4 +12,8 @@ export class Language extends AbstractBaseEntity {
 
   @Column({ nullable: true })
   description: string;
+
+  @ManyToMany(() => User, user => user.languages)
+  @JoinTable()
+  users?: User[];
 }
diff --git a/src/modules/languages/languages.controller.ts b/src/modules/languages/languages.controller.ts
index 6c60e3bd..7a45f265 100644
--- a/src/modules/languages/languages.controller.ts
+++ b/src/modules/languages/languages.controller.ts
@@ -1,8 +1,7 @@
-import { Controller, Post, Body, Get, Patch, Param, Res } from '@nestjs/common';
+import { Controller, Post, Body, Get, Patch, Param, Res, Request, Response } from '@nestjs/common';
 import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
 import { CreateLanguageDto, UpdateLanguageDto } from './dto/create-language.dto';
 import { LanguagesService } from './languages.service';
-import { Response } from 'express';
 
 @ApiTags('Languages')
 @Controller('languages')
@@ -25,11 +24,24 @@ export class LanguagesController {
   @ApiOperation({ summary: 'Get all supported languages' })
   @ApiResponse({ status: 200, description: 'List of supported languages.' })
   @ApiResponse({ status: 401, description: 'Unauthorized' })
-  async getLanguages(@Res() response: Response): Promise<any> {
+  async getLanguages(@Response() response): Promise<any> {
     const result = await this.languagesService.getSupportedLanguages();
     return response.status(result.status_code).json(result);
   }
 
+  @Get(':id')
+  @ApiBearerAuth()
+  @ApiOperation({ summary: 'Get all languages from a userId' })
+  @ApiResponse({ status: 200, description: 'List of  languages by the user.' })
+  @ApiResponse({ status: 401, description: 'Unauthorized' })
+  @ApiResponse({ status: 400, description: 'Invalid UserId' })
+  @ApiResponse({ status: 404, description: 'No languages found for the specified user' })
+  @ApiResponse({ status: 500, description: 'Internal server error' })
+  async getLanguagesByUserId(@Param('id') id: string, @Request() req, @Response() response): Promise<any> {
+    const result = await this.languagesService.getLanguagesById(id, req.user);
+    return response.status(result.status_code).json(result);
+  }
+
   @Patch(':id')
   @ApiBearerAuth()
   @ApiOperation({ summary: 'Update a language' })
diff --git a/src/modules/languages/languages.module.ts b/src/modules/languages/languages.module.ts
index 93a3d512..2bd08059 100644
--- a/src/modules/languages/languages.module.ts
+++ b/src/modules/languages/languages.module.ts
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
 import { Language } from './entities/language.entity';
 import { LanguagesService } from './languages.service';
 import { LanguagesController } from './languages.controller';
+import { User } from '@modules/user/entities/user.entity';
 
 @Module({
-  imports: [TypeOrmModule.forFeature([Language])],
+  imports: [TypeOrmModule.forFeature([Language, User])],
   controllers: [LanguagesController],
   providers: [LanguagesService],
 })
diff --git a/src/modules/languages/languages.service.ts b/src/modules/languages/languages.service.ts
index 224799a7..bd3a682f 100644
--- a/src/modules/languages/languages.service.ts
+++ b/src/modules/languages/languages.service.ts
@@ -1,5 +1,7 @@
 import {
+  BadRequestException,
   ConflictException,
+  ForbiddenException,
   HttpStatus,
   Injectable,
   InternalServerErrorException,
@@ -10,6 +12,8 @@ import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { Language } from './entities/language.entity';
 import { CreateLanguageDto, UpdateLanguageDto } from './dto/create-language.dto';
+import { User } from '@modules/user/entities/user.entity';
+import { isUUID } from 'class-validator';
 
 @Injectable()
 export class LanguagesService {
@@ -100,4 +104,58 @@ export class LanguagesService {
       });
     }
   }
+  async getLanguagesById(userId: string, user: User): Promise<any> {
+    try {
+      if (!isUUID(userId)) {
+        throw new BadRequestException('Invalid user Id');
+      }
+
+      if (user.id !== userId) {
+        throw new ForbiddenException({
+          status_code: HttpStatus.FORBIDDEN,
+          message: 'You are not authorized to access this resource',
+        });
+      }
+
+      const languages = await this.languageRepository
+        .createQueryBuilder('language')
+        .innerJoin('language.users', 'user')
+        .where('user.id = :userId', { userId })
+        .getMany();
+
+      if (!languages || languages.length === 0) {
+        throw new NotFoundException({
+          status_code: HttpStatus.NOT_FOUND,
+          message: 'Languages associated with this user not found',
+        });
+      }
+
+      const formattedLanguages = languages.map(language => ({
+        id: language.id,
+        language: language.language,
+        description: language.description,
+        code: language.code,
+      }));
+
+      return {
+        status: 'OK',
+        status_code: HttpStatus.OK,
+        message: 'Languages fetched successfully',
+        data: formattedLanguages,
+      };
+    } catch (error) {
+      if (
+        error instanceof NotFoundException ||
+        error instanceof ForbiddenException ||
+        error instanceof BadRequestException
+      ) {
+        throw error;
+      }
+      Logger.error('LanguagesServiceError ~ getLanguagesById ~', error);
+      throw new InternalServerErrorException({
+        message: 'An error occurred',
+        status_code: HttpStatus.INTERNAL_SERVER_ERROR,
+      });
+    }
+  }
 }
diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts
index ef0e4020..f71303dc 100644
--- a/src/modules/user/entities/user.entity.ts
+++ b/src/modules/user/entities/user.entity.ts
@@ -22,6 +22,7 @@ import { Cart } from '../../dashboard/entities/cart.entity';
 import { Order } from '../../dashboard/entities/order.entity';
 import { Organisation } from '../../organisations/entities/organisations.entity';
 import { Profile } from '../../profile/entities/profile.entity';
+import { Language } from '@modules/languages/entities/language.entity';
 
 export enum UserType {
   SUPER_ADMIN = 'super-admin',
@@ -110,4 +111,8 @@ export class User extends AbstractBaseEntity {
 
   @OneToMany(() => Cart, cart => cart.user)
   cart: Cart[];
+
+  @ManyToMany(() => Language, language => language.users)
+  @JoinTable()
+  languages?: Language[];
 }



Test patch:
diff --git a/src/modules/languages/tests/languages.controller.spec.ts b/src/modules/languages/tests/languages.controller.spec.ts
index e2443834..06b610cf 100644
--- a/src/modules/languages/tests/languages.controller.spec.ts
+++ b/src/modules/languages/tests/languages.controller.spec.ts
@@ -5,7 +5,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { Language } from '../entities/language.entity';
 import { CreateLanguageDto } from '../dto/create-language.dto';
-import { HttpException, HttpStatus } from '@nestjs/common';
+import { BadRequestException, ForbiddenException, HttpException, HttpStatus, NotFoundException } from '@nestjs/common';
+import { User } from '@modules/user/entities/user.entity';
 
 const mockLanguageRepository = {
   findOne: jest.fn(),
@@ -16,6 +17,7 @@ const mockLanguageRepository = {
 
 const mockLanguagesService = {
   createLanguage: jest.fn(),
+  getLanguagesById: jest.fn(),
   getSupportedLanguages: jest.fn(),
 };
 
@@ -175,4 +177,98 @@ describe('LanguagesController', () => {
       );
     });
   });
+
+  describe('getLanguagesByUserId', () => {
+    it('should return languages associated with a user', async () => {
+      const userId = '550e8400-e29b-41d4-a716-446655440000';
+      const user = { id: 'user-id' } as User;
+
+      const languages = [
+        { id: '1', language: 'English', code: 'en', description: 'English' },
+        { id: '2', language: 'Spanish', code: 'es', description: 'Español' },
+      ];
+      mockLanguagesService.getLanguagesById.mockResolvedValue({
+        status: 'OK',
+        status_code: HttpStatus.OK,
+        message: 'Languages fetched successfully',
+        data: languages,
+      });
+
+      const req = { user };
+      const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+
+      await controller.getLanguagesByUserId(userId, req, res as any);
+
+      expect(res.status).toHaveBeenCalledWith(HttpStatus.OK);
+      expect(res.json).toHaveBeenCalledWith({
+        status: 'OK',
+        status_code: HttpStatus.OK,
+        message: 'Languages fetched successfully',
+        data: languages,
+      });
+    });
+
+    it('should handle invalid user ID', async () => {
+      const userId = 'invalid-id';
+      const user = { id: 'user-id' } as User;
+
+      mockLanguagesService.getLanguagesById.mockRejectedValue(new BadRequestException('Invalid user Id'));
+
+      const req = { user };
+      const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+
+      await expect(controller.getLanguagesByUserId(userId, req, res as any)).rejects.toThrow(BadRequestException);
+    });
+
+    it('should handle unauthorized access', async () => {
+      const userId = '550e8400-e29b-41d4-a716-446655440000';
+      const user = { id: 'different-user-id' } as User;
+
+      mockLanguagesService.getLanguagesById.mockRejectedValue(
+        new ForbiddenException({
+          status_code: HttpStatus.FORBIDDEN,
+          message: 'You are not authorized to access this resource',
+        })
+      );
+
+      const req = { user };
+      const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+
+      await expect(controller.getLanguagesByUserId(userId, req, res as any)).rejects.toThrow(ForbiddenException);
+    });
+
+    it('should handle no languages found for the user', async () => {
+      const userId = '550e8400-e29b-41d4-a716-446655440000';
+      const user = { id: userId } as User;
+
+      mockLanguagesService.getLanguagesById.mockRejectedValue(
+        new NotFoundException({
+          status_code: HttpStatus.NOT_FOUND,
+          message: 'Languages associated with this user not found',
+        })
+      );
+
+      const req = { user };
+      const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+
+      await expect(controller.getLanguagesByUserId(userId, req, res as any)).rejects.toThrow(NotFoundException);
+    });
+
+    it('should handle no languages found for the user', async () => {
+      const userId = '550e8400-e29b-41d4-a716-446655440000';
+      const user = { id: userId } as User;
+
+      mockLanguagesService.getLanguagesById.mockRejectedValue(
+        new NotFoundException({
+          status_code: HttpStatus.NOT_FOUND,
+          message: 'Languages associated with this user not found',
+        })
+      );
+
+      const req = { user };
+      const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+
+      await expect(controller.getLanguagesByUserId(userId, req, res as any)).rejects.toThrow(NotFoundException);
+    });
+  });
 });
diff --git a/src/modules/languages/tests/languages.service.spec.ts b/src/modules/languages/tests/languages.service.spec.ts
index 306b11a3..30af51be 100644
--- a/src/modules/languages/tests/languages.service.spec.ts
+++ b/src/modules/languages/tests/languages.service.spec.ts
@@ -1,16 +1,30 @@
+import { User } from '@modules/user/entities/user.entity';
+import {
+  BadRequestException,
+  ConflictException,
+  ForbiddenException,
+  HttpException,
+  HttpStatus,
+  InternalServerErrorException,
+  NotFoundException,
+} from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
-import { LanguagesService } from '../languages.service';
 import { getRepositoryToken } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
-import { Language } from '../entities/language.entity';
 import { CreateLanguageDto, UpdateLanguageDto } from '../dto/create-language.dto';
-import { HttpException, HttpStatus, ConflictException, NotFoundException } from '@nestjs/common';
+import { Language } from '../entities/language.entity';
+import { LanguagesService } from '../languages.service';
 
 const mockLanguageRepository = {
   findOne: jest.fn(),
   create: jest.fn(),
   save: jest.fn(),
   find: jest.fn(),
+  createQueryBuilder: jest.fn(() => ({
+    innerJoin: jest.fn().mockReturnThis(),
+    where: jest.fn().mockReturnThis(),
+    getMany: jest.fn(),
+  })),
 };
 
 describe('LanguagesService', () => {
@@ -246,4 +260,114 @@ describe('LanguagesService', () => {
       );
     });
   });
+
+  describe('getLanguagesByUserId', () => {
+    it('should return languages associated with a user', async () => {
+      const userId = '550e8400-e29b-41d4-a716-446655440000';
+      const user = {
+        id: userId,
+      } as User;
+
+      const languages: Language[] = [
+        {
+          id: '1',
+          language: 'English',
+          code: 'en',
+          description: 'English',
+          created_at: new Date(),
+          updated_at: new Date(),
+        },
+        {
+          id: '2',
+          language: 'Spanish',
+          code: 'es',
+          description: 'Español',
+          created_at: new Date(),
+          updated_at: new Date(),
+        },
+      ];
+
+      jest.spyOn(repository, 'createQueryBuilder').mockReturnValue({
+        innerJoin: jest.fn().mockReturnThis(),
+        where: jest.fn().mockReturnThis(),
+        getMany: jest.fn().mockResolvedValue(languages),
+      } as any);
+
+      const result = await service.getLanguagesById(userId, user);
+      expect(result).toEqual({
+        status: 'OK',
+        status_code: HttpStatus.OK,
+        message: 'Languages fetched successfully',
+        data: languages.map(language => ({
+          id: language.id,
+          language: language.language,
+          description: language.description,
+          code: language.code,
+        })),
+      });
+    });
+
+    it('should handle invalid user ID', async () => {
+      const userId = 'invalid-id';
+      const user = {
+        id: 'user-id',
+      } as User;
+
+      await expect(service.getLanguagesById(userId, user)).rejects.toThrow(new BadRequestException('Invalid user Id'));
+    });
+
+    it('should handle unauthorized access', async () => {
+      const userId = '550e8400-e29b-41d4-a716-446655440000';
+      const user = {
+        id: '6a5db1d0-87c2-4602-a5a5-0ffacc2377d8',
+      } as User;
+
+      await expect(service.getLanguagesById(userId, user)).rejects.toThrow(
+        new ForbiddenException({
+          status_code: HttpStatus.FORBIDDEN,
+          message: 'You are not authorized to access this resource',
+        })
+      );
+    });
+
+    it('should handle no languages found for the user', async () => {
+      const userId = '6a5db1d0-87c2-4602-a5a5-0ffacc2377d8';
+      const user = {
+        id: userId,
+      } as User;
+
+      jest.spyOn(repository, 'createQueryBuilder').mockReturnValue({
+        innerJoin: jest.fn().mockReturnThis(),
+        where: jest.fn().mockReturnThis(),
+        getMany: jest.fn().mockResolvedValue([]),
+      } as any);
+
+      await expect(service.getLanguagesById(userId, user)).rejects.toThrow(
+        new NotFoundException({
+          status_code: HttpStatus.NOT_FOUND,
+          message: 'Languages associated with this user not found',
+        })
+      );
+    });
+
+    it('should handle errors during fetch', async () => {
+      const userId = '6a5db1d0-87c2-4602-a5a5-0ffacc2377d8';
+      const user = {
+        id: userId,
+      } as User;
+
+      jest.spyOn(repository, 'createQueryBuilder').mockReturnValue({
+        innerJoin: jest.fn().mockReturnThis(),
+        where: jest.fn().mockReturnThis(),
+        getMany: jest.fn().mockRejectedValue(new Error('An error occurred')),
+      } as any);
+
+      await expect(service.getLanguagesById(userId, user)).rejects.toThrow(
+        new InternalServerErrorException({
+          message: 'An error occurred',
+          status_code: HttpStatus.INTERNAL_SERVER_ERROR,
+        })
+      );
+    });
+  });
 });