Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ Aplicação full stack para gerenciamento de tarefas com Angular 17 no frontend
- [Executar frontend](#executar-frontend)
- [Backend (ASP.NET Core)](#backend-aspnet-core)
- [Stack do backend](#stack-do-backend)
- [Subir SQL Server no Docker](#subir-sql-server-no-docker)
- [Configurar connection string (User Secrets)](#configurar-connection-string-user-secrets)
- [Aplicar migrations](#aplicar-migrations)
- [Setup local do Backend](#setup-local-do-backend)
- [Executar backend](#executar-backend)
- [Execução local com API](#execução-local-com-api)
- [Endpoints da API](#endpoints-da-api)
Expand Down Expand Up @@ -101,10 +99,14 @@ Aplicação local: http://localhost:4200/tarefas
- SQL Server
- Swagger

### Subir SQL Server no Docker
### Setup local do Backend

Siga os passos abaixo para configurar o banco de dados e as credenciais localmente.

1) Subir SQL Server no Docker

```bash
docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=SuaSenhaAqui@123" \
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=SuaSenhaAqui@123" \
-p 1433:1433 --name sqlserver \
-d mcr.microsoft.com/mssql/server:2022-latest
```
Expand All @@ -115,7 +117,8 @@ Verifique se o container está ativo:
docker ps
```

### Configurar connection string (User Secrets)
2) Configurar connection string (User Secrets)

A aplicação lê a string de conexão de `ConnectionStrings:DefaultConnection`. Para configurá-la localmente com segurança, utilize **User Secrets**.

```bash
Expand All @@ -127,12 +130,13 @@ dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost,
> [!NOTE]
> TrustServerCertificate=true é necessário em ambiente local com SQL Server no Docker.

### Aplicar migrations
3) Aplicar migrations

Caso seja a primeira vez configurando ou tenha modificado os modelos, crie a migração inicial antes de aplicá-la ao banco:

```bash
cd TaskFlow.Api
dotnet tool install --global dotnet-ef
dotnet ef migrations add Migration
dotnet ef migrations add InitialCreate
dotnet ef database update
```

Expand Down Expand Up @@ -197,7 +201,7 @@ Exemplo de payload (POST/PUT):
> [!TIP]
> Se a lista de tarefas não carregar, confirme se o modo correto está ativo (mock ou api) e se a URL da API está acessível.

- Erro de conexão com SQL Server: valide container, senha do usuário sa e connection string.
- Erro de conexão com SQL Server (Login failed for user 'sa'): Verifique se a senha no comando `docker run` e no `dotnet user-secrets set` são idênticas. Se necessário, remova o container (`docker rm -f sqlserver`) e refaça o passo 1 e 2 do setup.
- Erro de CORS: confirme origem http://localhost:4200 no backend.
- Erro de certificado local: use o endpoint HTTP em desenvolvimento (http://localhost:5055).

Expand All @@ -214,6 +218,8 @@ Embora o projeto atenda a todos os requisitos do desafio, algumas evoluções ar
### Backend
- **Implementação de Soft Delete:** Substituir a exclusão física (DELETE no banco) por exclusão lógica (adicionando campos como `IsDeleted` ou `DataExclusao`). Isso preserva o histórico de dados e previne perdas acidentais.
- **Tratamento Global de Exceções:** Extrair os blocos de `try-catch` das *Controllers* e implementar um *Global Exception Handler* via Middleware. Isso centraliza o tratamento de erros, padroniza as respostas de falha da API (utilizando o padrão `ProblemDetails`) e deixa os *Controllers* muito mais limpos e focados apenas no roteamento.
- **Refatoração para Princípios SOLID (DIP e ISP):** Introduzir interfaces para os serviços e repositórios, eliminando a dependência direta de classes concretas e do `DbContext`. Isso melhora o desacoplamento e facilita a escrita de testes unitários.
- **Abstração da Camada de Dados (Repository Pattern):** Isolar o acesso ao banco de dados em repositórios específicos, permitindo que os serviços foquem apenas nas regras de negócio e simplificando a troca ou evolução do provedor de persistência.

### Expansão e Ecossistema
- **Desenvolvimento de Plugin Nativo para o Obsidian:** Evoluir a solução front-end para atuar como um plugin do Obsidian. O objetivo é preencher uma lacuna atual na comunidade, oferecendo um quadro Kanban capaz de persistir, centralizar e sincronizar estados diretamente em uma API externa (cloud), permitindo a integração do fluxo de trabalho com sistemas de terceiros.
Expand Down
3 changes: 3 additions & 0 deletions TaskFlow.Api/ApiConventions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using Microsoft.AspNetCore.Mvc;

[assembly: ApiConventionType(typeof(DefaultApiConventions))]
87 changes: 75 additions & 12 deletions TaskFlow.Api/Controllers/TarefasController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,56 +5,119 @@

namespace TaskFlow.Api.Controllers
{
/// <summary>
/// Gerencia as operações CRUD de tarefas.
/// </summary>
/// <remarks>
/// Os status codes de resposta seguem as <see cref="DefaultApiConventions"/>
/// definidas no assembly. O único desvio é o <c>PUT</c>, que retorna
/// <c>204 No Content</c> em vez do corpo atualizado — o cliente deve
/// recarregar o recurso com um <c>GET</c> subsequente se necessário.
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Produces("application/json")]
public class TarefasController(TarefaService service) : ControllerBase
{
/// <summary>
/// Retorna todas as tarefas, com filtro opcional por status.
/// </summary>
/// <param name="status">
/// Filtra as tarefas pelo status informado.
/// Valores aceitos: <c>Pendente</c>, <c>EmAndamento</c>, <c>Concluida</c>.
/// Quando omitido, retorna todas as tarefas independentemente do status.
/// </param>
/// <returns>Lista de tarefas ordenada por data de criação decrescente.</returns>
[HttpGet]
public async Task<ActionResult<List<TarefaResponse>>> ListarAsync(
public async Task<ActionResult<List<TarefaResponse>>> GetAsync(
[FromQuery] StatusTarefa? status = null)
{
var tarefas = await service.ListarAsync(status);
var tarefas = await service.GetAsync(status);
return Ok(tarefas);
}

/// <summary>
/// Retorna uma tarefa pelo seu identificador único.
/// </summary>
/// <param name="id">Identificador da tarefa.</param>
/// <returns>Dados completos da tarefa encontrada.</returns>
[HttpGet("{id}")]
public async Task<ActionResult<TarefaResponse>> BuscarIdAsync(int id)
public async Task<ActionResult<TarefaResponse>> GetByIdAsync(int id)
{
var tarefa = await service.BuscarIdAsync(id);
var tarefa = await service.GetByIdAsync(id);
if (tarefa is null)
return NotFound(new { Message = "Tarefa não encontrada" });

return Ok(tarefa);
}

/// <summary>
/// Cria uma nova tarefa.
/// </summary>
/// <remarks>
/// O status inicial é sempre <c>Pendente</c>, independentemente do valor
/// enviado no corpo da requisição. As datas de início e conclusão são
/// gerenciadas internamente pela API conforme o status evolui.
/// </remarks>
/// <param name="request">Dados da tarefa a ser criada.</param>
/// <returns>
/// A tarefa criada, com o cabeçalho <c>Location</c> apontando para
/// o endpoint <c>GET /api/tarefas/{id}</c> do recurso recém-criado.
/// </returns>
[HttpPost]
public async Task<ActionResult<TarefaResponse>> CriarAsync(TarefaRequest request)
public async Task<ActionResult<TarefaResponse>> PostAsync(TarefaRequest request)
{
var tarefa = await service.CriarAsync(request);
return StatusCode(201, tarefa); // 201 - created.
var tarefa = await service.PostAsync(request);
return CreatedAtAction("GetById", new { id = tarefa.Id }, tarefa);
}

/// <summary>
/// Atualiza os dados de uma tarefa existente.
/// </summary>
/// <remarks>
/// Atualiza título, descrição e status da tarefa. A transição de status
/// gerencia automaticamente as datas de ciclo de vida:
/// <list type="bullet">
/// <item><description>
/// <c>Pendente</c> — zera <c>DataInicio</c> e <c>DataConclusao</c>.
/// </description></item>
/// <item><description>
/// <c>EmAndamento</c> — define <c>DataInicio</c> (somente na primeira
/// transição para este status; não sobrescreve se já definida).
/// </description></item>
/// <item><description>
/// <c>Concluida</c> — define <c>DataConclusao</c> com o momento atual.
/// </description></item>
/// </list>
/// Espaços no início e fim de <c>Titulo</c> e <c>Descricao</c> são removidos
/// automaticamente antes de persistir.
/// </remarks>
/// <param name="id">Identificador da tarefa a ser atualizada.</param>
/// <param name="request">Novos dados da tarefa.</param>
[HttpPut("{id}")]
public async Task<ActionResult<TarefaResponse>> AtualizarAsync(int id, TarefaRequest request)
public async Task<ActionResult> PutAsync(int id, TarefaRequest request)
{
try
{
var tarefa = await service.AtualizarAsync(id, request);
return Ok(tarefa);
await service.PutAsync(id, request);
return NoContent();
}
catch (KeyNotFoundException)
{
return NotFound(new { Message = $"Tarefa {id} não encontrada" });
}
}

/// <summary>
/// Remove uma tarefa permanentemente.
/// </summary>
/// <param name="id">Identificador da tarefa a ser removida.</param>
[HttpDelete("{id}")]
public async Task<ActionResult> DeletarAsync(int id)
public async Task<ActionResult> DeleteAsync(int id)
{
try
{
await service.DeletarAsync(id);
await service.DeleteAsync(id);
return NoContent();
}
catch (KeyNotFoundException)
Expand Down
123 changes: 69 additions & 54 deletions TaskFlow.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,54 +1,69 @@
using Microsoft.EntityFrameworkCore;
using TaskFlow.Api.Data;
using TaskFlow.Api.Services;

var builder = WebApplication.CreateBuilder(args);

// Bugfix para serialização de enums como strings (em vez de números) na API | resolve envio de form data do Angular
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddScoped<TarefaService>();

/* DbContext
A chave "DefaultConnection" é lida do User Secrets (ou de appsettings.json em produção).
Para configurá-la localmente via User Secrets, execute no terminal (dentro de TaskFlow.Api/):
> dotnet user-secrets init
> dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=...;Database=...;User Id=...;Password=...;TrustServerCertificate=true"
Se quiser usar um nome diferente de "DefaultConnection", altere tanto aqui quanto na chave do User Secret.
*/
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));

// CORS - Angular
builder.Services.AddCors(options =>
{
options.AddPolicy("Angular", policy =>
{
policy.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowAnyMethod();
});
});


var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.UseCors("Angular");

app.UseHttpsRedirection();

app.MapControllers();

app.Run();

using Microsoft.EntityFrameworkCore;
using TaskFlow.Api.Data;
using TaskFlow.Api.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "TaskFlow API",
Version = "v1",
Description = "API REST para gerenciamento de tarefas com suporte a status e filtros."
});

var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
});

builder.Services.AddScoped<TarefaService>();

/* DbContext
A chave "DefaultConnection" é lida do User Secrets (ou de appsettings.json em produção).
*/
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"Connection string 'ConnectionStrings:DefaultConnection' não configurada. Configure o banco de dados via Docker e User Secrets conforme o README.");
}

builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));

builder.Services.AddCors(options =>
{
options.AddPolicy("Angular", policy =>
{
policy.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowAnyMethod();
});
});


var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "TaskFlow API v1");
options.DocumentTitle = "TaskFlow API";
});

