Skip to content

Commit

Permalink
API: extends users module
Browse files Browse the repository at this point in the history
  • Loading branch information
agnlez authored and alexeh committed Oct 10, 2024
1 parent 85d1e21 commit 16c570f
Show file tree
Hide file tree
Showing 19 changed files with 455 additions and 26 deletions.
4 changes: 3 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
save-prefix=''
save-prefix=''
public-hoist-pattern[]=*nestjs-base-service*
public-hoist-pattern[]=*typeorm*
4 changes: 3 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/typeorm": "^10.0.2",
"@ts-rest/nest": "^3.51.0",
"@types/multer": "^1.4.12",
"@types/multer": "1.4.12",
"bcrypt": "catalog:",
"class-transformer": "catalog:",
"class-validator": "catalog:",
"dotenv": "16.4.5",
"lodash": "^4.17.21",
"nestjs-base-service": "catalog:",
"nodemailer": "^6.9.15",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
Expand Down
9 changes: 9 additions & 0 deletions api/src/decorators/get-user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '@shared/entities/users/user.entity';

export const GetUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): User => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
4 changes: 2 additions & 2 deletions api/src/modules/auth/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { UsersService } from '@api/modules/users/users.service';
import { User } from '@shared/entities/users/user.entity';
import * as bcrypt from 'bcrypt';
import { CommandBus, EventBus } from '@nestjs/cqrs';
import { UserWithAccessToken } from '@shared/dtos/user.dto';
import { UserWithAccessToken } from '@shared/dtos/users/user.dto';
import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema';
import { CreateUserDto } from '@shared/schemas/users/create-user.schema';
import { CreateUserDto } from '@shared/dtos/users/create-user.dto';
import { randomBytes } from 'node:crypto';
import { SendWelcomeEmailCommand } from '@api/modules/notifications/email/commands/send-welcome-email.command';
import { JwtManager } from '@api/modules/auth/services/jwt.manager';
Expand Down
69 changes: 66 additions & 3 deletions api/src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,67 @@
import { Controller } from '@nestjs/common';
import {
Controller,
ClassSerializerInterceptor,
Body,
Param,
ParseUUIDPipe,
UseInterceptors,
HttpStatus,
UnauthorizedException,
} from '@nestjs/common';

@Controller('users')
export class UsersController {}
import { UsersService } from './users.service';
import { tsRestHandler, TsRestHandler } from '@ts-rest/nest';
import { GetUser } from '@api/decorators/get-user.decorator';
import { User } from '@shared/entities/users/user.entity';
import { usersContract as c } from '@shared/contracts/users.contract';

import { UpdateUserPasswordDto } from '@shared/dtos/users/update-user-password.dto';
import { UpdateUserDto } from '@shared/dtos/users/update-user.dto';

@Controller()
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
constructor(private usersService: UsersService) {}

@TsRestHandler(c.findMe)
async findMe(@GetUser() user: User): Promise<any> {
return tsRestHandler(c.findMe, async ({ query }) => {
const foundUser = await this.usersService.getById(user.id, query);
if (!foundUser) {
throw new UnauthorizedException();
}
return { body: { data: foundUser }, status: HttpStatus.OK };
});
}

@TsRestHandler(c.updatePassword)
async updatePassword(
@Body() dto: UpdateUserPasswordDto['newPassword'],
@GetUser() user: User,
): Promise<any> {
return tsRestHandler(c.updatePassword, async () => {
const updatedUser = await this.usersService.updatePassword(user, dto);
return { body: { data: updatedUser }, status: HttpStatus.OK };
});
}

@TsRestHandler(c.updateUser)
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
): Promise<any> {
return tsRestHandler(c.updateUser, async () => {
const user = await this.usersService.update(id, dto);
//return { body: { data: user }, status: HttpStatus.CREATED };
return { body: { data: user }, status: HttpStatus.CREATED };
});
}

@TsRestHandler(c.deleteMe)
async deleteMe(@GetUser() user: User): Promise<any> {
return tsRestHandler(c.deleteMe, async () => {
await this.usersService.remove(user.id);
return { body: null, status: HttpStatus.OK };
});
}
}
30 changes: 22 additions & 8 deletions api/src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,53 @@ import { User } from '@shared/entities/users/user.entity';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';

