/* * Copyright © 2024 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, NotFoundException, Param, Patch, Post, Query, Req, Session, UnauthorizedException, UseInterceptors, } from '@nestjs/common'; import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { Request } from 'express'; import { Session as ExpressSession } from 'express-session'; import { AttachmentService } from '@/attachment/services/attachment.service'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; import { Roles } from '@/utils/decorators/roles.decorator'; import { BaseController } from '@/utils/generics/base-controller'; import { generateInitialsAvatar, getBotAvatar } from '@/utils/helpers/avatar'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe'; import { PopulatePipe } from '@/utils/pipes/populate.pipe'; import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe'; import { TFilterQuery } from '@/utils/types/filter.types'; import { InvitationCreateDto } from '../dto/invitation.dto'; import { UserCreateDto, UserEditProfileDto, UserRequestResetDto, UserResetPasswordDto, UserUpdateStateAndRolesDto, } from '../dto/user.dto'; import { User, UserFull, UserPopulate, UserStub } from '../schemas/user.schema'; import { InvitationService } from '../services/invitation.service'; import { PasswordResetService } from '../services/passwordReset.service'; import { PermissionService } from '../services/permission.service'; import { RoleService } from '../services/role.service'; import { UserService } from '../services/user.service'; import { ValidateAccountService } from '../services/validate-account.service'; @UseInterceptors(CsrfInterceptor) @Controller('user') export class ReadOnlyUserController extends BaseController< User, UserStub, UserPopulate, UserFull > { constructor( protected readonly userService: UserService, protected readonly roleService: RoleService, protected readonly invitationService: InvitationService, protected readonly permissionService: PermissionService, protected readonly attachmentService: AttachmentService, protected readonly logger: LoggerService, protected readonly passwordResetService: PasswordResetService, protected readonly validateAccountService: ValidateAccountService, ) { super(userService); } /** * Retrieves the bot's profile picture. * * @returns A promise that resolves to the bot's avatar URL. */ @Roles('public') @Get('bot/profile_pic') async botProfilePic(@Query('color') color: string) { return getBotAvatar(color); } /** * Retrieves the user's profile picture. * * @param id - The ID of the user. * * @returns A promise that resolves to the user's avatar or an avatar generated from initials if not found. */ @Roles('public') @Get(':id/profile_pic') async UserProfilePic(@Param('id') id: string) { try { const res = await this.userService.userProfilePic(id); return res; } catch (e) { const user = await this.userService.findOne(id); if (user) { return generateInitialsAvatar(user); } else { throw new NotFoundException(`user with ID ${id} not found`); } } } /** * Retrieves the current user's roles and permissions. * * @param req - The request object containing the authenticated user. * * @returns A promise that resolves to the user's roles and associated permissions. */ @Roles('public') @Get('permissions/:id?') async permissions(@Req() req: Request) { if (!req.user || !('id' in req.user && req.user.id)) { throw new UnauthorizedException(); } const currentUser = await this.userService.findOneAndPopulate( req.user.id as string, ); const currentPermissions = await this.permissionService.findAndPopulate({ role: { $in: currentUser.roles.map(({ id }) => id), }, }); return { roles: currentUser.roles, permissions: currentPermissions.map((permission) => { if (permission.model) { return { model: permission.model.name, action: permission.action, relation: permission.relation, }; } }), }; } /** * Retrieves a paginated list of users based on filters. * * @param pageQuery - The pagination query object. * @param populate - An array of fields to populate. * @param filters - Filters applied to the query. * * @returns A promise that resolves to a paginated list of users. */ @Get() async findPage( @Query(PageQueryPipe) pageQuery: PageQueryDto, @Query(PopulatePipe) populate: string[], @Query( new SearchFilterPipe({ allowedFields: ['first_name', 'last_name'], }), ) filters: TFilterQuery, ) { if (pageQuery.limit) { return this.canPopulate(populate) ? await this.userService.findPageAndPopulate(filters, pageQuery) : await this.userService.findPage(filters, pageQuery); } return this.canPopulate(populate) ? await this.userService.findAndPopulate(filters, pageQuery.sort) : await this.userService.find(filters, pageQuery.sort); } /** * Counts the number of users that match the provided filters. * * @returns A promise that resolves to the count of filtered users. */ @Get('count') async filterCount( @Query( new SearchFilterPipe({ allowedFields: ['first_name', 'last_name'], }), ) filters?: TFilterQuery, ) { return await this.count(filters); } /** * Retrieves a single user by ID. * * @param id - The ID of the user to retrieve. * @param populate - An array of fields to populate. * * @returns A promise that resolves to the user document. */ @Get(':id') async findOne( @Param('id') id: string, @Query(PopulatePipe) populate: string[], ) { const doc = this.canPopulate(populate) ? await this.userService.findOneAndPopulate(id) : await this.userService.findOne(id); if (!doc) { this.logger.warn(`Unable to find User by id ${id}`); throw new NotFoundException(`User with ID ${id} not found`); } return doc; } } @UseInterceptors(CsrfInterceptor) @Controller('user') export class ReadWriteUserController extends ReadOnlyUserController { /** * Creates a new user. * * @param user - The user object to create. * * @returns A promise that resolves to the created user. */ @CsrfCheck(true) @Post() async create(@Body() user: UserCreateDto) { this.validate({ dto: user, allowedIds: { roles: (await this.roleService.findAll()) .filter((role) => user.roles.includes(role.id)) .map((role) => role.id), avatar: (await this.attachmentService.findOne(user.avatar))?.id, }, }); return await this.userService.create(user); } /** * Updates an existing user profile. * * @param req - The request object containing the authenticated user. * @param id - The ID of the user to update. * @param userUpdate - The user update object. * * @returns A promise that resolves to the updated user. */ @CsrfCheck(true) @Patch('edit/:id') async updateOne( @Req() req: Request, @Param('id') id: string, @Body() userUpdate: UserEditProfileDto, ) { if (!('id' in req.user && req.user.id) || req.user.id !== id) { throw new UnauthorizedException(); } const result = await this.userService.updateOne(req.user.id, userUpdate); if (!result) { this.logger.warn(`Unable to update User by id ${id}`); throw new NotFoundException(`User with ID ${id} not found`); } return result; } /** * Updates the state and roles of a user. * * This method allows updating the state and roles of a user. It ensures that * the current user cannot disable their own account or revoke their admin * privileges. If an update attempt fails, it throws a `NotFoundException`. * * @param id - The ID of the user to update. * @param body - The new state and roles of the user. * @param session - The current session of the user performing the update. * * @returns The updated user data. */ @CsrfCheck(true) @Patch(':id') async updateStateAndRoles( @Param('id') id: string, @Body() body: UserUpdateStateAndRolesDto, @Session() session: ExpressSession, ) { const oldRoles = (await this.userService.findOne(id)).roles; const newRoles = body.roles; const { id: adminRoleId } = await this.roleService.findOne({ name: 'admin', }); if (id === session.passport?.user?.id && body.state === false) { throw new ForbiddenException('Your account state is protected'); } if ( session?.passport?.user?.id === id && oldRoles.includes(adminRoleId) && !newRoles.includes(adminRoleId) ) { throw new ForbiddenException('Admin privileges are protected'); } const result = await this.userService.updateOne(id, body); if (!result) { this.logger.warn(`Unable to update User by id ${id}`); throw new NotFoundException(`User with ID ${id} not found`); } return result; } /** * Deletes a user by ID. * * This method deletes a user from the system. If no user with the given ID is found, * it throws a `NotFoundException`. A successful deletion returns a `204 No Content` status. * * @param id - The ID of the user to delete. * * @returns Nothing (HTTP 204 on success). */ @CsrfCheck(true) @Delete(':id') @HttpCode(204) async deleteOne(@Param('id') id: string) { const result = await this.userService.deleteOne(id); if (result.deletedCount === 0) { this.logger.warn(`Unable to delete User by id ${id}`); throw new NotFoundException(`User with ID ${id} not found`); } return result; } /** * Sends an invitation to a user. * * This method allows an administrator or authorized user to invite someone by * creating an invitation entry in the system. * * @param invitationCreateDto - The invitation details, including recipient information. * * @returns The created invitation record. */ @CsrfCheck(true) @Post('invite') async invite(@Body() invitationCreateDto: InvitationCreateDto) { return await this.invitationService.create(invitationCreateDto); } /** * Requests a password reset. * * This method initiates the password reset process for a user. It sends an * email or other communication to the user with instructions on how to reset * their password. * * @param body - The email or identifier of the user requesting the password reset. * * @returns A success message indicating the reset request has been processed. */ @Roles('public') @Post('reset') async requestReset(@Body() body: UserRequestResetDto) { return await this.passwordResetService.requestReset(body); } /** * Resets the password for a user. * * This method allows a user to reset their password using a provided token. The token * must be valid and correspond to a valid reset request. * * @param body - The new password and any other necessary information. * @param token - The reset token provided to the user. * * @returns A success message indicating the password has been reset. */ @Roles('public') @Post('reset/:token') async reset( @Body() body: UserResetPasswordDto, @Param('token') token: string, ) { return await this.passwordResetService.reset(body, token); } /** * Confirms a user's account. * * This method verifies a user's account by validating a confirmation token. It marks * the account as confirmed and activates it if the token is valid. * * @param body - The confirmation token to verify the user's account. * * @returns A success message indicating the account has been confirmed. */ @Roles('public') @Post('confirm') async confirmAccount(@Body() body: { token: string }) { return await this.validateAccountService.confirmAccount(body); } }