app.UseCors("Angular");

app.UseHttpsRedirection();

app.MapControllers();

app.Run();
17 changes: 7 additions & 10 deletions TaskFlow.Api/Services/TarefaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace TaskFlow.Api.Services;

public class TarefaService(AppDbContext db)
{
public async Task<List<TarefaResponse>> ListarAsync(StatusTarefa? status = null)
public async Task<List<TarefaResponse>> GetAsync(StatusTarefa? status = null)
{
var query = db.Tarefas.AsQueryable();

Expand All @@ -20,13 +20,13 @@ public async Task<List<TarefaResponse>> ListarAsync(StatusTarefa? status = null)

return tarefas.Select(t => t.ToResponse()).ToList();
}
public async Task<TarefaResponse?> BuscarIdAsync(int id)
public async Task<TarefaResponse?> GetByIdAsync(int id)
{
var tarefa = await db.Tarefas.FindAsync(id);
return tarefa?.ToResponse();
}

public async Task<TarefaResponse> CriarAsync(TarefaRequest request)
public async Task<TarefaResponse> PostAsync(TarefaRequest request)
{
var tarefa = request.ToEntity();

Expand All @@ -36,28 +36,25 @@ public async Task<TarefaResponse> CriarAsync(TarefaRequest request)
return tarefa.ToResponse();
}

public async Task<TarefaResponse> AtualizarAsync(int id, TarefaRequest request)
public async Task<TarefaResponse> PutAsync(int id, TarefaRequest request)
{
var tarefa = await db.Tarefas.FindAsync(id);

if (tarefa == null)
throw new KeyNotFoundException($"Tarefa {id} não encontrada");

tarefa.Titulo = request.Titulo.Trim(); // Remove espaços acidentais
tarefa.Titulo = request.Titulo.Trim();
tarefa.Descricao = request.Descricao.Trim();

// Bugfix: Atualiza as datas com base no novo status, somente se o status tiver sido modificado
if (tarefa.Status != request.Status)
{
tarefa.Status = request.Status;

// data de início
if (tarefa.Status == StatusTarefa.Pendente)
tarefa.DataInicio = null;
else if (tarefa.DataInicio == null) // Somente definir a data de início se ainda não tiver sido definida
else if (tarefa.DataInicio == null)
tarefa.DataInicio = DateTime.UtcNow;

// data de conclusão
if (tarefa.Status == StatusTarefa.Concluida)
tarefa.DataConclusao = DateTime.UtcNow;
else
Expand All @@ -67,7 +64,7 @@ public async Task<TarefaResponse> AtualizarAsync(int id, TarefaRequest request)
return tarefa.ToResponse();
}

public async Task DeletarAsync(int id)
public async Task DeleteAsync(int id)
{
var tarefa = await db.Tarefas.FindAsync(id);
if (tarefa == null)
Expand Down
Loading
Loading