diff --git a/.gitignore b/.gitignore index 2f6db146f..185d5c4c1 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 7e2977b49..e25ab6e1a 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 6c60e3bd8..7a45f265b 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 { + async getLanguages(@Response() response): Promise { 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 { + 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 93a3d5122..2bd080590 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 224799a74..bd3a682f5 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 { + 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/languages/tests/languages.controller.spec.ts b/src/modules/languages/tests/languages.controller.spec.ts index e24438340..06b610cfb 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 306b11a39..30af51bed 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, + }) + ); + }); + }); }); diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index ef0e40209..f71303dc6 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[]; }