diff --git a/package.json b/package.json index de4f1bb..7e073ed 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,6 @@ "test:e2e": "NODE_ENV=test jest --config ./tests/jest-e2e.json" }, "dependencies": { - "@commitlint/cli": "^19.3.0", - "@commitlint/config-conventional": "^19.2.2", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", @@ -31,18 +29,12 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/terminus": "^10.2.3", - "@typescript-eslint/eslint-plugin": "^7.16.0", - "@typescript-eslint/parser": "^7.16.0", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", - "commitizen": "^4.3.0", "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", - "husky": "^9.0.11", - "lint-staged": "^15.2.7", "mongoose": "^8.5.0", - "mongoose-paginate": "^5.0.3", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -51,9 +43,16 @@ "zod": "^3.23.8" }, "devDependencies": { + "@commitlint/cli": "^19.3.0", + "@commitlint/config-conventional": "^19.2.2", "@automock/jest": "^2.1.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "commitizen": "^4.3.0", "@nestjs/cli": "^10.0.0", + "lint-staged": "^15.2.7", "@nestjs/schematics": "^10.0.0", + "husky": "^9.0.11", "@nestjs/swagger": "^7.4.0", "@nestjs/testing": "^10.0.0", "@types/bcrypt": "^5.0.2", @@ -105,5 +104,5 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" }, - "packageManager": "pnpm@9.2.0" + "packageManager": "pnpm@9.5.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e334cd9..5575b4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,6 @@ importers: .: dependencies: - '@commitlint/cli': - specifier: ^19.3.0 - version: 19.3.0(@types/node@20.14.10)(typescript@5.5.3) - '@commitlint/config-conventional': - specifier: ^19.2.2 - version: 19.2.2 '@nestjs/common': specifier: ^10.0.0 version: 10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -35,12 +29,6 @@ importers: '@nestjs/terminus': specifier: ^10.2.3 version: 10.2.3(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/mongoose@10.0.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(mongoose@8.5.0)(rxjs@7.8.1))(mongoose@8.5.0)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@typescript-eslint/eslint-plugin': - specifier: ^7.16.0 - version: 7.16.0(@typescript-eslint/parser@7.16.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3) - '@typescript-eslint/parser': - specifier: ^7.16.0 - version: 7.16.0(eslint@8.57.0)(typescript@5.5.3) bcrypt: specifier: ^5.1.1 version: 5.1.1 @@ -50,27 +38,15 @@ importers: class-validator: specifier: ^0.14.1 version: 0.14.1 - commitizen: - specifier: ^4.3.0 - version: 4.3.0(@types/node@20.14.10)(typescript@5.5.3) cookie-parser: specifier: ^1.4.6 version: 1.4.6 dotenv: specifier: ^16.4.5 version: 16.4.5 - husky: - specifier: ^9.0.11 - version: 9.0.11 - lint-staged: - specifier: ^15.2.7 - version: 15.2.7 mongoose: specifier: ^8.5.0 version: 8.5.0 - mongoose-paginate: - specifier: ^5.0.3 - version: 5.0.3 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -93,6 +69,12 @@ importers: '@automock/jest': specifier: ^2.1.0 version: 2.1.0(jest@29.7.0(@types/node@20.14.10)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))) + '@commitlint/cli': + specifier: ^19.3.0 + version: 19.3.0(@types/node@20.14.10)(typescript@5.5.3) + '@commitlint/config-conventional': + specifier: ^19.2.2 + version: 19.2.2 '@nestjs/cli': specifier: ^10.0.0 version: 10.4.2 @@ -126,6 +108,15 @@ importers: '@types/supertest': specifier: ^6.0.0 version: 6.0.2 + '@typescript-eslint/eslint-plugin': + specifier: ^7.16.0 + version: 7.16.0(@typescript-eslint/parser@7.16.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3) + '@typescript-eslint/parser': + specifier: ^7.16.0 + version: 7.16.0(eslint@8.57.0)(typescript@5.5.3) + commitizen: + specifier: ^4.3.0 + version: 4.3.0(@types/node@20.14.10)(typescript@5.5.3) cz-conventional-changelog: specifier: ^3.3.0 version: 3.3.0(@types/node@20.14.10)(typescript@5.5.3) @@ -138,9 +129,15 @@ importers: eslint-plugin-prettier: specifier: ^5.0.0 version: 5.1.3(@types/eslint@8.56.10)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.2) + husky: + specifier: ^9.0.11 + version: 9.0.11 jest: specifier: ^29.5.0 version: 29.7.0(@types/node@20.14.10)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3)) + lint-staged: + specifier: ^15.2.7 + version: 15.2.7 prettier: specifier: ^3.0.0 version: 3.3.2 @@ -1241,9 +1238,6 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bluebird@3.0.5: - resolution: {integrity: sha512-5o9RE3ued60EEv6gcT5L2APn/4zSpAwzeH65fuF+d/K3/LGZ9JpGIlDU2HeI4NujAY5Wh7mUBM5UF3Zuh8bBhQ==} - body-parser@1.20.2: resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2810,10 +2804,6 @@ packages: socks: optional: true - mongoose-paginate@5.0.3: - resolution: {integrity: sha512-sqrQJJ2nY7z2S5XzrMxbnlnAXpOnqyyM8t/FQ4Rv0uimYyacitmVSZOjlotbiQLI8I4v4hy3dCtJn31+OPl5Wg==} - engines: {node: '>=4.0.0'} - mongoose@8.5.0: resolution: {integrity: sha512-iGgZvgO+fIgX1AQMehkG+Wj8qrWc9it8vUZrSKWjrebgfwHTqUcIdTgWK8mT1us1xd83NOQxiuGbg9ZJtLxs2Q==} engines: {node: '>=16.20.1'} @@ -5159,8 +5149,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bluebird@3.0.5: {} - body-parser@1.20.2: dependencies: bytes: 3.1.2 @@ -6959,10 +6947,6 @@ snapshots: bson: 6.8.0 mongodb-connection-string-url: 3.0.1 - mongoose-paginate@5.0.3: - dependencies: - bluebird: 3.0.5 - mongoose@8.5.0: dependencies: bson: 6.8.0 diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 141f3ec..3ed0500 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,19 +3,15 @@ import { MongooseModule } from '@nestjs/mongoose'; import { AuthModule } from '~/app/auth/auth.module'; import { UserModule } from '~/app/user/user.module'; +import { AuthorModule } from '~/app/author/author.module'; import { env } from '~/env'; @Module({ imports: [ - MongooseModule.forRoot(env.DATABASE_URL, { - connectionFactory: connection => { - connection.plugin(require('mongoose-paginate')); - - return connection; - }, - }), + MongooseModule.forRoot(env.DATABASE_URL), AuthModule, + AuthorModule, UserModule, ], controllers: [], diff --git a/src/app/auth/role.guard.ts b/src/app/auth/role.guard.ts new file mode 100644 index 0000000..5c1b15a --- /dev/null +++ b/src/app/auth/role.guard.ts @@ -0,0 +1,23 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { Roles } from '~/app/auth/auth.decorator'; +import { Session } from '~/schemas/session.schema'; + +@Injectable() +export class RoleGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const roles = this.reflector.get(Roles, context.getHandler()); + + if (roles) { + const req = context.switchToHttp().getRequest(); + const session = req.user as Session; + + return roles.includes(session.user.role); + } + + return true; + } +} diff --git a/src/app/auth/tests/auth.service.spec.ts b/src/app/auth/tests/auth.service.spec.ts index 6c5fe7a..00c6fa6 100644 --- a/src/app/auth/tests/auth.service.spec.ts +++ b/src/app/auth/tests/auth.service.spec.ts @@ -1,7 +1,6 @@ import * as bcrypt from 'bcrypt'; import { Test, TestingModule } from '@nestjs/testing'; -import { AuthService } from '~/app/auth/auth.service'; import { Model } from 'mongoose'; import { JwtService } from '@nestjs/jwt'; import { getModelToken } from '@nestjs/mongoose'; @@ -18,6 +17,7 @@ import { USER_NOT_FOUND, } from '~/errors'; import { UserService } from '~/app/user/user.service'; +import { AuthService } from '~/app/auth/auth.service'; describe('AuthService', () => { let authService: AuthService; diff --git a/src/app/author/author.controller.ts b/src/app/author/author.controller.ts new file mode 100644 index 0000000..8c35cbf --- /dev/null +++ b/src/app/author/author.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Delete, + Get, + HttpStatus, + Param, + Post, + Put, + Query, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; + +import { + CreateAuthorDto, + SearchAuthorDto, + UpdateAuthorDto, +} from '~/app/author/author.dto'; +import { AuthorService } from '~/app/author/author.service'; +import { JwtAuthGuard } from '~/app/auth/auth.guard'; +import { RoleGuard } from '~/app/auth/role.guard'; +import { Roles } from '~/app/auth/auth.decorator'; +import { Role } from '~/types/role.enum'; + +@ApiTags('Author') +@Controller('author') +export class AuthorController { + constructor(private readonly authorService: AuthorService) {} + + @Post('create') + @ApiResponse({ + status: 201, + description: 'Create new author', + }) + @UseGuards(JwtAuthGuard, RoleGuard) + @Roles([Role.ADMIN]) + async create(@Body() body: CreateAuthorDto, @Res() res: Response) { + const data = await this.authorService.create(body); + + return res.status(HttpStatus.CREATED).json(data); + } + + @Get(':id') + @ApiResponse({ + status: 200, + description: 'Get a author', + }) + async getById(@Param('id') id: string, @Res() res: Response) { + const data = await this.authorService.getById(id); + + return res.status(HttpStatus.CREATED).json(data); + } + + @Delete('delete/:id') + @ApiResponse({ + status: 201, + description: 'Delete a author', + }) + @UseGuards(JwtAuthGuard, RoleGuard) + @Roles([Role.ADMIN]) + async delete(@Param('id') id: string, @Res() res: Response) { + await this.authorService.delete(id); + + return res.status(HttpStatus.OK).send(); + } + + @Put('update/:id') + @ApiResponse({ + status: 201, + description: 'Update a author', + }) + @UseGuards(JwtAuthGuard, RoleGuard) + @Roles([Role.ADMIN]) + async update( + @Body() body: UpdateAuthorDto, + @Param('id') id: string, + @Res() res: Response, + ) { + const data = await this.authorService.update(id, body); + + return res.status(HttpStatus.OK).json(data); + } + + @Get('search') + @ApiResponse({ + status: 200, + description: 'Author search', + }) + async search(@Query() query: SearchAuthorDto, @Res() res: Response) { + const data = await this.authorService.search(query); + return res.status(HttpStatus.OK).json(data); + } +} diff --git a/src/app/author/author.dto.ts b/src/app/author/author.dto.ts new file mode 100644 index 0000000..34b4116 --- /dev/null +++ b/src/app/author/author.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsDateString, IsNotEmpty, IsOptional, Min } from 'class-validator'; + +import { SortOrder } from '~/types/filter.enum'; + +export class UpdateAuthorDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsNotEmpty() + name: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + birthDate: Date; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNotEmpty() + biography: string; +} + +export class CreateAuthorDto { + @ApiProperty() + @IsNotEmpty() + name: string; + + @ApiProperty() + @IsDateString() + birthDate: Date; + + @ApiProperty() + @IsNotEmpty() + biography: string; +} + +export class SearchAuthorDto { + @ApiProperty({ required: false }) + @Transform(params => { + if (params.value) { + if (params.value.trim().length == 0) return undefined; + } + + return params.value; + }) + query: string; + + @ApiProperty({ default: 1, required: false }) + @Transform(params => parseInt(params.value)) + @IsOptional() + @Min(1) + page: number = 1; + + @ApiProperty({ enum: SortOrder, default: SortOrder.DESC, required: false }) + sortOrder: SortOrder; + + @ApiProperty({ required: false }) + @IsOptional() + sortField: string; + + @ApiProperty({ default: 12, required: false }) + @Transform(params => parseInt(params.value)) + @IsOptional() + @Min(1) + perPage: number = 12; +} diff --git a/src/app/author/author.module.ts b/src/app/author/author.module.ts new file mode 100644 index 0000000..deb6b9c --- /dev/null +++ b/src/app/author/author.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { AuthorController } from '~/app/author/author.controller'; +import { AuthorService } from '~/app/author/author.service'; +import { Author, AuthorSchema } from '~/schemas/author.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Author.name, schema: AuthorSchema }]), + ], + controllers: [AuthorController], + providers: [AuthorService], +}) +export class AuthorModule {} diff --git a/src/app/author/author.service.ts b/src/app/author/author.service.ts new file mode 100644 index 0000000..1f40ee4 --- /dev/null +++ b/src/app/author/author.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { FilterQuery, Model } from 'mongoose'; + +import { + CreateAuthorDto, + SearchAuthorDto, + UpdateAuthorDto, +} from '~/app/author/author.dto'; +import { Author } from '~/schemas/author.schema'; +import { paginate } from '~/utils/funcs/pagination'; + +@Injectable() +export class AuthorService { + constructor( + @InjectModel(Author.name) private readonly authorModel: Model, + ) {} + + async create(data: CreateAuthorDto) { + return this.authorModel.create(data); + } + + async update(id: string, data: UpdateAuthorDto) { + return this.authorModel.findByIdAndUpdate(id, data, { new: true }); + } + + async delete(id: string) { + return this.authorModel.findByIdAndDelete(id); + } + + async getById(id: string) { + return this.authorModel.findById(id); + } + + async search(data: SearchAuthorDto) { + const query: FilterQuery = {}; + + if (data.query) { + query.$or = [{ name: { $regex: data.query, $options: 'i' } }]; + } + + return paginate(this.authorModel, query, data); + } +} diff --git a/src/app/author/tests/author.service.spec.ts b/src/app/author/tests/author.service.spec.ts new file mode 100644 index 0000000..b333931 --- /dev/null +++ b/src/app/author/tests/author.service.spec.ts @@ -0,0 +1,49 @@ +import { Model } from 'mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/mongoose'; + +import { Author } from '~/schemas/author.schema'; +import { AuthorService } from '~/app/author/author.service'; +import { CreateAuthorDto } from '~/app/author/author.dto'; +import { Entity } from '~/types'; + +describe('AuthorService', () => { + let authorModel: Model; + let authorService: AuthorService; + + afterEach(() => jest.clearAllMocks()); + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [], + providers: [ + AuthorService, + { + provide: getModelToken(Author.name), + useValue: Model, + }, + ], + }).compile(); + + authorService = app.get(AuthorService); + authorModel = app.get>(getModelToken(Author.name)); + }); + + describe('create', () => { + const payload: CreateAuthorDto = { + biography: expect.anything(), + name: expect.anything(), + birthDate: expect.anything(), + }; + + const author = {} as Entity[]; + it('should return author created', async () => { + jest.spyOn(authorModel, 'create').mockResolvedValue(author); + + const data = await authorService.create(payload); + + expect(data).toBe(author); + expect(authorModel.create).toHaveBeenCalledWith(payload); + }); + }); +}); diff --git a/src/app/user/user.service.ts b/src/app/user/user.service.ts index 94cec35..a4f57b5 100644 --- a/src/app/user/user.service.ts +++ b/src/app/user/user.service.ts @@ -4,7 +4,7 @@ import { FilterQuery, Model, Types } from 'mongoose'; import { User } from '~/schemas/user.schema'; import { SignUpDto } from '~/app/auth/auth.dto'; -import { UpdateUserProfileDto } from './user.dto'; +import { UpdateUserProfileDto } from '~/app/user/user.dto'; @Injectable() export class UserService { diff --git a/src/main.ts b/src/main.ts index 5fd1dee..d84a9ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,7 @@ import { AUTH_COOKIE } from '~/constants'; import 'reflect-metadata'; (async () => { - const app = await NestFactory.create(AppModule, {}); + const app = await NestFactory.create(AppModule, { logger: false }); app.use(cookieParser()); app.useGlobalPipes( diff --git a/src/schemas/author.schema.ts b/src/schemas/author.schema.ts new file mode 100644 index 0000000..ff9e5ad --- /dev/null +++ b/src/schemas/author.schema.ts @@ -0,0 +1,18 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import * as mongoose from 'mongoose'; + +export type AuthorDocument = mongoose.HydratedDocument; + +@Schema({ timestamps: true }) +export class Author { + @Prop({ required: false }) + biography: string; + + @Prop({ required: true, index: 'text' }) + name: string; + + @Prop({ required: true }) + birthDate: Date; +} + +export const AuthorSchema = SchemaFactory.createForClass(Author); diff --git a/src/schemas/session.schema.ts b/src/schemas/session.schema.ts index aba3c9a..e464d43 100644 --- a/src/schemas/session.schema.ts +++ b/src/schemas/session.schema.ts @@ -5,7 +5,7 @@ import { User } from './user.schema'; export type SessionDocument = mongoose.HydratedDocument; -@Schema() +@Schema({ timestamps: true }) export class Session { @Prop({ required: false }) accessToken: string; diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index bdebcae..2f593c5 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -6,7 +6,7 @@ import { Role } from '~/types/role.enum'; export type UserDocument = HydratedDocument; -@Schema() +@Schema({ timestamps: true }) export class User { @Prop({ required: true }) name: string; diff --git a/src/types/filter.enum.ts b/src/types/filter.enum.ts new file mode 100644 index 0000000..cabb1f2 --- /dev/null +++ b/src/types/filter.enum.ts @@ -0,0 +1,4 @@ +export enum SortOrder { + ASC = 'asc', + DESC = 'desc', +} diff --git a/src/utils/funcs/pagination.ts b/src/utils/funcs/pagination.ts new file mode 100644 index 0000000..b83de48 --- /dev/null +++ b/src/utils/funcs/pagination.ts @@ -0,0 +1,32 @@ +import * as mongoose from 'mongoose'; + +import { SortOrder } from '~/types/filter.enum'; + +type PaginationArgs = { + page: number; + perPage: number; + sortOrder: SortOrder; + query: string; + sortField: string | undefined; +}; +export const paginate = async ( + model: mongoose.Model, + query: mongoose.FilterQuery, + args: PaginationArgs, +) => { + const orders: Record = { + [SortOrder.ASC]: 1, + [SortOrder.DESC]: -1, + }; + + const total = await model.countDocuments(query); + const items = await model + .find(query) + .sort({ [args.sortField]: orders[args.sortOrder] }) + .skip((args.page - 1) * args.perPage) + .limit(args.perPage); + + const pages = Math.ceil(total / args.perPage); + + return { page: args.page, items, pages, total, perPage: args.perPage }; +};