diff --git a/.env.exemple b/.env.exemple index 475d809..0496119 100644 --- a/.env.exemple +++ b/.env.exemple @@ -1,2 +1,3 @@ DATABASE_URL="postgresql://username:password@ip_server:port/database?schema=public" -REDIS_URL="redis://username:password@ip_server:port" \ No newline at end of file +REDIS_URL="redis://username:password@ip_server:port" +SESSION_TTL_SECONDS=3600 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 3cd0ff0..38bbcbd 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,4 @@ { "singleQuote": true, "trailingComma": "all" -} \ No newline at end of file +} diff --git a/package.json b/package.json index 9e4cd26..78d2769 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@nestjs/core": "11.0.1", "@nestjs/platform-express": "11.0.1", "@prisma/client": "6.14.0", + "bcrypt": "6.0.0", "class-transformer": "0.5.1", "class-validator": "0.14.2", "dayjs": "1.11.13", @@ -36,6 +37,7 @@ "@nestjs/cli": "11.0.0", "@nestjs/schematics": "11.0.0", "@nestjs/testing": "11.0.1", + "@types/bcrypt": "6.0.0", "@types/express": "5.0.0", "@types/jest": "30.0.0", "@types/node": "22.10.7", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be48f55..f186de0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,11 @@ enum status_usuario { EXCLUIDO } +enum eboolean { + V + F +} + model videos { id Int @id @default(autoincrement()) uuid String @default(dbgenerated("gen_random_uuid()")) @db.Uuid @@ -54,7 +59,7 @@ model Usuario { id Int @id @default(autoincrement()) uuid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid email String @unique @db.VarChar(255) - email_verificado Boolean @default(false) + email_verificado eboolean @default(F) password String @db.VarChar(255) nome String? @db.VarChar(100) sobrenome String? @db.VarChar(100) diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index a354002..d22f389 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -1,22 +1,22 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/src/app.module.ts b/src/app.module.ts index 39c59ee..b784c99 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,22 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from './prisma/prisma.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { VideosModule } from './videos/videos.module'; +import { UsuariosModule } from './usuarios/usuarios.module'; +import { RedisModule } from './redis/redis.module'; +import { AuthModule } from './auth/auth.module'; @Module({ - imports: [PrismaModule, VideosModule], + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + RedisModule, + AuthModule, + VideosModule, + UsuariosModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/src/app.service.ts b/src/app.service.ts index d12de69..927d7cc 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/videos/dto/paginated.dto.ts b/src/shared/dto/paginated.ts similarity index 100% rename from src/videos/dto/paginated.dto.ts rename to src/shared/dto/paginated.ts diff --git a/src/videos/videos.pipe.ts b/src/shared/pipe/index.ts similarity index 100% rename from src/videos/videos.pipe.ts rename to src/shared/pipe/index.ts diff --git a/src/usuarios/dto/create-usuario-dto.ts b/src/usuarios/dto/create-usuario-dto.ts new file mode 100644 index 0000000..67491d1 --- /dev/null +++ b/src/usuarios/dto/create-usuario-dto.ts @@ -0,0 +1,19 @@ +import { IsString, MaxLength } from 'class-validator'; + +export class CreateUsuarioDto { + @IsString() + @MaxLength(244) + nome!: string; + + @IsString() + @MaxLength(244) + sobrenome!: string; + + @IsString() + @MaxLength(244) + email!: string; + + @IsString() + @MaxLength(244) + password!: string; +} diff --git a/src/usuarios/dto/usuarios.response.ts b/src/usuarios/dto/usuarios.response.ts new file mode 100644 index 0000000..2289b51 --- /dev/null +++ b/src/usuarios/dto/usuarios.response.ts @@ -0,0 +1,53 @@ +import dayjs from 'dayjs'; +import { Expose, Transform, TransformFnParams } from 'class-transformer'; +import { papel_usuario, status_usuario, eboolean } from 'generated/prisma'; + +export class UsuariosResponseDto { + @Expose() + id!: number; + + @Expose({ name: 'nome' }) + nome!: string | null; + + @Expose() + sobrenome!: string | null; + + @Expose() + uuid!: string | null; + + @Expose() + email!: string | null; + + @Expose({ name: 'email_verificado' }) + email_verificado!: eboolean; + + @Expose() + papel!: papel_usuario; + + @Expose() + status!: status_usuario; + + @Expose({ name: 'criado_em' }) + @Transform( + ({ value }: TransformFnParams) => + value ? dayjs(value as Date | string).format('DD/MM/YYYY HH:mm:ss') : '', + { toPlainOnly: true }, + ) + criado_em!: string; + + @Expose({ name: 'atualizado_em' }) + @Transform( + ({ value }: TransformFnParams) => + value ? dayjs(value as Date | string).format('DD/MM/YYYY HH:mm:ss') : '', + { toPlainOnly: true }, + ) + atualizado_em!: string; + + @Expose({ name: 'bloqueado_ate' }) + @Transform( + ({ value }: TransformFnParams) => + value ? dayjs(value as Date | string).format('DD/MM/YYYY HH:mm:ss') : '', + { toPlainOnly: true }, + ) + bloqueado_ate!: string; +} diff --git a/src/usuarios/usuarios.controller.ts b/src/usuarios/usuarios.controller.ts new file mode 100644 index 0000000..1dda8a1 --- /dev/null +++ b/src/usuarios/usuarios.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + Param, + Post, + Body, + UsePipes, + ValidationPipe, + Patch, +} from '@nestjs/common'; +import { UsuariosService } from './usuarios.service'; +import { UsuariosResponseDto } from './dto/usuarios.response'; +import { CreateUsuarioDto } from './dto/create-usuario-dto'; + +@Controller('usuarios') +export class UsuariosController { + constructor(private readonly usuariosService: UsuariosService) {} + + @Get() + async list(): Promise { + return this.usuariosService.list(); + } + + @Get(':uuid') + async get(@Param('uuid') uuid: string): Promise { + return this.usuariosService.get(uuid); + } + + @Post() + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async create(@Body() body: CreateUsuarioDto): Promise { + return this.usuariosService.create(body); + } + + @Patch(':uuid/email-verificado') + async emailVerificado( + @Param('uuid') uuid: string, + ): Promise { + return this.usuariosService.emailVerificado(uuid); + } +} diff --git a/src/usuarios/usuarios.module.ts b/src/usuarios/usuarios.module.ts new file mode 100644 index 0000000..66df050 --- /dev/null +++ b/src/usuarios/usuarios.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UsuariosService } from './usuarios.service'; +import { UsuariosController } from './usuarios.controller'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [UsuariosService], + controllers: [UsuariosController], +}) +export class UsuariosModule {} diff --git a/src/usuarios/usuarios.service.ts b/src/usuarios/usuarios.service.ts new file mode 100644 index 0000000..0277295 --- /dev/null +++ b/src/usuarios/usuarios.service.ts @@ -0,0 +1,141 @@ +import bcrypt from 'bcrypt'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; + +import { Prisma, Usuario } from 'generated/prisma'; + +import { PrismaService } from '../prisma/prisma.service'; +import { PaginatedResponse } from '../shared/dto/paginated'; +import { UsuariosResponseDto } from './dto/usuarios.response'; +import { CreateUsuarioDto } from './dto/create-usuario-dto'; + +const SELECT = { + id: true, + uuid: true, + email: true, + email_verificado: true, + nome: true, + sobrenome: true, + papel: true, + status: true, + ultimo_login_em: true, + ultimo_login_ip: true, + tentativas_login_falhadas: true, + bloqueado_ate: true, + criado_em: true, + atualizado_em: true, +}; + +@Injectable() +export class UsuariosService { + constructor(private readonly prisma: PrismaService) {} + + async list(): Promise { + const data = await this.prisma.usuario.findMany({ + where: { email: { not: 'admin@clipperia.com' } }, + orderBy: { id: 'desc' }, + select: SELECT, + }); + + return plainToInstance(UsuariosResponseDto, data, { + excludeExtraneousValues: true, + }); + } + + async listPaginated( + page: number, + perPage: number, + direction: 'asc' | 'desc' = 'desc', + ): Promise> { + const skip = page >= 1 ? page * perPage : 0; + + const [rows, total] = await Promise.all([ + this.prisma.usuario.findMany({ + orderBy: { id: direction }, + skip, + take: perPage ?? 1, + select: SELECT, + }), + this.prisma.usuario.count(), + ]); + + const content: UsuariosResponseDto[] = plainToInstance( + UsuariosResponseDto, + rows, + { excludeExtraneousValues: true }, + ); + + const totalPages = Math.max(1, Math.ceil(total / perPage)); + + return { + content, + pagination: { + page, + direction, + perPage, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; + } + + async get(uuid: string): Promise { + const row = await this.prisma.usuario.findUnique({ + where: { uuid }, + select: SELECT, + }); + + if (!row) { + throw new NotFoundException('Usuário não encontrado'); + } + + return plainToInstance(UsuariosResponseDto, row, { + excludeExtraneousValues: true, + }); + } + + async update(id: number, data: Prisma.UsuarioUpdateInput): Promise { + return this.prisma.usuario.update({ + where: { id }, + data, + }); + } + + async create(dto: CreateUsuarioDto): Promise { + const { email, password, nome, sobrenome } = dto; + + const senhaCriptografada = await bcrypt.hash(password, 10); + + const usuario = await this.prisma.usuario.create({ + data: { + email, + password: senhaCriptografada, + nome, + sobrenome, + }, + }); + + return plainToInstance(UsuariosResponseDto, usuario, { + excludeExtraneousValues: true, + }); + } + + async delete(id: number): Promise { + return this.prisma.usuario.delete({ + where: { id }, + }); + } + + async emailVerificado(uuid: string): Promise { + const usuario = await this.prisma.usuario.update({ + where: { uuid }, + data: { email_verificado: 'V' }, + }); + + return plainToInstance(UsuariosResponseDto, usuario, { + excludeExtraneousValues: true, + }); + } +} diff --git a/src/videos/videos.controller.ts b/src/videos/videos.controller.ts index 9d8c715..f6a1bfa 100644 --- a/src/videos/videos.controller.ts +++ b/src/videos/videos.controller.ts @@ -11,8 +11,8 @@ import { videos, Prisma, video_situation } from 'generated/prisma'; import { VideosService } from './videos.service'; import { VideoResponseDto } from './dto/video-response.dto'; -import { PaginatedQueryDto, PaginatedResponse } from './dto/paginated.dto'; -import { EBooleanPipe } from './videos.pipe'; +import { PaginatedQueryDto, PaginatedResponse } from '../shared/dto/paginated'; +import { EBooleanPipe } from '../shared/pipe'; @Controller('videos') export class VideosController { diff --git a/src/videos/videos.service.ts b/src/videos/videos.service.ts index e3ee61a..7c34cf2 100644 --- a/src/videos/videos.service.ts +++ b/src/videos/videos.service.ts @@ -5,7 +5,7 @@ import { Prisma, videos, video_situation } from 'generated/prisma'; import { PrismaService } from '../prisma/prisma.service'; import { VideoResponseDto } from './dto/video-response.dto'; -import { PaginatedResponse } from './dto/paginated.dto'; +import { PaginatedResponse } from '../shared/dto/paginated'; @Injectable() export class VideosService { diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 3e0f23d..8355870 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,25 +1,21 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => + request(app.getHttpServer()).get('/').expect(200).expect('Hello World!')); +});