feat(tasks): implement CRUD endpoints with Supabase integration

This commit is contained in:
Erik Silva
2025-12-01 01:28:42 -03:00
parent 58df03d597
commit 6a73cce6c3
7 changed files with 489 additions and 55 deletions

View File

@@ -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 ```http
GET /tasks GET /tasks
Authorization: Bearer {token} Authorization: Bearer {token}
# Query params opcionais: # Query params opcionais:
?status=all|completed|pending ?completed=true|false
?sort=created_at|updated_at ?category=compras
?priority=low|medium|high
?sortBy=created_at|due_date|priority
?order=asc|desc ?order=asc|desc
``` ```
**Response (200 OK):** **Response (200 OK):**
```json ```json
{ {
"success": true,
"message": "Tarefas recuperadas com sucesso",
"count": 5,
"data": [ "data": [
{ {
"id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
@@ -158,18 +211,28 @@ Authorization: Bearer {token}
"title": "Fazer compras", "title": "Fazer compras",
"description": "Ir ao supermercado", "description": "Ir ao supermercado",
"completed": false, "completed": false,
"due_date": "2025-12-25T00:00:00Z",
"category": "compras",
"priority": "high",
"created_at": "2025-12-01T10:00:00Z", "created_at": "2025-12-01T10:00:00Z",
"updated_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 ```http
GET /tasks/:id GET /tasks/:id
Authorization: Bearer {token} Authorization: Bearer {token}
@@ -178,49 +241,25 @@ Authorization: Bearer {token}
**Response (200 OK):** **Response (200 OK):**
```json ```json
{ {
"success": true,
"message": "Tarefa recuperada com sucesso",
"data": {
"id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"user_id": "550e8400-e29b-41d4-a716-446655440000", "user_id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Fazer compras", "title": "Fazer compras",
"description": "Ir ao supermercado", "description": "Ir ao supermercado",
"completed": false, "completed": false,
"due_date": "2025-12-25T00:00:00Z",
"category": "compras",
"priority": "high",
"created_at": "2025-12-01T10:00:00Z", "created_at": "2025-12-01T10:00:00Z",
"updated_at": "2025-12-01T10:00:00Z" "updated_at": "2025-12-01T10:00:00Z"
}
} }
``` ```
**Erros:** **Erros:**
- `404 Not Found` - Tarefa não encontrada - `404 Not Found` - Tarefa não encontrada ou não pertence ao usuário
- `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
- `401 Unauthorized` - Token inválido - `401 Unauthorized` - Token inválido
--- ---
@@ -234,23 +273,35 @@ Content-Type: application/json
{ {
"title": "Fazer compras (atualizado)", "title": "Fazer compras (atualizado)",
"description": "Ir ao supermercado e padaria", "description": "Ir ao supermercado e padaria",
"completed": true "completed": true,
"dueDate": "2025-12-20T00:00:00Z",
"priority": "medium"
} }
``` ```
**Response (200 OK):** **Response (200 OK):**
```json ```json
{ {
"success": true,
"message": "Tarefa atualizada com sucesso",
"data": {
"id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "id": "6b1f2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"user_id": "550e8400-e29b-41d4-a716-446655440000", "user_id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Fazer compras (atualizado)", "title": "Fazer compras (atualizado)",
"description": "Ir ao supermercado e padaria", "description": "Ir ao supermercado e padaria",
"completed": true, "completed": true,
"due_date": "2025-12-20T00:00:00Z",
"category": "compras",
"priority": "medium",
"created_at": "2025-12-01T10:00:00Z", "created_at": "2025-12-01T10:00:00Z",
"updated_at": "2025-12-01T10:30:00Z" "updated_at": "2025-12-01T10:30:00Z"
}
} }
``` ```
**Campos opcionais:**
- Todos os campos de CreateTaskDto são opcionais em PATCH
**Erros:** **Erros:**
- `404 Not Found` - Tarefa não encontrada - `404 Not Found` - Tarefa não encontrada
- `400 Bad Request` - Dados inválidos - `400 Bad Request` - Dados inválidos
@@ -267,6 +318,7 @@ Authorization: Bearer {token}
**Response (200 OK):** **Response (200 OK):**
```json ```json
{ {
"success": true,
"message": "Tarefa deletada com sucesso" "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) ## 🔄 Real-time (WebSocket)
### Conectar ao Realtime ### Conectar ao Realtime

View File

@@ -10,6 +10,7 @@ import jwtConfig from './config/jwt.config';
import { SupabaseService } from './config/supabase.service'; import { SupabaseService } from './config/supabase.service';
import { JwtStrategy } from './auth/strategies/jwt.strategy'; import { JwtStrategy } from './auth/strategies/jwt.strategy';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { TasksModule } from './tasks/tasks.module';
import * as Joi from 'joi'; import * as Joi from 'joi';
@Module({ @Module({
@@ -40,6 +41,7 @@ import * as Joi from 'joi';
}), }),
}), }),
AuthModule, AuthModule,
TasksModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, SupabaseService, JwtStrategy], providers: [AppService, SupabaseService, JwtStrategy],

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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,
},
};
}
}