diff --git a/CLAUDE.md b/CLAUDE.md index d6ea0e5..b66eaee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ pnpm --filter api test:e2e # Run e2e tests - Entry: `src/main.tsx` → `src/App.tsx` - Vite + React 19, strict TypeScript (`tsconfig.app.json`) - ESLint with React hooks and refresh plugins -- Styling with **Tailwind CSS** +- Styling with **Tailwind CSS** (v4, config via `@theme` em `src/index.css`) #### Component Structure — Atomic Design @@ -66,6 +66,34 @@ src/components/ pages/ # Full pages wired to real data/routes ``` +#### Design Tokens — Cores + +A paleta do projeto é definida via `@theme` em `src/index.css` e deve ser usada em todos os componentes. **Nunca use hexadecimais diretamente em classes Tailwind.** + +| Token | Uso | +|---|---| +| `grafite` | Fundo da página | +| `cinza-escuro` | Fundo de cards/modais | +| `cinza-medio` | Fundo de inputs, checkboxes | +| `offwhite` | Texto principal | +| `verde-destaque` | Acento primário (botões, links, focus rings) | +| `verde-petroleo` | Texto sobre fundo verde (ex: label do botão primário) | + +Exemplos: `bg-grafite`, `text-offwhite`, `bg-verde-destaque`, `text-verde-petroleo`, `focus:ring-verde-destaque`. + +#### Design Tokens — Tamanhos de Fonte + +Use sempre os tokens padrão do Tailwind mais próximos ao valor do design. **Nunca use tamanhos arbitrários como `text-[31px]`.** + +| Token Tailwind | Tamanho | Uso (Figma) | +|---|---|---| +| `text-3xl` | 30px | Títulos de página (Subtitle Large) | +| `text-2xl` | 24px | Subtítulos (Paragraph Large ~22px) | +| `text-lg` | 18px | Texto de corpo / labels (Paragraph) | +| `text-base` | 16px | Texto secundário (Paragraph Small ~15px) | +| `text-sm` | 14px | Texto auxiliar pequeno | +| `text-xs` | 12px | Labels menores (Label ~12.5px) | + Rules: - A component **must not** import from a higher-level category (atoms cannot import molecules). - Every component **must have a co-located test** file (`ComponentName.test.tsx`) covering its essential usage (render, key interactions, accessibility where applicable). diff --git a/apps/api/package.json b/apps/api/package.json index 80cccdc..e3305b1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,14 +17,27 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "seed": "ts-node -r tsconfig-paths/register src/seed.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.6", + "@nestjs/typeorm": "^11.0.0", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pg": "^8.20.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "typeorm": "^0.3.28" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -32,9 +45,11 @@ "@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", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8662803..c0875bf 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,9 +1,37 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; +import { Comment } from './posts/entities/comment.entity'; +import { Like } from './posts/entities/like.entity'; +import { Post } from './posts/entities/post.entity'; +import { PostsModule } from './posts/posts.module'; +import { User } from './users/entities/user.entity'; +import { UsersModule } from './users/users.module'; @Module({ - imports: [], + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('DB_HOST', 'localhost'), + port: config.get('DB_PORT', 5432), + username: config.get('DB_USERNAME', 'codeconnect'), + password: config.get('DB_PASSWORD', 'codeconnect'), + database: config.get('DB_NAME', 'codeconnect'), + entities: [User, Post, Comment, Like], + synchronize: true, + }), + }), + UsersModule, + AuthModule, + PostsModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..ecbc1e1 --- /dev/null +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -0,0 +1,54 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: { + login: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + describe('login', () => { + it('should call authService.login and return the token', async () => { + const dto = { email: 'john@example.com', password: 'secret123' }; + const expected = { access_token: 'signed-token' }; + authService.login.mockResolvedValue(expected); + + const result = await controller.login(dto); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(authService.login).toHaveBeenCalledWith(dto); + expect(result).toEqual(expected); + }); + }); + + describe('getMe', () => { + it('should return req.user', () => { + const mockUser = { + id: 'uuid-1', + name: 'John', + email: 'john@example.com', + }; + const req = { user: mockUser }; + + const result = controller.getMe(req); + + expect(result).toEqual(mockUser); + }); + }); +}); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..3394333 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,44 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Request, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login and receive JWT' }) + @ApiResponse({ status: 200, description: 'JWT token returned' }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) + async login(@Body() loginDto: LoginDto) { + return this.authService.login(loginDto); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'Current user data' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + getMe(@Request() req: { user: unknown }) { + return req.user; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..78534d4 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [ + UsersModule, + PassportModule, + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET', 'dev-secret-change-me'), + signOptions: { expiresIn: '1h' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..94a340b --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,83 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; +import { UsersService } from '../users/users.service'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + let usersService: jest.Mocked; + let jwtService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: UsersService, + useValue: { + findByEmail: jest.fn(), + }, + }, + { + provide: JwtService, + useValue: { + sign: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AuthService); + usersService = module.get(UsersService); + jwtService = module.get(JwtService); + }); + + describe('login', () => { + it('should return access_token on valid credentials', async () => { + const hashedPassword = await bcrypt.hash('secret123', 10); + usersService.findByEmail.mockResolvedValue({ + id: 'uuid-1', + name: 'John', + email: 'john@example.com', + password: hashedPassword, + }); + jwtService.sign.mockReturnValue('signed-token'); + + const result = await service.login({ + email: 'john@example.com', + password: 'secret123', + }); + + expect(result).toEqual({ access_token: 'signed-token' }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: 'uuid-1', + email: 'john@example.com', + }); + }); + + it('should throw UnauthorizedException when email not found', async () => { + usersService.findByEmail.mockResolvedValue(null); + + await expect( + service.login({ email: 'nobody@example.com', password: 'pass' }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException when password is wrong', async () => { + const hashedPassword = await bcrypt.hash('correct-pass', 10); + usersService.findByEmail.mockResolvedValue({ + id: 'uuid-1', + name: 'John', + email: 'john@example.com', + password: hashedPassword, + }); + + await expect( + service.login({ email: 'john@example.com', password: 'wrong-pass' }), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..6d823e8 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,28 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { UsersService } from '../users/users.service'; +import { LoginDto } from './dto/login.dto'; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + async login(dto: LoginDto): Promise<{ access_token: string }> { + const user = await this.usersService.findByEmail(dto.email); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + + const passwordMatch = await bcrypt.compare(dto.password, user.password); + if (!passwordMatch) { + throw new UnauthorizedException('Invalid credentials'); + } + + const payload = { sub: user.id, email: user.email }; + return { access_token: this.jwtService.sign(payload) }; + } +} diff --git a/apps/api/src/auth/constants.ts b/apps/api/src/auth/constants.ts new file mode 100644 index 0000000..c3fedc2 --- /dev/null +++ b/apps/api/src/auth/constants.ts @@ -0,0 +1,2 @@ +export const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-me'; +export const JWT_EXPIRES_IN = '1h'; diff --git a/apps/api/src/auth/dto/login.dto.ts b/apps/api/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..4ac0f01 --- /dev/null +++ b/apps/api/src/auth/dto/login.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString } from 'class-validator'; + +export class LoginDto { + @ApiProperty({ example: 'john@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'strongP@ss1' }) + @IsString() + password: string; +} diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/apps/api/src/auth/guards/optional-jwt-auth.guard.ts b/apps/api/src/auth/guards/optional-jwt-auth.guard.ts new file mode 100644 index 0000000..d1cd44e --- /dev/null +++ b/apps/api/src/auth/guards/optional-jwt-auth.guard.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class OptionalJwtAuthGuard extends AuthGuard('jwt') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleRequest(_err: any, user: any) { + return user || null; + } +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..1024f9b --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { UsersService } from '../../users/users.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private readonly usersService: UsersService, + config: ConfigService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.get('JWT_SECRET', 'dev-secret-change-me'), + }); + } + + async validate(payload: { sub: string; email: string }) { + const user = await this.usersService.findById(payload.sub); + if (!user) return null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...result } = user; + return result; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f76bc8d..6e25cfa 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,8 +1,30 @@ +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + + app.enableCors({ origin: 'http://localhost:5173' }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const config = new DocumentBuilder() + .setTitle('Code Connect API') + .setDescription('Authentication and user management API') + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + await app.listen(process.env.PORT ?? 3000); } -bootstrap(); +void bootstrap(); diff --git a/apps/api/src/posts/dto/create-comment.dto.ts b/apps/api/src/posts/dto/create-comment.dto.ts new file mode 100644 index 0000000..9ffbf30 --- /dev/null +++ b/apps/api/src/posts/dto/create-comment.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateCommentDto { + @ApiProperty({ example: 'Excelente post! Aprendi muito com esse conteúdo.' }) + @IsString() + @IsNotEmpty() + content: string; +} diff --git a/apps/api/src/posts/dto/create-post.dto.ts b/apps/api/src/posts/dto/create-post.dto.ts new file mode 100644 index 0000000..8d7be02 --- /dev/null +++ b/apps/api/src/posts/dto/create-post.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreatePostDto { + @ApiProperty({ example: 'Construindo um design system com React' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ example: 'Neste post vou mostrar como construir um design system escalável com React e TypeScript.' }) + @IsString() + @IsNotEmpty() + content: string; + + @ApiPropertyOptional({ example: 'https://example.com/thumbnail.png' }) + @IsString() + @IsOptional() + thumbnail?: string; + + @ApiPropertyOptional({ example: ['React', 'TypeScript', 'Front-end'] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + tags?: string[]; +} diff --git a/apps/api/src/posts/dto/query-posts.dto.ts b/apps/api/src/posts/dto/query-posts.dto.ts new file mode 100644 index 0000000..3f417c4 --- /dev/null +++ b/apps/api/src/posts/dto/query-posts.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class QueryPostsDto { + @ApiPropertyOptional({ example: 'React hooks' }) + @IsString() + @IsOptional() + search?: string; + + @ApiPropertyOptional({ example: 1, default: 1 }) + @Type(() => Number) + @IsInt() + @Min(1) + @IsOptional() + page?: number = 1; + + @ApiPropertyOptional({ example: 10, default: 10 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + @IsOptional() + limit?: number = 10; +} diff --git a/apps/api/src/posts/entities/comment.entity.ts b/apps/api/src/posts/entities/comment.entity.ts new file mode 100644 index 0000000..38184d9 --- /dev/null +++ b/apps/api/src/posts/entities/comment.entity.ts @@ -0,0 +1,27 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Post } from './post.entity'; + +@Entity('comments') +export class Comment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('text') + content: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE', eager: true }) + author: User; + + @ManyToOne(() => Post, (post) => post.comments, { onDelete: 'CASCADE' }) + post: Post; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/apps/api/src/posts/entities/like.entity.ts b/apps/api/src/posts/entities/like.entity.ts new file mode 100644 index 0000000..34d4f12 --- /dev/null +++ b/apps/api/src/posts/entities/like.entity.ts @@ -0,0 +1,25 @@ +import { + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Post } from './post.entity'; + +@Entity('likes') +@Unique(['user', 'post']) +export class Like { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @ManyToOne(() => Post, (post) => post.likes, { onDelete: 'CASCADE' }) + post: Post; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/apps/api/src/posts/entities/post.entity.ts b/apps/api/src/posts/entities/post.entity.ts new file mode 100644 index 0000000..5391625 --- /dev/null +++ b/apps/api/src/posts/entities/post.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Comment } from './comment.entity'; +import { Like } from './like.entity'; + +@Entity('posts') +export class Post { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column('text') + content: string; + + @Column({ type: 'varchar', nullable: true }) + thumbnail: string | null; + + @Column('simple-array', { nullable: true }) + tags: string[]; + + @ManyToOne(() => User, { onDelete: 'CASCADE', eager: false }) + author: User; + + @OneToMany(() => Comment, (comment) => comment.post) + comments: Comment[]; + + @OneToMany(() => Like, (like) => like.post) + likes: Like[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/apps/api/src/posts/posts.controller.spec.ts b/apps/api/src/posts/posts.controller.spec.ts new file mode 100644 index 0000000..05b8de6 --- /dev/null +++ b/apps/api/src/posts/posts.controller.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PostsController } from './posts.controller'; +import { PostsService } from './posts.service'; + +const mockUser = { id: 'user-1', name: 'Julio', email: 'julio@test.com' }; +const mockPost = { + id: 'post-1', + title: 'Post Teste', + content: 'Conteúdo', + thumbnail: null, + tags: ['React'], + author: mockUser, + likesCount: 5, + commentsCount: 2, + likedByMe: false, +}; + +const mockPostsService = { + findAll: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + addComment: jest.fn(), + toggleLike: jest.fn(), +}; + +describe('PostsController', () => { + let controller: PostsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PostsController], + providers: [{ provide: PostsService, useValue: mockPostsService }], + }).compile(); + + controller = module.get(PostsController); + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated posts', async () => { + const paginatedResult = { + data: [mockPost], + meta: { page: 1, limit: 10, total: 1, totalPages: 1 }, + }; + mockPostsService.findAll.mockResolvedValue(paginatedResult); + + const result = await controller.findAll({ page: 1, limit: 10 }, { user: undefined }); + + expect(mockPostsService.findAll).toHaveBeenCalledWith({ page: 1, limit: 10 }, undefined); + expect(result).toEqual(paginatedResult); + }); + + it('should pass userId from authenticated request', async () => { + mockPostsService.findAll.mockResolvedValue({ data: [], meta: {} }); + + await controller.findAll({ page: 1, limit: 10 }, { user: mockUser }); + + expect(mockPostsService.findAll).toHaveBeenCalledWith({ page: 1, limit: 10 }, mockUser.id); + }); + }); + + describe('findOne', () => { + it('should return a single post', async () => { + mockPostsService.findById.mockResolvedValue(mockPost); + + const result = await controller.findOne('post-1', { user: undefined }); + + expect(mockPostsService.findById).toHaveBeenCalledWith('post-1', undefined); + expect(result).toEqual(mockPost); + }); + }); + + describe('create', () => { + it('should create a post', async () => { + mockPostsService.create.mockResolvedValue(mockPost); + + const result = await controller.create( + { title: 'Novo Post', content: 'Conteúdo' }, + { user: mockUser }, + ); + + expect(mockPostsService.create).toHaveBeenCalledWith( + { title: 'Novo Post', content: 'Conteúdo' }, + mockUser.id, + ); + expect(result).toEqual(mockPost); + }); + }); + + describe('addComment', () => { + it('should add a comment to a post', async () => { + const mockComment = { id: 'c-1', content: 'Ótimo!', author: mockUser }; + mockPostsService.addComment.mockResolvedValue(mockComment); + + const result = await controller.addComment('post-1', { content: 'Ótimo!' }, { user: mockUser }); + + expect(mockPostsService.addComment).toHaveBeenCalledWith('post-1', { content: 'Ótimo!' }, mockUser.id); + expect(result).toEqual(mockComment); + }); + }); + + describe('toggleLike', () => { + it('should toggle like on a post', async () => { + mockPostsService.toggleLike.mockResolvedValue({ liked: true }); + + const result = await controller.toggleLike('post-1', { user: mockUser }); + + expect(mockPostsService.toggleLike).toHaveBeenCalledWith('post-1', mockUser.id); + expect(result).toEqual({ liked: true }); + }); + }); +}); diff --git a/apps/api/src/posts/posts.controller.ts b/apps/api/src/posts/posts.controller.ts new file mode 100644 index 0000000..dababc7 --- /dev/null +++ b/apps/api/src/posts/posts.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Request, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { OptionalJwtAuthGuard } from '../auth/guards/optional-jwt-auth.guard'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { CreatePostDto } from './dto/create-post.dto'; +import { QueryPostsDto } from './dto/query-posts.dto'; +import { PostsService } from './posts.service'; + +@ApiTags('posts') +@Controller('posts') +export class PostsController { + constructor(private readonly postsService: PostsService) {} + + @Get() + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Listar posts com busca e paginação' }) + @ApiResponse({ status: 200, description: 'Lista de posts paginada' }) + findAll(@Query() query: QueryPostsDto, @Request() req: { user?: { id: string } }) { + return this.postsService.findAll(query, req.user?.id); + } + + @Get(':id') + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Buscar post por ID' }) + @ApiResponse({ status: 200, description: 'Post encontrado' }) + @ApiResponse({ status: 404, description: 'Post não encontrado' }) + findOne(@Param('id') id: string, @Request() req: { user?: { id: string } }) { + return this.postsService.findById(id, req.user?.id); + } + + @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Criar novo post' }) + @ApiResponse({ status: 201, description: 'Post criado' }) + create(@Body() dto: CreatePostDto, @Request() req: { user: { id: string } }) { + return this.postsService.create(dto, req.user.id); + } + + @Post(':id/comments') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Adicionar comentário ao post' }) + @ApiResponse({ status: 201, description: 'Comentário adicionado' }) + addComment( + @Param('id') id: string, + @Body() dto: CreateCommentDto, + @Request() req: { user: { id: string } }, + ) { + return this.postsService.addComment(id, dto, req.user.id); + } + + @Post(':id/likes') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Curtir/descurtir post (toggle)' }) + @ApiResponse({ status: 200, description: 'Like alternado' }) + toggleLike(@Param('id') id: string, @Request() req: { user: { id: string } }) { + return this.postsService.toggleLike(id, req.user.id); + } +} diff --git a/apps/api/src/posts/posts.module.ts b/apps/api/src/posts/posts.module.ts new file mode 100644 index 0000000..020c2bf --- /dev/null +++ b/apps/api/src/posts/posts.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PassportModule } from '@nestjs/passport'; +import { Comment } from './entities/comment.entity'; +import { Like } from './entities/like.entity'; +import { Post } from './entities/post.entity'; +import { PostsController } from './posts.controller'; +import { PostsService } from './posts.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Post, Comment, Like]), + PassportModule, + ], + controllers: [PostsController], + providers: [PostsService], +}) +export class PostsModule {} diff --git a/apps/api/src/posts/posts.service.spec.ts b/apps/api/src/posts/posts.service.spec.ts new file mode 100644 index 0000000..190d839 --- /dev/null +++ b/apps/api/src/posts/posts.service.spec.ts @@ -0,0 +1,206 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Comment } from './entities/comment.entity'; +import { Like } from './entities/like.entity'; +import { Post } from './entities/post.entity'; +import { PostsService } from './posts.service'; + +const mockAuthor = { id: 'user-1', name: 'Julio Silva', email: 'julio@test.com' }; + +const mockPost: Partial = { + id: 'post-1', + title: 'Post de Teste', + content: 'Conteúdo do post de teste para verificar o serviço.', + thumbnail: 'https://example.com/img.png', + tags: ['React', 'TypeScript'], + author: mockAuthor as any, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockComment = { + id: 'comment-1', + content: 'Ótimo post!', + author: { id: 'user-2', name: 'Ana' }, + post: { id: 'post-1' }, + createdAt: new Date(), +}; + +const mockLike = { + id: 'like-1', + user: { id: 'user-2' }, + post: { id: 'post-1' }, + createdAt: new Date(), +}; + +const createQueryBuilderMock = (result: any) => ({ + leftJoin: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + loadRelationCountAndMap: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(result), + getManyAndCount: jest.fn().mockResolvedValue([[result], 1]), + getRawMany: jest.fn().mockResolvedValue([]), +}); + +describe('PostsService', () => { + let service: PostsService; + let postRepo: any; + let commentRepo: any; + let likeRepo: any; + + beforeEach(async () => { + postRepo = { + createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(mockPost)), + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + commentRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + likeRepo = { + createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(null)), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PostsService, + { provide: getRepositoryToken(Post), useValue: postRepo }, + { provide: getRepositoryToken(Comment), useValue: commentRepo }, + { provide: getRepositoryToken(Like), useValue: likeRepo }, + ], + }).compile(); + + service = module.get(PostsService); + }); + + describe('findAll', () => { + it('should return paginated posts', async () => { + const result = await service.findAll({ page: 1, limit: 10 }); + + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('meta'); + expect(result.meta.page).toBe(1); + expect(result.meta.limit).toBe(10); + }); + + it('should apply search filter when provided', async () => { + const qbMock = createQueryBuilderMock(mockPost); + postRepo.createQueryBuilder.mockReturnValue(qbMock); + + await service.findAll({ page: 1, limit: 10, search: 'React' }); + + expect(qbMock.where).toHaveBeenCalledWith( + 'post.title ILIKE :search OR post.content ILIKE :search', + { search: '%React%' }, + ); + }); + + it('should include likedByMe false when no userId provided', async () => { + const result = await service.findAll({ page: 1, limit: 10 }); + + expect(result.data[0]).toHaveProperty('likedByMe', false); + }); + }); + + describe('findById', () => { + it('should return a post by id', async () => { + const result = await service.findById('post-1'); + expect(result).toHaveProperty('id', 'post-1'); + }); + + it('should throw NotFoundException when post not found', async () => { + const qbMock = createQueryBuilderMock(null); + postRepo.createQueryBuilder.mockReturnValue(qbMock); + + await expect(service.findById('nonexistent')).rejects.toThrow(NotFoundException); + }); + + it('should include likedByMe true when user liked the post', async () => { + likeRepo.findOne.mockResolvedValue(mockLike); + + const result = await service.findById('post-1', 'user-2'); + expect(result.likedByMe).toBe(true); + }); + }); + + describe('create', () => { + it('should create and return a post', async () => { + postRepo.create.mockReturnValue(mockPost); + postRepo.save.mockResolvedValue(mockPost); + + const result = await service.create( + { title: 'Novo Post', content: 'Conteúdo', tags: ['React'] }, + 'user-1', + ); + + expect(postRepo.create).toHaveBeenCalled(); + expect(postRepo.save).toHaveBeenCalled(); + expect(result).toEqual(mockPost); + }); + }); + + describe('addComment', () => { + it('should add a comment to a post', async () => { + postRepo.findOneBy.mockResolvedValue(mockPost); + commentRepo.create.mockReturnValue(mockComment); + commentRepo.save.mockResolvedValue(mockComment); + + const result = await service.addComment('post-1', { content: 'Ótimo!' }, 'user-2'); + + expect(commentRepo.save).toHaveBeenCalled(); + expect(result).toEqual(mockComment); + }); + + it('should throw NotFoundException when post not found', async () => { + postRepo.findOneBy.mockResolvedValue(null); + + await expect( + service.addComment('nonexistent', { content: 'Test' }, 'user-1'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('toggleLike', () => { + it('should create like when not already liked', async () => { + postRepo.findOneBy.mockResolvedValue(mockPost); + likeRepo.findOne.mockResolvedValue(null); + likeRepo.create.mockReturnValue(mockLike); + likeRepo.save.mockResolvedValue(mockLike); + + const result = await service.toggleLike('post-1', 'user-2'); + expect(result).toEqual({ liked: true }); + }); + + it('should remove like when already liked', async () => { + postRepo.findOneBy.mockResolvedValue(mockPost); + likeRepo.findOne.mockResolvedValue(mockLike); + + const result = await service.toggleLike('post-1', 'user-2'); + expect(likeRepo.remove).toHaveBeenCalledWith(mockLike); + expect(result).toEqual({ liked: false }); + }); + + it('should throw NotFoundException when post not found', async () => { + postRepo.findOneBy.mockResolvedValue(null); + + await expect(service.toggleLike('nonexistent', 'user-1')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/posts/posts.service.ts b/apps/api/src/posts/posts.service.ts new file mode 100644 index 0000000..10cfb32 --- /dev/null +++ b/apps/api/src/posts/posts.service.ts @@ -0,0 +1,133 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { CreatePostDto } from './dto/create-post.dto'; +import { QueryPostsDto } from './dto/query-posts.dto'; +import { Comment } from './entities/comment.entity'; +import { Like } from './entities/like.entity'; +import { Post } from './entities/post.entity'; + +@Injectable() +export class PostsService { + constructor( + @InjectRepository(Post) + private readonly postsRepository: Repository, + @InjectRepository(Comment) + private readonly commentsRepository: Repository, + @InjectRepository(Like) + private readonly likesRepository: Repository, + ) {} + + async findAll(query: QueryPostsDto, userId?: string) { + const page = query.page ?? 1; + const limit = query.limit ?? 10; + const skip = (page - 1) * limit; + + const qb = this.postsRepository + .createQueryBuilder('post') + .leftJoin('post.author', 'author') + .addSelect(['author.id', 'author.name', 'author.email']) + .loadRelationCountAndMap('post.likesCount', 'post.likes') + .loadRelationCountAndMap('post.commentsCount', 'post.comments') + .orderBy('post.createdAt', 'DESC') + .skip(skip) + .take(limit); + + if (query.search) { + qb.where( + 'post.title ILIKE :search OR post.content ILIKE :search', + { search: `%${query.search}%` }, + ); + } + + const [posts, total] = await qb.getManyAndCount(); + + let likedPostIds: Set = new Set(); + if (userId) { + const likes = await this.likesRepository + .createQueryBuilder('like') + .innerJoin('like.post', 'post') + .where('like.userId = :userId', { userId }) + .select('post.id') + .getRawMany(); + likedPostIds = new Set(likes.map((l) => l.post_id)); + } + + const data = posts.map((post) => ({ + ...post, + likedByMe: likedPostIds.has(post.id), + })); + + return { + data, + meta: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; + } + + async findById(id: string, userId?: string) { + const post = await this.postsRepository + .createQueryBuilder('post') + .leftJoin('post.author', 'author') + .addSelect(['author.id', 'author.name', 'author.email']) + .leftJoinAndSelect('post.comments', 'comment') + .leftJoin('comment.author', 'commentAuthor') + .addSelect(['commentAuthor.id', 'commentAuthor.name']) + .loadRelationCountAndMap('post.likesCount', 'post.likes') + .where('post.id = :id', { id }) + .getOne(); + + if (!post) throw new NotFoundException('Post não encontrado'); + + let likedByMe = false; + if (userId) { + const like = await this.likesRepository.findOne({ + where: { user: { id: userId }, post: { id } }, + }); + likedByMe = !!like; + } + + return { ...post, likedByMe }; + } + + async create(dto: CreatePostDto, authorId: string) { + const post = this.postsRepository.create({ + ...dto, + author: { id: authorId }, + }); + return this.postsRepository.save(post); + } + + async addComment(postId: string, dto: CreateCommentDto, authorId: string) { + const post = await this.postsRepository.findOneBy({ id: postId }); + if (!post) throw new NotFoundException('Post não encontrado'); + + const comment = this.commentsRepository.create({ + content: dto.content, + author: { id: authorId }, + post: { id: postId }, + }); + return this.commentsRepository.save(comment); + } + + async toggleLike(postId: string, userId: string) { + const post = await this.postsRepository.findOneBy({ id: postId }); + if (!post) throw new NotFoundException('Post não encontrado'); + + const existing = await this.likesRepository.findOne({ + where: { user: { id: userId }, post: { id: postId } }, + }); + + if (existing) { + await this.likesRepository.remove(existing); + return { liked: false }; + } + + const like = this.likesRepository.create({ + user: { id: userId }, + post: { id: postId }, + }); + await this.likesRepository.save(like); + return { liked: true }; + } +} diff --git a/apps/api/src/seed.ts b/apps/api/src/seed.ts new file mode 100644 index 0000000..f26bb56 --- /dev/null +++ b/apps/api/src/seed.ts @@ -0,0 +1,193 @@ +import { NestFactory } from '@nestjs/core'; +import * as bcrypt from 'bcrypt'; +import { AppModule } from './app.module'; +import { Comment } from './posts/entities/comment.entity'; +import { Like } from './posts/entities/like.entity'; +import { Post } from './posts/entities/post.entity'; +import { User } from './users/entities/user.entity'; +import { DataSource } from 'typeorm'; + +async function seed() { + const app = await NestFactory.createApplicationContext(AppModule); + const dataSource = app.get(DataSource); + + const userRepo = dataSource.getRepository(User); + const postRepo = dataSource.getRepository(Post); + const commentRepo = dataSource.getRepository(Comment); + const likeRepo = dataSource.getRepository(Like); + + // Limpa dados existentes do seed (na ordem correta por FK) + await dataSource.query('TRUNCATE TABLE likes, comments, posts CASCADE'); + + // Remove apenas os usuarios seed para re-criar + await userRepo.delete({ email: 'julio@codeconnect.dev' }); + await userRepo.delete({ email: 'ana@codeconnect.dev' }); + await userRepo.delete({ email: 'marcos@codeconnect.dev' }); + + // Cria usuários seed + const hash = (pw: string) => bcrypt.hash(pw, 10); + + const julio = userRepo.create({ + name: 'Julio Silva', + email: 'julio@codeconnect.dev', + password: await hash('senha123'), + }); + const ana = userRepo.create({ + name: 'Ana Costa', + email: 'ana@codeconnect.dev', + password: await hash('senha123'), + }); + const marcos = userRepo.create({ + name: 'Marcos Oliveira', + email: 'marcos@codeconnect.dev', + password: await hash('senha123'), + }); + + await userRepo.save([julio, ana, marcos]); + + // Dados dos posts mockados + const postsData = [ + { + title: 'Construindo um Design System com React e TypeScript', + content: + 'Neste post vou mostrar como criar um design system escalável usando React, TypeScript e Storybook. Vamos cobrir tokens de design, componentes atômicos, e como documentar tudo de forma eficiente para o time.\n\nUm design system bem construído economiza horas de desenvolvimento e garante consistência visual em toda a aplicação. Vamos começar pelos fundamentos: cores, tipografia e espaçamentos.', + thumbnail: 'https://picsum.photos/seed/react-ds/800/450', + tags: ['React', 'TypeScript', 'Design System', 'Front-end'], + author: julio, + }, + { + title: 'NestJS: Arquitetura Modular para APIs Escaláveis', + content: + 'NestJS é um framework Node.js que traz conceitos do Angular para o backend. Neste post vou mostrar como organizar módulos, controllers e services de forma escalável.\n\nVamos criar uma API REST completa com autenticação JWT, validação de dados e documentação Swagger. O NestJS facilita muito a criação de código bem estruturado e testável.', + thumbnail: 'https://picsum.photos/seed/nestjs/800/450', + tags: ['NestJS', 'Node.js', 'TypeScript', 'Back-end'], + author: ana, + }, + { + title: 'Acessibilidade na Web: Por onde começar?', + content: + 'Acessibilidade não é um recurso extra — é uma necessidade. Neste post vou abordar os conceitos fundamentais de WCAG, ARIA e como implementar componentes acessíveis do zero.\n\nVeremos como testar acessibilidade com screen readers, ferramentas de auditoria e como integrar verificações automatizadas no CI/CD.', + thumbnail: 'https://picsum.photos/seed/a11y/800/450', + tags: ['Acessibilidade', 'Front-end', 'HTML', 'CSS'], + author: marcos, + }, + { + title: 'React Query: Gerenciamento de Estado Servidor', + content: + 'React Query revolucionou a forma como lidamos com dados do servidor no React. Neste post vou mostrar como usar queries, mutations e cache de forma eficiente.\n\nVamos substituir useEffect + useState pelo pattern declarativo do React Query, reduzindo drasticamente o boilerplate e melhorando a experiência do usuário.', + thumbnail: null, + tags: ['React', 'React Query', 'State Management'], + author: julio, + }, + { + title: 'Tailwind CSS v4: O que mudou?', + content: + 'O Tailwind CSS v4 trouxe mudanças significativas na configuração e performance. Vamos explorar a nova sintaxe @theme no CSS, a eliminação do tailwind.config.js e as melhorias de velocidade.\n\nA nova abordagem CSS-first facilita muito a integração com design tokens e torna o código mais próximo do CSS nativo.', + thumbnail: 'https://picsum.photos/seed/tailwind/800/450', + tags: ['Tailwind CSS', 'CSS', 'Front-end'], + author: ana, + }, + { + title: 'PostgreSQL Full Text Search: Do Básico ao Avançado', + content: + 'O PostgreSQL possui capacidades nativas de busca full-text que muitas vezes substituem a necessidade de ferramentas como Elasticsearch. Vamos explorar tsvector, tsquery e índices GIN.\n\nNeste post vou mostrar como implementar busca em português com stemming correto e ranking de relevância.', + thumbnail: null, + tags: ['PostgreSQL', 'Back-end', 'Database', 'Performance'], + author: marcos, + }, + { + title: 'Hooks Customizados: Abstraindo Lógica no React', + content: + 'Hooks customizados são uma das features mais poderosas do React. Neste post vou mostrar como criar hooks reutilizáveis para casos comuns: formulários, requisições, debounce, e muito mais.\n\nVamos seguir boas práticas de nomenclatura, testes e documentação para garantir que os hooks sejam fáceis de usar e manter.', + thumbnail: 'https://picsum.photos/seed/hooks/800/450', + tags: ['React', 'Hooks', 'Front-end'], + author: julio, + }, + { + title: 'Docker para Desenvolvedores Front-end', + content: + 'Docker não é só para o time de backend! Neste post vou mostrar como containerizar aplicações React, configurar ambientes de desenvolvimento consistentes e usar Docker Compose para orquestrar serviços.\n\nVamos criar um setup que funciona igual em qualquer máquina, eliminando o clássico "funciona na minha máquina".', + thumbnail: null, + tags: ['Docker', 'DevOps', 'Front-end'], + author: ana, + }, + { + title: 'Testing Library: Testes que Importam', + content: + 'Testing Library mudou a forma como testamos componentes React. A filosofia é clara: teste comportamento, não implementação. Neste post vou mostrar como escrever testes que realmente agregam valor.\n\nVamos cobrir queries, eventos, async testing e como integrar com Vitest para um setup moderno e rápido.', + thumbnail: 'https://picsum.photos/seed/testing/800/450', + tags: ['Testing', 'React', 'Vitest', 'Front-end'], + author: marcos, + }, + { + title: 'GraphQL vs REST: Quando usar cada um?', + content: + 'Essa é uma das perguntas mais frequentes em entrevistas e discussões técnicas. Neste post vou comparar GraphQL e REST de forma honesta, sem hype.\n\nVamos analisar casos de uso reais, performance, complexidade de implementação e quando cada abordagem faz mais sentido para o seu projeto.', + thumbnail: null, + tags: ['GraphQL', 'REST', 'Back-end', 'API'], + author: julio, + }, + { + title: 'Monorepos com pnpm: Configuração e Boas Práticas', + content: + 'Monorepos são uma ótima forma de organizar projetos que compartilham código entre múltiplos apps. O pnpm tem suporte nativo a workspaces e é extremamente eficiente no gerenciamento de dependências.\n\nVamos criar um monorepo do zero com pnpm, configurar scripts compartilhados e resolver os problemas comuns de hoisting.', + thumbnail: 'https://picsum.photos/seed/monorepo/800/450', + tags: ['pnpm', 'Monorepo', 'DevOps'], + author: ana, + }, + { + title: 'Animações com Framer Motion no React', + content: + 'Framer Motion é a biblioteca de animações mais completa para React. Neste post vou mostrar como criar animações fluidas, transições de página e gestos interativos.\n\nVamos explorar as APIs declarativas do Framer Motion e como integrar animações sem comprometer a acessibilidade e performance.', + thumbnail: null, + tags: ['React', 'Framer Motion', 'Animações', 'Front-end'], + author: marcos, + }, + ]; + + const posts = await postRepo.save(postsData.map((d) => postRepo.create(d))); + + // Adiciona comentários nos primeiros posts + const comments = [ + { content: 'Excelente post! Aprendi muito com esse conteúdo.', author: ana, post: posts[0] }, + { content: 'Muito bem explicado. Vou aplicar isso no meu projeto.', author: marcos, post: posts[0] }, + { content: 'Parabéns pela clareza na explicação!', author: julio, post: posts[1] }, + { content: 'Tinha dúvidas sobre isso e você esclareceu tudo.', author: ana, post: posts[2] }, + { content: 'Conteúdo de qualidade. Continua postando!', author: marcos, post: posts[3] }, + { content: 'Que post incrível! Salvei para ler com calma.', author: julio, post: posts[4] }, + ]; + + await commentRepo.save(comments.map((c) => commentRepo.create(c))); + + // Adiciona likes em alguns posts + const likePairs = [ + { user: ana, post: posts[0] }, + { user: marcos, post: posts[0] }, + { user: julio, post: posts[1] }, + { user: marcos, post: posts[1] }, + { user: ana, post: posts[1] }, + { user: julio, post: posts[2] }, + { user: ana, post: posts[3] }, + { user: marcos, post: posts[4] }, + { user: julio, post: posts[4] }, + { user: ana, post: posts[5] }, + { user: julio, post: posts[6] }, + { user: marcos, post: posts[7] }, + { user: ana, post: posts[8] }, + ]; + + await likeRepo.save(likePairs.map((l) => likeRepo.create(l))); + + console.log('✅ Seed concluído com sucesso!'); + console.log(` ${posts.length} posts criados`); + console.log(` ${comments.length} comentários criados`); + console.log(` ${likePairs.length} likes criados`); + console.log(' Usuários: julio@codeconnect.dev, ana@codeconnect.dev, marcos@codeconnect.dev (senha: senha123)'); + + await app.close(); +} + +seed().catch((err) => { + console.error('❌ Erro no seed:', err); + process.exit(1); +}); diff --git a/apps/api/src/users/dto/create-user.dto.ts b/apps/api/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..53320e1 --- /dev/null +++ b/apps/api/src/users/dto/create-user.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class CreateUserDto { + @ApiProperty({ example: 'John Doe' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ example: 'john@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'strongP@ss1', minLength: 6 }) + @IsString() + @MinLength(6) + password: string; +} diff --git a/apps/api/src/users/entities/user.entity.ts b/apps/api/src/users/entities/user.entity.ts new file mode 100644 index 0000000..1947f8b --- /dev/null +++ b/apps/api/src/users/entities/user.entity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ unique: true }) + email: string; + + @Column() + password: string; +} diff --git a/apps/api/src/users/users.controller.spec.ts b/apps/api/src/users/users.controller.spec.ts new file mode 100644 index 0000000..6678b79 --- /dev/null +++ b/apps/api/src/users/users.controller.spec.ts @@ -0,0 +1,47 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +describe('UsersController', () => { + let controller: UsersController; + let usersService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: { + create: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(UsersController); + usersService = module.get(UsersService); + }); + + describe('create', () => { + it('should call usersService.create and return the result', async () => { + const dto = { + name: 'John Doe', + email: 'john@example.com', + password: 'secret123', + }; + const expected = { + id: 'uuid-1', + name: 'John Doe', + email: 'john@example.com', + }; + usersService.create.mockResolvedValue(expected); + + const result = await controller.create(dto); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersService.create).toHaveBeenCalledWith(dto); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..7ef786d --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UsersService } from './users.service'; + +@ApiTags('users') +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Register a new user' }) + @ApiResponse({ status: 201, description: 'User created successfully' }) + @ApiResponse({ status: 409, description: 'Email already in use' }) + async create(@Body() createUserDto: CreateUserDto) { + return this.usersService.create(createUserDto); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..12a46ed --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from './entities/user.entity'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.spec.ts b/apps/api/src/users/users.service.spec.ts new file mode 100644 index 0000000..be34708 --- /dev/null +++ b/apps/api/src/users/users.service.spec.ts @@ -0,0 +1,133 @@ +import { ConflictException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import * as bcrypt from 'bcrypt'; +import { Repository } from 'typeorm'; +import { User } from './entities/user.entity'; +import { UsersService } from './users.service'; + +const mockUser: User = { + id: 'uuid-1', + name: 'John Doe', + email: 'john@example.com', + password: 'hashed-password', +}; + +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked, 'findOneBy' | 'create' | 'save'>>; + + beforeEach(async () => { + repo = { + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: repo, + }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + describe('create', () => { + it('should create a user and return it without password', async () => { + repo.findOneBy.mockResolvedValue(null); + repo.create.mockReturnValue(mockUser); + repo.save.mockResolvedValue(mockUser); + + const result = await service.create({ + name: 'John Doe', + email: 'john@example.com', + password: 'secret123', + }); + + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('name', 'John Doe'); + expect(result).toHaveProperty('email', 'john@example.com'); + expect(result).not.toHaveProperty('password'); + }); + + it('should hash the password before saving', async () => { + repo.findOneBy.mockResolvedValue(null); + repo.create.mockImplementation((data) => data as User); + repo.save.mockImplementation(async (data) => ({ ...data, id: 'uuid-1' }) as User); + + await service.create({ + name: 'John Doe', + email: 'john@example.com', + password: 'secret123', + }); + + const savedData = repo.save.mock.calls[0][0] as User; + const isMatch = await bcrypt.compare('secret123', savedData.password); + expect(isMatch).toBe(true); + }); + + it('should generate unique IDs for each user', async () => { + repo.findOneBy.mockResolvedValue(null); + repo.create.mockImplementation((data) => data as User); + repo.save + .mockResolvedValueOnce({ ...mockUser, id: 'uuid-1', email: 'alice@example.com' }) + .mockResolvedValueOnce({ ...mockUser, id: 'uuid-2', email: 'bob@example.com' }); + + const u1 = await service.create({ name: 'Alice', email: 'alice@example.com', password: 'pass123' }); + const u2 = await service.create({ name: 'Bob', email: 'bob@example.com', password: 'pass456' }); + + expect(u1.id).not.toEqual(u2.id); + }); + + it('should throw ConflictException for duplicate email', async () => { + repo.findOneBy.mockResolvedValue(mockUser); + + await expect( + service.create({ + name: 'John Clone', + email: 'john@example.com', + password: 'other123', + }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('findByEmail', () => { + it('should return user when found', async () => { + repo.findOneBy.mockResolvedValue(mockUser); + + const user = await service.findByEmail('john@example.com'); + expect(user).toBeDefined(); + expect(user?.email).toBe('john@example.com'); + }); + + it('should return null when not found', async () => { + repo.findOneBy.mockResolvedValue(null); + + const user = await service.findByEmail('nobody@example.com'); + expect(user).toBeNull(); + }); + }); + + describe('findById', () => { + it('should return user when found', async () => { + repo.findOneBy.mockResolvedValue(mockUser); + + const user = await service.findById('uuid-1'); + expect(user).toBeDefined(); + expect(user?.id).toBe('uuid-1'); + }); + + it('should return null when not found', async () => { + repo.findOneBy.mockResolvedValue(null); + + const user = await service.findById('non-existent-id'); + expect(user).toBeNull(); + }); + }); +}); diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..cab734c --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,42 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import * as bcrypt from 'bcrypt'; +import { Repository } from 'typeorm'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from './entities/user.entity'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + async create(dto: CreateUserDto): Promise> { + const exists = await this.usersRepository.findOneBy({ email: dto.email }); + if (exists) { + throw new ConflictException('Email already in use'); + } + + const hashedPassword = await bcrypt.hash(dto.password, 10); + const user = this.usersRepository.create({ + name: dto.name, + email: dto.email, + password: hashedPassword, + }); + + const saved = await this.usersRepository.save(user); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...result } = saved; + return result; + } + + async findByEmail(email: string): Promise { + return this.usersRepository.findOneBy({ email }); + } + + async findById(id: string): Promise { + return this.usersRepository.findOneBy({ id }); + } +} diff --git a/apps/web/index.html b/apps/web/index.html index 29d72da..8d6827f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,10 +1,14 @@ - + Code Connect + + + +
diff --git a/apps/web/package.json b/apps/web/package.json index 39c1c49..1f5ee23 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "test": "vitest run" }, "dependencies": { + "axios": "^1.13.6", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.2" @@ -21,6 +22,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/jest-axe": "^3.5.9", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -29,6 +31,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jest-axe": "^10.0.0", "jsdom": "^29.0.1", "tailwindcss": "^4.2.2", "typescript": "~5.9.3", diff --git a/apps/web/public/banner-cadastro.png b/apps/web/public/banner-cadastro.png new file mode 100644 index 0000000..9812d93 Binary files /dev/null and b/apps/web/public/banner-cadastro.png differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a3942bb..e84932a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,36 @@ -import { Routes, Route, Navigate } from 'react-router-dom' +import { Navigate, Route, Routes } from 'react-router-dom' +import { useAuth } from './contexts/AuthContext' +import { FeedPage } from './components/pages/FeedPage/FeedPage' import { LoginPage } from './components/pages/LoginPage/LoginPage' +import { PublishPage } from './components/pages/PublishPage/PublishPage' +import { SignupPage } from './components/pages/SignupPage/SignupPage' +import { FeedLayout } from './components/templates/FeedLayout/FeedLayout' +import { PrivateRoute } from './components/templates/PrivateRoute/PrivateRoute' function App() { + const { isAuthenticated, loading } = useAuth() + + if (loading) return null + return ( - } /> - } /> + : } /> + : } /> + + {/* Layout compartilhado entre feed e detalhes */} + }> + } /> + + + + } + /> + + + } /> ) } diff --git a/apps/web/src/components/atoms/Button/Button.tsx b/apps/web/src/components/atoms/Button/Button.tsx index 7f94aae..f10da40 100644 --- a/apps/web/src/components/atoms/Button/Button.tsx +++ b/apps/web/src/components/atoms/Button/Button.tsx @@ -10,7 +10,7 @@ interface ButtonProps extends ButtonHTMLAttributes { export function Button({ children, variant = 'primary', className = '', ...props }: ButtonProps) { const variants: Record = { primary: - 'w-full bg-[#4ADE80] hover:bg-green-500 text-black font-semibold rounded-lg px-4 py-3 transition-colors flex items-center justify-center gap-2', + 'w-full bg-verde-destaque hover:bg-verde-destaque/80 text-verde-petroleo font-semibold rounded-lg px-4 py-3 transition-colors flex items-center justify-center gap-2', social: 'flex flex-col items-center gap-1 bg-transparent border border-gray-600 hover:border-gray-400 rounded-lg p-3 transition-colors text-gray-300 text-xs', } diff --git a/apps/web/src/components/atoms/Checkbox/Checkbox.tsx b/apps/web/src/components/atoms/Checkbox/Checkbox.tsx index a286df4..c470ad1 100644 --- a/apps/web/src/components/atoms/Checkbox/Checkbox.tsx +++ b/apps/web/src/components/atoms/Checkbox/Checkbox.tsx @@ -13,7 +13,7 @@ export function Checkbox({ id, label, checked, onChange }: CheckboxProps) { type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} - className="w-4 h-4 rounded border-gray-600 bg-[#2a2b35] accent-green-400 cursor-pointer" + className="w-4 h-4 rounded border-gray-600 bg-cinza-medio accent-verde-destaque cursor-pointer" />