diff --git a/backend-api/API.md b/backend-api/API.md index eb4061e..6f4f5f6 100644 --- a/backend-api/API.md +++ b/backend-api/API.md @@ -135,22 +135,75 @@ Content-Type: application/json --- -### 2. Tarefas (Tasks) - Em Desenvolvimento +### 2. Tarefas (Tasks) -#### 2.1 Listar Tarefas +#### 2.1 Criar Tarefa +```http +POST /tasks +Authorization: Bearer {token} +Content-Type: application/json + +{ + "title": "Fazer compras", + "description": "Ir ao supermercado", + "dueDate": "2025-12-25T00:00:00Z", + "category": "compras", + "priority": "high", + "completed": false +} +``` + +**Response (201 Created):** +```json +{ + "success": true, + "message": "Tarefa criada com sucesso", + "data": { + "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "title": "Fazer compras", + "description": "Ir ao supermercado", + "completed": false, + "due_date": "2025-12-25T00:00:00Z", + "category": "compras", + "priority": "high", + "created_at": "2025-12-01T10:00:00Z", + "updated_at": "2025-12-01T10:00:00Z" + } +} +``` + +**Validações:** +- `title`: Obrigatório, mínimo 3 caracteres, máximo 255 +- `description`: Opcional, máximo 2000 caracteres +- `priority`: low | medium (default) | high +- `dueDate`, `category`: Opcionais + +**Erros:** +- `400 Bad Request` - Validação falhou +- `401 Unauthorized` - Token inválido + +--- + +#### 2.2 Listar Tarefas ```http GET /tasks Authorization: Bearer {token} # Query params opcionais: -?status=all|completed|pending -?sort=created_at|updated_at +?completed=true|false +?category=compras +?priority=low|medium|high +?sortBy=created_at|due_date|priority ?order=asc|desc ``` **Response (200 OK):** ```json { + "success": true, + "message": "Tarefas recuperadas com sucesso", + "count": 5, "data": [ { "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", @@ -158,18 +211,28 @@ Authorization: Bearer {token} "title": "Fazer compras", "description": "Ir ao supermercado", "completed": false, + "due_date": "2025-12-25T00:00:00Z", + "category": "compras", + "priority": "high", "created_at": "2025-12-01T10:00:00Z", "updated_at": "2025-12-01T10:00:00Z" } - ], - "total": 1, - "page": 1 + ] } ``` +**Query Exemplos:** +- `GET /tasks?completed=false` - Tarefas pendentes +- `GET /tasks?priority=high&sortBy=due_date` - Prioridade alta, ordenadas por vencimento +- `GET /tasks?category=trabalho&order=asc` - Categoria trabalho, ordem ascendente + +**Erros:** +- `400 Bad Request` - Query inválida +- `401 Unauthorized` - Token inválido + --- -#### 2.2 Obter Tarefa Específica +#### 2.3 Obter Tarefa Específica ```http GET /tasks/:id Authorization: Bearer {token} @@ -178,49 +241,25 @@ Authorization: Bearer {token} **Response (200 OK):** ```json { - "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", - "user_id": "550e8400-e29b-41d4-a716-446655440000", - "title": "Fazer compras", - "description": "Ir ao supermercado", - "completed": false, - "created_at": "2025-12-01T10:00:00Z", - "updated_at": "2025-12-01T10:00:00Z" + "success": true, + "message": "Tarefa recuperada com sucesso", + "data": { + "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "title": "Fazer compras", + "description": "Ir ao supermercado", + "completed": false, + "due_date": "2025-12-25T00:00:00Z", + "category": "compras", + "priority": "high", + "created_at": "2025-12-01T10:00:00Z", + "updated_at": "2025-12-01T10:00:00Z" + } } ``` **Erros:** -- `404 Not Found` - Tarefa não encontrada -- `401 Unauthorized` - Token inválido - ---- - -#### 2.3 Criar Tarefa -```http -POST /tasks -Authorization: Bearer {token} -Content-Type: application/json - -{ - "title": "Fazer compras", - "description": "Ir ao supermercado" -} -``` - -**Response (201 Created):** -```json -{ - "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", - "user_id": "550e8400-e29b-41d4-a716-446655440000", - "title": "Fazer compras", - "description": "Ir ao supermercado", - "completed": false, - "created_at": "2025-12-01T10:00:00Z", - "updated_at": "2025-12-01T10:00:00Z" -} -``` - -**Erros:** -- `400 Bad Request` - Título obrigatório +- `404 Not Found` - Tarefa não encontrada ou não pertence ao usuário - `401 Unauthorized` - Token inválido --- @@ -234,23 +273,35 @@ Content-Type: application/json { "title": "Fazer compras (atualizado)", "description": "Ir ao supermercado e padaria", - "completed": true + "completed": true, + "dueDate": "2025-12-20T00:00:00Z", + "priority": "medium" } ``` **Response (200 OK):** ```json { - "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", - "user_id": "550e8400-e29b-41d4-a716-446655440000", - "title": "Fazer compras (atualizado)", - "description": "Ir ao supermercado e padaria", - "completed": true, - "created_at": "2025-12-01T10:00:00Z", - "updated_at": "2025-12-01T10:30:00Z" + "success": true, + "message": "Tarefa atualizada com sucesso", + "data": { + "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "title": "Fazer compras (atualizado)", + "description": "Ir ao supermercado e padaria", + "completed": true, + "due_date": "2025-12-20T00:00:00Z", + "category": "compras", + "priority": "medium", + "created_at": "2025-12-01T10:00:00Z", + "updated_at": "2025-12-01T10:30:00Z" + } } ``` +**Campos opcionais:** +- Todos os campos de CreateTaskDto são opcionais em PATCH + **Erros:** - `404 Not Found` - Tarefa não encontrada - `400 Bad Request` - Dados inválidos @@ -267,6 +318,7 @@ Authorization: Bearer {token} **Response (200 OK):** ```json { + "success": true, "message": "Tarefa deletada com sucesso" } ``` @@ -277,6 +329,31 @@ Authorization: Bearer {token} --- +#### 2.6 Obter Estatísticas +```http +GET /tasks/stats +Authorization: Bearer {token} +``` + +**Response (200 OK):** +```json +{ + "success": true, + "message": "Estatísticas recuperadas com sucesso", + "data": { + "total": 10, + "completed": 6, + "pending": 4, + "completionPercentage": 60 + } +} +``` + +**Erros:** +- `401 Unauthorized` - Token inválido + +--- + ## 🔄 Real-time (WebSocket) ### Conectar ao Realtime diff --git a/backend-api/src/app.module.ts b/backend-api/src/app.module.ts index e6c3397..b1b88c7 100644 --- a/backend-api/src/app.module.ts +++ b/backend-api/src/app.module.ts @@ -10,6 +10,7 @@ import jwtConfig from './config/jwt.config'; import { SupabaseService } from './config/supabase.service'; import { JwtStrategy } from './auth/strategies/jwt.strategy'; import { AuthModule } from './auth/auth.module'; +import { TasksModule } from './tasks/tasks.module'; import * as Joi from 'joi'; @Module({ @@ -40,6 +41,7 @@ import * as Joi from 'joi'; }), }), AuthModule, + TasksModule, ], controllers: [AppController], providers: [AppService, SupabaseService, JwtStrategy], diff --git a/backend-api/src/tasks/dto/create-task.dto.ts b/backend-api/src/tasks/dto/create-task.dto.ts new file mode 100644 index 0000000..fd35b23 --- /dev/null +++ b/backend-api/src/tasks/dto/create-task.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsBoolean, IsOptional, MaxLength, MinLength } from 'class-validator'; + +export class CreateTaskDto { + @IsString({ message: 'Título deve ser uma string' }) + @MinLength(3, { message: 'Título deve ter no mínimo 3 caracteres' }) + @MaxLength(255, { message: 'Título pode ter no máximo 255 caracteres' }) + title: string; + + @IsOptional() + @IsString({ message: 'Descrição deve ser uma string' }) + @MaxLength(2000, { message: 'Descrição pode ter no máximo 2000 caracteres' }) + description?: string; + + @IsOptional() + @IsBoolean({ message: 'Concluído deve ser um booleano' }) + completed?: boolean = false; + + @IsOptional() + @IsString({ message: 'Data de vencimento deve ser uma string ISO' }) + dueDate?: string; + + @IsOptional() + @IsString({ message: 'Categoria deve ser uma string' }) + @MaxLength(50, { message: 'Categoria pode ter no máximo 50 caracteres' }) + category?: string; + + @IsOptional() + @IsString({ message: 'Prioridade deve ser uma string' }) + priority?: 'low' | 'medium' | 'high' = 'medium'; +} diff --git a/backend-api/src/tasks/dto/update-task.dto.ts b/backend-api/src/tasks/dto/update-task.dto.ts new file mode 100644 index 0000000..75dc1fc --- /dev/null +++ b/backend-api/src/tasks/dto/update-task.dto.ts @@ -0,0 +1,31 @@ +import { IsString, IsBoolean, IsOptional, MaxLength, MinLength } from 'class-validator'; + +export class UpdateTaskDto { + @IsOptional() + @IsString({ message: 'Título deve ser uma string' }) + @MinLength(3, { message: 'Título deve ter no mínimo 3 caracteres' }) + @MaxLength(255, { message: 'Título pode ter no máximo 255 caracteres' }) + title?: string; + + @IsOptional() + @IsString({ message: 'Descrição deve ser uma string' }) + @MaxLength(2000, { message: 'Descrição pode ter no máximo 2000 caracteres' }) + description?: string; + + @IsOptional() + @IsBoolean({ message: 'Concluído deve ser um booleano' }) + completed?: boolean; + + @IsOptional() + @IsString({ message: 'Data de vencimento deve ser uma string ISO' }) + dueDate?: string; + + @IsOptional() + @IsString({ message: 'Categoria deve ser uma string' }) + @MaxLength(50, { message: 'Categoria pode ter no máximo 50 caracteres' }) + category?: string; + + @IsOptional() + @IsString({ message: 'Prioridade deve ser uma string' }) + priority?: 'low' | 'medium' | 'high'; +} diff --git a/backend-api/src/tasks/tasks.controller.ts b/backend-api/src/tasks/tasks.controller.ts new file mode 100644 index 0000000..fb9798d --- /dev/null +++ b/backend-api/src/tasks/tasks.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, +} from '@nestjs/common'; +import { TasksService } from './tasks.service'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; + +@Controller('tasks') +@UseGuards(JwtAuthGuard) +export class TasksController { + constructor(private readonly tasksService: TasksService) {} + + @Post() + create( + @CurrentUser() userId: string, + @Body() createTaskDto: CreateTaskDto, + ) { + return this.tasksService.create(userId, createTaskDto); + } + + @Get() + findAll( + @CurrentUser() userId: string, + @Query('completed') completed?: string, + @Query('category') category?: string, + @Query('priority') priority?: string, + @Query('sortBy') sortBy?: 'created_at' | 'due_date' | 'priority', + @Query('order') order?: 'asc' | 'desc', + ) { + const filters = { + completed: completed === 'true' ? true : completed === 'false' ? false : undefined, + category, + priority, + sortBy, + order, + }; + + return this.tasksService.findAll(userId, filters); + } + + @Get('stats') + getStats(@CurrentUser() userId: string) { + return this.tasksService.getStats(userId); + } + + @Get(':id') + findOne( + @Param('id') id: string, + @CurrentUser() userId: string, + ) { + return this.tasksService.findOne(id, userId); + } + + @Patch(':id') + update( + @Param('id') id: string, + @CurrentUser() userId: string, + @Body() updateTaskDto: UpdateTaskDto, + ) { + return this.tasksService.update(id, userId, updateTaskDto); + } + + @Delete(':id') + remove( + @Param('id') id: string, + @CurrentUser() userId: string, + ) { + return this.tasksService.remove(id, userId); + } +} diff --git a/backend-api/src/tasks/tasks.module.ts b/backend-api/src/tasks/tasks.module.ts new file mode 100644 index 0000000..50b98a2 --- /dev/null +++ b/backend-api/src/tasks/tasks.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TasksService } from './tasks.service'; +import { TasksController } from './tasks.controller'; +import { SupabaseService } from '../config/supabase.service'; + +@Module({ + providers: [TasksService, SupabaseService], + controllers: [TasksController], + exports: [TasksService], +}) +export class TasksModule {} diff --git a/backend-api/src/tasks/tasks.service.ts b/backend-api/src/tasks/tasks.service.ts new file mode 100644 index 0000000..7091bc6 --- /dev/null +++ b/backend-api/src/tasks/tasks.service.ts @@ -0,0 +1,203 @@ +import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { SupabaseService } from '../config/supabase.service'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; + +@Injectable() +export class TasksService { + constructor(private supabaseService: SupabaseService) {} + + async create(userId: string, createTaskDto: CreateTaskDto) { + const { data, error } = await this.supabaseService + .getClient() + .from('tasks') + .insert([ + { + title: createTaskDto.title, + description: createTaskDto.description || null, + completed: createTaskDto.completed || false, + due_date: createTaskDto.dueDate || null, + category: createTaskDto.category || null, + priority: createTaskDto.priority || 'medium', + user_id: userId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]) + .select(); + + if (error) { + throw new BadRequestException(`Erro ao criar tarefa: ${error.message}`); + } + + return { + success: true, + message: 'Tarefa criada com sucesso', + data: data?.[0], + }; + } + + async findAll( + userId: string, + filters?: { + completed?: boolean; + category?: string; + priority?: string; + sortBy?: 'created_at' | 'due_date' | 'priority'; + order?: 'asc' | 'desc'; + }, + ) { + let query = this.supabaseService + .getClient() + .from('tasks') + .select('*') + .eq('user_id', userId); + + // Aplicar filtros + if (filters?.completed !== undefined) { + query = query.eq('completed', filters.completed); + } + + if (filters?.category) { + query = query.eq('category', filters.category); + } + + if (filters?.priority) { + query = query.eq('priority', filters.priority); + } + + // Ordenação + const sortBy = filters?.sortBy || 'created_at'; + const order = filters?.order || 'desc'; + query = query.order(sortBy, { ascending: order === 'asc' }); + + const { data, error } = await query; + + if (error) { + throw new BadRequestException(`Erro ao buscar tarefas: ${error.message}`); + } + + return { + success: true, + message: 'Tarefas recuperadas com sucesso', + count: data?.length || 0, + data: data || [], + }; + } + + async findOne(id: string, userId: string) { + const { data, error } = await this.supabaseService + .getClient() + .from('tasks') + .select('*') + .eq('id', id) + .eq('user_id', userId) + .single(); + + if (error || !data) { + throw new NotFoundException('Tarefa não encontrada'); + } + + return { + success: true, + message: 'Tarefa recuperada com sucesso', + data, + }; + } + + async update(id: string, userId: string, updateTaskDto: UpdateTaskDto) { + // Verificar se tarefa existe e pertence ao usuário + const { data: existingTask, error: fetchError } = await this.supabaseService + .getClient() + .from('tasks') + .select('*') + .eq('id', id) + .eq('user_id', userId) + .single(); + + if (fetchError || !existingTask) { + throw new NotFoundException('Tarefa não encontrada'); + } + + // Atualizar tarefa + const { data, error } = await this.supabaseService + .getClient() + .from('tasks') + .update({ + ...updateTaskDto, + updated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('user_id', userId) + .select(); + + if (error) { + throw new BadRequestException(`Erro ao atualizar tarefa: ${error.message}`); + } + + return { + success: true, + message: 'Tarefa atualizada com sucesso', + data: data?.[0], + }; + } + + async remove(id: string, userId: string) { + // Verificar se tarefa existe e pertence ao usuário + const { data: existingTask, error: fetchError } = await this.supabaseService + .getClient() + .from('tasks') + .select('*') + .eq('id', id) + .eq('user_id', userId) + .single(); + + if (fetchError || !existingTask) { + throw new NotFoundException('Tarefa não encontrada'); + } + + // Deletar tarefa + const { error } = await this.supabaseService + .getClient() + .from('tasks') + .delete() + .eq('id', id) + .eq('user_id', userId); + + if (error) { + throw new BadRequestException(`Erro ao deletar tarefa: ${error.message}`); + } + + return { + success: true, + message: 'Tarefa deletada com sucesso', + }; + } + + async getStats(userId: string) { + const { data, error } = await this.supabaseService + .getClient() + .from('tasks') + .select('completed') + .eq('user_id', userId); + + if (error) { + throw new BadRequestException(`Erro ao obter estatísticas: ${error.message}`); + } + + const total = data?.length || 0; + const completed = data?.filter((t) => t.completed)?.length || 0; + const pending = total - completed; + + return { + success: true, + message: 'Estatísticas recuperadas com sucesso', + data: { + total, + completed, + pending, + completionPercentage: total > 0 ? Math.round((completed / total) * 100) : 0, + }, + }; + } +}