diff --git a/Makefile b/Makefile index cdf9cf8..ae37afb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: build build: - docker-compose build --no-cache + docker-compose build .PHONY: start start: @@ -8,7 +8,7 @@ start: .PHONY: run run: - docker-compose build --no-cache && docker-compose up + docker-compose build && docker-compose up .PHONY: stop stop: diff --git a/src/users/interface/user.interface.ts b/src/users/interface/user.interface.ts index 988453d..e65fd20 100644 --- a/src/users/interface/user.interface.ts +++ b/src/users/interface/user.interface.ts @@ -9,8 +9,10 @@ export interface User extends Document { verificationToken?: string; isVerified?: boolean; role?: UserRole; + knowledges?: mongoose.Types.ObjectId[]; subjects?: mongoose.Types.ObjectId[]; journeys?: mongoose.Types.ObjectId[]; subscribedJourneys?: mongoose.Types.ObjectId[]; + subscribedSubjects?: mongoose.Types.ObjectId[]; completedTrails?: mongoose.Types.ObjectId[]; } diff --git a/src/users/interface/user.schema.ts b/src/users/interface/user.schema.ts index b244f61..ee2fd2b 100644 --- a/src/users/interface/user.schema.ts +++ b/src/users/interface/user.schema.ts @@ -17,9 +17,13 @@ export const UserSchema = new mongoose.Schema( default: UserRole.ALUNO, }, subjects: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Subject' }], + knowledges: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Knowledge' }], subscribedJourneys: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Journey' }, ], + subscribedSubjects: [ + { type: mongoose.Schema.Types.ObjectId, ref: 'Subject' }, + ], completedTrails: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Trail' }], }, { timestamps: true, collection: 'users' }, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 35c8631..8c073f8 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -26,7 +26,7 @@ import { UsersService } from './users.service'; @Controller('users') export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor(private readonly usersService: UsersService) { } @Post() @UsePipes(ValidationPipe) @@ -54,7 +54,7 @@ export class UsersController { return { message: `User ${user.username} updated successfully!` } - } catch ( error ) { + } catch (error) { if (error instanceof NotFoundException) { throw new NotFoundException(`User with ID ${req.userId} not found`); } @@ -80,6 +80,11 @@ export class UsersController { return await this.usersService.getSubscribedJourneys(userId); } + @Get(':userId/subscribedSubjects') + async getSubscribedSubjects(@Param('userId') userId: string): Promise { + return await this.usersService.getSubscribedSubjects(userId); + } + @Get() async getUsers() { return await this.usersService.getUsers(); @@ -97,6 +102,37 @@ export class UsersController { } } + @Put(':id/knowledges/:knowledgeId/add') + async addKnowledgeToUser( + @Param('id') id: string, + @Param('knowledgeId') knowledgeId: string, + ) { + try { + return await this.usersService.addKnowledgeToUser(id, knowledgeId); + } catch (error) { + throw error; + } + } + + @UseGuards(JwtAuthGuard) + @Post(':userId/subjects/subscribe/:subjectId') + async subscribeSubject( + @Param('userId') userId: string, + @Param('subjectId') subjectId: string, + ) { + return await this.usersService.subscribeSubject(userId, subjectId); + } + + @UseGuards(JwtAuthGuard) + @Delete(':userId/subjects/unsubscribe/:subjectId') + async unsubscribeSubject( + @Param('userId') userId: string, + @Param('subjectId') subjectId: string, + ) { + return this.usersService.unsubscribeSubject(userId, subjectId); + } + + @UseGuards(JwtAuthGuard) @Post(':userId/subscribe/:journeyId') async subscribeJourney( diff --git a/src/users/users.service.ts b/src/users/users.service.ts index d5f603f..ec9227b 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -19,7 +19,7 @@ export class UsersService { constructor( @InjectModel('User') private readonly userModel: Model, private readonly emailService: EmailService, - ) {} + ) { } async createUser(createUserDto: CreateUserDto): Promise { const { name, email, username, password } = createUserDto; @@ -45,13 +45,13 @@ export class UsersService { } } - async updateUser ( userId: string, updateUserDto: UpdateUserDto ): Promise { + async updateUser(userId: string, updateUserDto: UpdateUserDto): Promise { await this.getUserById(userId); - if ( updateUserDto.email ) updateUserDto.isVerified = false; + if (updateUserDto.email) updateUserDto.isVerified = false; var updateAttr = Object.fromEntries( - Object.entries(updateUserDto).filter(([value]) => value !== null) + Object.entries(updateUserDto).filter(([value]) => value !== null) ) try { @@ -64,12 +64,12 @@ export class UsersService { } ) - if ( updateAttr['isVerified'] === false ) await this.emailService.sendVerificationEmail(updateAttr['email']); + if (updateAttr['isVerified'] === false) await this.emailService.sendVerificationEmail(updateAttr['email']); return updatedUser; - } catch ( error ) { - if ( error instanceof MongoError ) { + } catch (error) { + if (error instanceof MongoError) { throw new NotFoundException(`User with ID ${userId} not found!`) } throw error @@ -112,6 +112,20 @@ export class UsersService { return user; } + async addKnowledgeToUser(userId: string, knowledgeId: string): Promise { + const user = await this.userModel.findById(userId).exec(); + + if (!user) throw new NotFoundException(`User with ID ${userId} not found`); + + const objectId = new Types.ObjectId(knowledgeId); + + user.knowledges = (user.knowledges ? user.knowledges : []) + + if (!user.knowledges.includes(objectId)) user.knowledges.push(objectId); + + return user.save(); + } + async addSubjectToUser(userId: string, subjectId: string): Promise { const user = await this.userModel.findById(userId).exec(); @@ -119,7 +133,7 @@ export class UsersService { const objectId = new Types.ObjectId(subjectId); - user.subjects = ( user.subjects ? user.subjects : [] ) + user.subjects = (user.subjects ? user.subjects : []) if (!user.subjects.includes(objectId)) user.subjects.push(objectId); @@ -208,6 +222,27 @@ export class UsersService { return user.save(); } + async subscribeSubject(userId: string, subjectId: string): Promise> { + const user = await this.userModel.findById(userId).exec(); + + if (!user) throw new NotFoundException(`Couldn't find user with ID ${userId}`); + + if (!user.subscribedSubjects) user.subscribedSubjects = [new Types.ObjectId(subjectId)]; + else if (!user.subscribedSubjects.includes(new Types.ObjectId(subjectId))) + user.subscribedSubjects.push(new Types.ObjectId(subjectId)); + else throw new ConflictException(`User already subscribed at subject with ID ${subjectId}!`); + + const savedUser = await user.save(); + + return { + id: savedUser.id, + email: savedUser.email, + name: savedUser.name, + username: savedUser.username, + subscribedSubjects: savedUser.subscribedSubjects + } + } + async unsubscribeJourney(userId: string, journeyId: string): Promise { const user = await this.userModel.findById(userId).exec(); if (!user) { @@ -225,6 +260,29 @@ export class UsersService { return user.save(); } + async unsubscribeSubject(userId: string, subjectId: string): Promise> { + const user = await this.userModel.findById(userId).exec(); + + if (!user) throw new NotFoundException(`Couldn't find user with ID ${userId}`); + + const objectId = new Types.ObjectId(subjectId); + + if (!user.subscribedSubjects || !user.subscribedSubjects.includes(objectId)) + throw new NotFoundException(`User not subscribed at subject with ID ${subjectId}`) + + user.subscribedSubjects = user.subscribedSubjects.filter((id) => !id.equals(objectId)) + + const savedUser = await user.save(); + + return { + id: savedUser.id, + name: savedUser.name, + email: savedUser.email, + username: savedUser.username, + subscribedSubjects: savedUser.subscribedSubjects + } + } + async getSubscribedJourneys(userId: string): Promise { const user = await this.userModel.findById(userId).exec(); if (!user) { @@ -234,6 +292,14 @@ export class UsersService { return user.subscribedJourneys; } + async getSubscribedSubjects(userId: string): Promise { + const user = await this.userModel.findById(userId).exec(); + + if (!user) throw new NotFoundException(`Couldn't find user with ID ${userId}`); + + return user.subscribedSubjects; + } + async findByEmail(email: string): Promise { return this.userModel.findOne({ email }).exec(); } diff --git a/test/user.controller.spec.ts b/test/user.controller.spec.ts index 477e85e..bffd5c9 100644 --- a/test/user.controller.spec.ts +++ b/test/user.controller.spec.ts @@ -27,15 +27,34 @@ describe('UsersController', () => { role: UserRole.ALUNO, } + const mockSubscribedSubject = { + _id: 'mocked-id', + email: 'mocked-email', + name: 'mocked-name', + username: 'mocked-username', + subscribedSubjects: ['mocked-subject'] + } + + const mockUnsubscribedSubject = { + id: 'mocked-id', + email: 'mocked-email', + name: 'mocked-name', + username: 'mocked-username', + subscribedSubjects: [] + } + const mockUserService = { createUser: jest.fn().mockResolvedValue(mockUser), verifyUser: jest.fn().mockResolvedValue(mockUser), updateUser: jest.fn().mockResolvedValue(mockUpdatedUser), getSubscribedJourneys: jest.fn().mockResolvedValue([]), + getSubscribedSubjects: jest.fn().mockResolvedValue([]), getUsers: jest.fn().mockResolvedValue([mockUser]), addSubjectToUser: jest.fn().mockResolvedValue(mockUser), subscribeJourney: jest.fn().mockResolvedValue(mockUser), unsubscribeJourney: jest.fn().mockResolvedValue(mockUser), + subscribeSubject: jest.fn().mockResolvedValue(mockSubscribedSubject), + unsubscribeSubject: jest.fn().mockResolvedValue(mockUnsubscribedSubject), getUserById: jest.fn().mockResolvedValue(mockUser), deleteUserById: jest.fn().mockResolvedValue(undefined), updateUserRole: jest.fn().mockResolvedValue(mockUser), @@ -89,6 +108,38 @@ describe('UsersController', () => { }) }) + it('should subscribe to subject', async () => { + const userId = 'mocked-id'; + const subjectId = 'mocked-subject'; + + expect(await controller.subscribeSubject(userId, subjectId)).toBe(mockSubscribedSubject); + }) + + it('should return error when trying to subscribe to subject', async () => { + const userId = 'false-id'; + const subjectId = 'mocked-subject'; + + mockUserService.subscribeSubject.mockRejectedValueOnce(new NotFoundException(`Couldn't find user with ID ${userId}`)); + + await expect(controller.subscribeSubject(userId, subjectId)).rejects.toBeInstanceOf(NotFoundException); + }) + + it('should unsubscribe to subject', async () => { + const userId = 'mocked-id'; + const subjectId = 'mocked-subject'; + + expect(await controller.unsubscribeSubject(userId, subjectId)).toBe(mockUnsubscribedSubject); + }) + + it('should return error when trying to unsubscribe to subject', async () => { + const userId = 'false-id'; + const subjectId = 'mocked-subject'; + + mockUserService.unsubscribeSubject.mockRejectedValueOnce(new NotFoundException(`Couldn't find user with ID ${userId}`)); + + await expect(controller.unsubscribeSubject(userId, subjectId)).rejects.toBeInstanceOf(NotFoundException); + }) + it('should return an error while trying to update user', async () => { const updateUserDto: UpdateUserDto = { name: 'Mock User', @@ -115,6 +166,19 @@ describe('UsersController', () => { await expect(controller.getSubscribedJourneys(userId)).resolves.toEqual([]); }); + it('should return error when trying to return subscribed subjects', async () => { + const userId = 'false-id'; + + mockUserService.getSubscribedSubjects.mockRejectedValueOnce(new NotFoundException(`User with ID ${userId} not found`)); + + await expect(controller.getSubscribedSubjects(userId)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should get subscribed subjects', async () => { + const userId = 'mockUserId'; + await expect(controller.getSubscribedSubjects(userId)).resolves.toEqual([]); + }); + it('should get all users', async () => { await expect(controller.getUsers()).resolves.toEqual([mockUser]); }); diff --git a/test/user.service.spec.ts b/test/user.service.spec.ts index 42bf7d0..d7a39bb 100644 --- a/test/user.service.spec.ts +++ b/test/user.service.spec.ts @@ -23,6 +23,7 @@ describe('UsersService', () => { verificationToken: 'mockToken', isVerified: false, subscribedJourneys: [new Types.ObjectId(), new Types.ObjectId()], + subscribedSubjects: [new Types.ObjectId(), new Types.ObjectId()], completedTrails: [new Types.ObjectId(), new Types.ObjectId()], save: jest.fn().mockResolvedValue(this), }; @@ -37,6 +38,7 @@ describe('UsersService', () => { verificationToken: 'mockToken', isVerified: false, subscribedJourneys: [new Types.ObjectId(), new Types.ObjectId()], + subscribedSubjects: [new Types.ObjectId(), new Types.ObjectId()], completedTrails: [new Types.ObjectId(), new Types.ObjectId()], save: jest.fn().mockResolvedValue(this), }; @@ -274,6 +276,45 @@ describe('UsersService', () => { ).rejects.toThrow(NotFoundException); }); + it('should add a subject to user subscribedSubjects if not already subscribed', async () => { + const subjectId = new Types.ObjectId().toHexString(); + + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(mockUser), + } as any); + + const result = await service.subscribeSubject('mockId', subjectId); + + expect(result.subscribedSubjects).toContainEqual( + new Types.ObjectId(subjectId), + ); + expect(result.subscribedSubjects.length).toBe(3); + }); + + it('should throw NotFoundException if user is not found when subscribing in a subject', async () => { + const subjectId = new Types.ObjectId().toHexString(); + + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(null), + } as any); + + await expect( + service.subscribeSubject('invalidId', subjectId), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException if user is not found when unsubscribing from a subject', async () => { + const subjectId = new Types.ObjectId().toHexString(); + + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(null), + } as any); + + await expect( + service.unsubscribeSubject('invalidId', subjectId), + ).rejects.toThrow(NotFoundException); + }); + it('should add a journey to user subscribedJourneys if not already subscribed', async () => { const journeyId = new Types.ObjectId().toHexString();