import { AppBaseService } from '@api/utils/app-base.service';
import { CreateUserDto } from '@shared/dtos/users/create-user.dto';
import { UpdateUserDto } from '@shared/dtos/users/update-user.dto';
import { AppInfoDTO } from '@api/utils/info.dto';

@Injectable()
export class UsersService {
constructor(@InjectRepository(User) private repo: Repository<User>) {}
export class UsersService extends AppBaseService<
User,
CreateUserDto,
UpdateUserDto,
AppInfoDTO
> {
constructor(
@InjectRepository(User) private userRepository: Repository<User>,
) {
super(userRepository, 'user', 'users');
}

async findOneBy(id: string) {
return this.repo.findOne({
return this.userRepository.findOne({
where: { id },
});
}

async findByEmail(email: string): Promise<User | null> {
return this.repo.findOne({ where: { email } });
return this.userRepository.findOne({ where: { email } });
}

async createUser(newUser: Partial<User>) {
const existingUser = await this.findByEmail(newUser.email);
if (existingUser) {
throw new ConflictException(`Email ${newUser.email} already exists`);
}
return this.repo.save(newUser);
return this.userRepository.save(newUser);
}

async updatePassword(user: User, newPassword: string) {
user.password = await bcrypt.hash(newPassword, 10);
return this.repo.save(user);
return this.userRepository.save(user);
}

async delete(user: User) {
return this.repo.remove(user);
return this.userRepository.remove(user);
}

async isUserActive(id: string) {
const user = await this.repo.findOneBy({ id });
const user = await this.userRepository.findOneBy({ id });
return user.isActive;
}
}
67 changes: 67 additions & 0 deletions api/src/utils/app-base.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
BaseService,
DEFAULT_PAGINATION,
FetchSpecification,
} from 'nestjs-base-service';

import { Repository } from 'typeorm';
import { PaginationMeta } from '@shared/dtos/global/api-response.dto';

export abstract class AppBaseService<
// eslint-disable-next-line @typescript-eslint/ban-types
Entity extends object,
CreateModel,
UpdateModel,
Info,
> extends BaseService<Entity, CreateModel, UpdateModel, Info> {
constructor(
protected readonly repository: Repository<Entity>,
protected alias: string = 'base_entity',
protected pluralAlias: string = 'base_entities',
protected idProperty: string = 'id',
) {
super(repository, alias, { idProperty });
}

async findAllPaginated(
fetchSpecification?: FetchSpecification,
extraOps?: Record<string, any>,
info?: Info,
): Promise<{
data: (Partial<Entity> | undefined)[];
metadata: PaginationMeta | undefined;
}> {
const entitiesAndCount: [Partial<Entity>[], number] = await this.findAll(
{ ...fetchSpecification, ...extraOps },
info,
);
return this._paginate(entitiesAndCount, fetchSpecification);
}

private _paginate(
entitiesAndCount: [Partial<Entity>[], number],
fetchSpecification?: FetchSpecification,
): {
data: (Partial<Entity> | undefined)[];
metadata: PaginationMeta | undefined;
} {
const totalItems: number = entitiesAndCount[1];
const entities: Partial<Entity>[] = entitiesAndCount[0];
const pageSize: number =
fetchSpecification?.pageSize ?? DEFAULT_PAGINATION.pageSize ?? 25;
const page: number =
fetchSpecification?.pageNumber ?? DEFAULT_PAGINATION.pageNumber ?? 1;
const disablePagination: boolean | undefined =
fetchSpecification?.disablePagination;
const meta: PaginationMeta | undefined = disablePagination
? undefined
: new PaginationMeta({
totalPages: Math.ceil(totalItems / pageSize),
totalItems,
size: pageSize,
page,
});

return { data: entities, metadata: meta };
}
}
4 changes: 4 additions & 0 deletions api/src/utils/info.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { InfoDTO } from 'nestjs-base-service';
import { User } from '@shared/entities/users/user.entity';

export type AppInfoDTO = InfoDTO<User>;
4 changes: 3 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ catalog:
bcrypt: "5.1.1"
"@types/node": 20.14.2
typescript: 5.4.5
reflect-metadata: "^0.2.0"
reflect-metadata: "^0.2.0"
class-validator: "0.14.1"
nestjs-base-service: "0.11.1"
2 changes: 1 addition & 1 deletion shared/contracts/auth.contract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { initContract } from "@ts-rest/core";
import { LogInSchema } from "@shared/schemas/auth/login.schema";
import { UserWithAccessToken } from "@shared/dtos/user.dto";
import { UserWithAccessToken } from "@shared/dtos/users/user.dto";
import { JSONAPIError } from "@shared/dtos/json-api.error";
import { TokenTypeSchema } from "@shared/schemas/auth/token-type.schema";
import { z } from "zod";
Expand Down
2 changes: 2 additions & 0 deletions shared/contracts/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { initContract } from "@ts-rest/core";
import { adminContract } from "@shared/contracts/admin.contract";
import { authContract } from "@shared/contracts/auth.contract";
import { usersContract } from "@shared/contracts/users.contract";

const contract = initContract();

export const router = contract.router({
auth: authContract,
admin: adminContract,
user: usersContract,
});
70 changes: 70 additions & 0 deletions shared/contracts/users.contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { initContract } from "@ts-rest/core";
import { JSONAPIError } from "@shared/dtos/json-api.error";
import { generateEntityQuerySchema } from '@shared/schemas/query-param.schema';
import { User } from '@shared/entities/users/user.entity';
import { UserDto } from '@shared/dtos/users/user.dto';
import { UpdateUserPasswordDto } from '@shared/dtos/users/update-user-password.dto';
import { z } from 'zod';
import { UpdateUserDto } from '@shared/dtos/users/update-user.dto';
import { PasswordSchema } from '@shared/schemas/auth/login.schema';

import {
ApiResponse,
} from '@shared/dtos/global/api-response.dto';

const contract = initContract();
export const usersContract = contract.router({
findMe: {
method: 'GET',
path: '/users/me',
responses: {
200: contract.type<ApiResponse<UserDto>>(),
401: contract.type<JSONAPIError>(),
},
query: generateEntityQuerySchema(User),
},
updateUser: {
method: 'PATCH',
path: '/users/:id',
pathParams: z.object({
id: z.coerce.string(),
}),
responses: {
200: contract.type<ApiResponse<UserDto>>(),
400: contract.type<JSONAPIError>(),
401: contract.type<JSONAPIError>(),
},
body: contract.type<UpdateUserDto>(),
summary: 'Update an existing user',
},
updatePassword: {
method: 'PATCH',
path: '/users/me/password',
responses: {
200: contract.type<ApiResponse<UserDto>>(),
400: contract.type<JSONAPIError>(),
401: contract.type<JSONAPIError>(),
},
body: contract.type<UpdateUserPasswordDto>(),
summary: 'Update password of the user',
},
deleteMe: {
method: 'DELETE',
path: '/users/me',
responses: {
200: null,
400: contract.type<JSONAPIError>(),
401: contract.type<JSONAPIError>(),
},
body: null,
},
resetPassword: {
method: 'POST',
path: '/users/me/password/reset',
responses: {
200: contract.type<null>(),
400: contract.type<JSONAPIError>(),
},
body: PasswordSchema,
},
});
41 changes: 41 additions & 0 deletions shared/dtos/global/api-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @description Generic PaginationMetadata
*/

export class PaginationMeta {
totalPages: number;
totalItems: number;
size: number;
page: number;

constructor(paginationMeta: {
totalPages: number;
totalItems: number;
size: number | string;
page: number | string;
}) {
this.totalItems = paginationMeta.totalItems;
this.totalPages = paginationMeta.totalPages;
this.size =
typeof paginationMeta.size === 'string'
? parseInt(paginationMeta.size)
: paginationMeta.size;
this.page =
typeof paginationMeta.page === 'string'
? parseInt(paginationMeta.page)
: paginationMeta.page;
}
}

/**
* @description Generic ApiResponse
*/

export class ApiResponse<T> {
data: T;
}

export class ApiPaginationResponse<T> {
data: Partial<T>[];
metadata?: PaginationMeta;
}
Loading

0 comments on commit 16c570f

Please sign in to comment.