From 96aec6fbb3b59c6d8a74c0e7bbea0aed484fb16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Queiroz?= Date: Fri, 20 Feb 2026 21:16:14 -0300 Subject: [PATCH] Implement autosave task patch/move with optimistic concurrency --- Controllers/TaskManagerController.cs | 145 +++++--- Data/AppDbContext.cs | 21 +- Entitys/TaskEntity.cs | 18 +- Entitys/TaskState.cs | 9 + Entitys/UserEntity.cs | 1 + ...260220000100_TaskAutosaveAndConcurrency.cs | 112 ++++++ Models/TaskModel.cs | 6 +- Models/TaskMoveRequest.cs | 11 + Models/TaskPatchRequest.cs | 16 + Models/TaskResponse.cs | 21 ++ Models/TaskTimeRequest.cs | 10 + Program.cs | 67 ++-- README.md | 95 +++-- Services/JwtTokenService.cs | 3 +- Services/TaskManagerServices.cs | 330 ++++++++++++++---- .../Services/TaskManagerServicesTests.cs | 65 +++- Todo.Tests/Todo.Tests.csproj | 2 +- appsettings.Development.json | 11 + appsettings.json | 11 + 19 files changed, 776 insertions(+), 178 deletions(-) create mode 100644 Entitys/TaskState.cs create mode 100644 Migrations/20260220000100_TaskAutosaveAndConcurrency.cs create mode 100644 Models/TaskMoveRequest.cs create mode 100644 Models/TaskPatchRequest.cs create mode 100644 Models/TaskResponse.cs create mode 100644 Models/TaskTimeRequest.cs diff --git a/Controllers/TaskManagerController.cs b/Controllers/TaskManagerController.cs index de6cf8e..b37654b 100644 --- a/Controllers/TaskManagerController.cs +++ b/Controllers/TaskManagerController.cs @@ -1,16 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; -using Todo.Data; -using Todo.Migrations; +using System.Security.Claims; using Todo.Models; using Todo.Services; namespace Todo.Controllers { [ApiController] - public class TaskManagerController : ControllerBase { private readonly TaskManagerServices _taskManagerServices; @@ -29,7 +25,7 @@ public IActionResult Get() var tasksToDo = _taskManagerServices.GetTasksToDo(); return Ok(tasksToDo); } - catch (System.Exception) + catch { return BadRequest(); } @@ -38,14 +34,15 @@ public IActionResult Get() [HttpGet("/ListTaskDone")] public IActionResult ListTaskDone() { - try - { + try + { var tasksDone = _taskManagerServices.GetTaskDone(); return Ok(tasksDone); - }catch(System.Exception) + } + catch { - return BadRequest(); - } + return BadRequest(); + } } [Authorize] @@ -56,22 +53,23 @@ public IActionResult ListAllTasks() { var allTasks = _taskManagerServices.GetAllTasks(); return Ok(allTasks); - }catch(System.Exception) - { + } + catch + { return BadRequest(); } } [Authorize] [HttpGet("ListTaskByUser/{userId}")] - public IActionResult ListTarefaByUser( - [FromRoute] int userId) + public IActionResult ListTarefaByUser([FromRoute] int userId) { try { var tasksByUser = _taskManagerServices.GetTasksByUser(userId); return Ok(tasksByUser); - }catch(System.Exception) + } + catch { return BadRequest(); } @@ -81,58 +79,57 @@ public IActionResult ListTarefaByUser( [HttpGet("/GetById/{id:int}")] public IActionResult GetById([FromRoute] int id) { - try - { + try + { var taskById = _taskManagerServices.GetById(id); return Ok(taskById); - }catch(System.Exception) + } + catch { - return BadRequest(); - } + return BadRequest(); + } } [Authorize] [HttpPost("/insertTask/{userId}")] - public IActionResult Post( - [FromBody] TaskModel model, - [FromRoute] int userId) + public IActionResult Post([FromBody] TaskModel model, [FromRoute] int userId) { try { var task = _taskManagerServices.InsertTask(model, userId); return Ok(task); - }catch(System.Exception) + } + catch { return BadRequest(); - } + } } [Authorize] [HttpPut("/edit/{id:int}")] - public IActionResult Put( - [FromRoute] int id, - [FromBody] TaskModel model) + public IActionResult Put([FromRoute] int id, [FromBody] TaskModel model) { - try - { + try + { var taskToEdit = _taskManagerServices.EditTask(model, id); return Ok(taskToEdit); - }catch(System.Exception) - { + } + catch + { return BadRequest(); - } + } } [Authorize] [HttpDelete("/delete/{id:int}")] - public IActionResult Delete( - [FromRoute] int id) + public IActionResult Delete([FromRoute] int id) { try { var editeTask = _taskManagerServices.DeleteTask(id); return Ok(editeTask); - }catch(System.Exception) + } + catch { return BadRequest(); } @@ -140,14 +137,14 @@ public IActionResult Delete( [Authorize] [HttpPut("/done/{id:int}")] - public IActionResult Done( - [FromRoute] int id) + public IActionResult Done([FromRoute] int id) { try { var taskDone = _taskManagerServices.DoneTask(id); return Ok(taskDone); - }catch(System.Exception) + } + catch { return BadRequest(); } @@ -155,18 +152,78 @@ public IActionResult Done( [Authorize] [HttpPost("/asignTask")] - public IActionResult AsignTask( - [FromBody] TaskModel model) + public IActionResult AsignTask([FromBody] TaskModel model) { try { var asignTask = _taskManagerServices.AsignTask(model); return Ok(asignTask); - }catch(System.Exception) + } + catch { return BadRequest(); } } + [Authorize] + [HttpPatch("/api/tasks/{id:int}")] + public async Task PatchTask([FromRoute] int id, [FromBody] TaskPatchRequest request) + { + var (userId, tenantId) = ResolveIdentity(); + if (userId == null || tenantId == null) + return Unauthorized(new ProblemDetails { Title = "Token inválido", Status = 401 }); + + var ifMatchHeader = Request.Headers.IfMatch.FirstOrDefault()?.Trim('"'); + var ifMatch = TaskManagerServices.ParseRowVersion(ifMatchHeader); + + var (_, result) = await _taskManagerServices.PatchTaskAsync(id, request, userId.Value, tenantId.Value, ifMatch); + return result; + } + + [Authorize] + [HttpPost("/api/tasks/{id:int}/move")] + public async Task MoveTask([FromRoute] int id, [FromBody] TaskMoveRequest request) + { + var (userId, tenantId) = ResolveIdentity(); + if (userId == null || tenantId == null) + return Unauthorized(new ProblemDetails { Title = "Token inválido", Status = 401 }); + + var (_, result) = await _taskManagerServices.MoveTaskAsync(id, request, userId.Value, tenantId.Value); + return result; + } + + [Authorize] + [HttpPut("/api/tasks/{id:int}/time")] + public async Task UpdateTime([FromRoute] int id, [FromBody] TaskTimeRequest request) + { + var (userId, tenantId) = ResolveIdentity(); + if (userId == null || tenantId == null) + return Unauthorized(new ProblemDetails { Title = "Token inválido", Status = 401 }); + + var (_, result) = await _taskManagerServices.UpdateTaskTimeAsync(id, request, userId.Value, tenantId.Value); + return result; + } + + [Authorize] + [HttpGet("/api/boards/{boardId:int}/tasks")] + public async Task GetBoardTasks([FromRoute] int boardId) + { + var (userId, tenantId) = ResolveIdentity(); + if (userId == null || tenantId == null) + return Unauthorized(new ProblemDetails { Title = "Token inválido", Status = 401 }); + + return await _taskManagerServices.GetBoardTasksAsync(boardId, userId.Value, tenantId.Value); + } + + private (int? userId, int? tenantId) ResolveIdentity() + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + var tenantClaim = User.FindFirstValue("tenant_id") ?? User.FindFirstValue("tenantId"); + + if (!int.TryParse(userIdClaim, out var userId)) return (null, null); + if (!int.TryParse(tenantClaim, out var tenantId)) tenantId = 1; + + return (userId, tenantId); + } } -} \ No newline at end of file +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 41c5428..953a74b 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -1,11 +1,26 @@ using Microsoft.EntityFrameworkCore; using Todo.Domain; -namespace Todo.Data { - public class AppDbContext : DbContext { +namespace Todo.Data +{ + public class AppDbContext : DbContext + { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Tasks { get; set; } public DbSet Users { get; set; } public DbSet CategorieTasks { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .Property(x => x.RowVersion) + .IsRowVersion(); + + modelBuilder.Entity() + .Property(x => x.State) + .HasConversion(); + } } -} \ No newline at end of file +} diff --git a/Entitys/TaskEntity.cs b/Entitys/TaskEntity.cs index 82944c6..1f88bf2 100644 --- a/Entitys/TaskEntity.cs +++ b/Entitys/TaskEntity.cs @@ -1,17 +1,28 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using System.Diagnostics.CodeAnalysis; namespace Todo.Domain; - public class TaskEntity +public class TaskEntity { public int Id { get; set; } public string? Title { get; set; } public string? Description { get; set; } public bool Done { get; set; } public DateTime CreatedAt { get; set; } - public DateTime FinishedAt { get; set; } + public DateTime? FinishedAt { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public int UpdatedBy { get; set; } public int CategorieTaskId { get; set; } + public int Order { get; set; } + public TaskState State { get; set; } = TaskState.Todo; + public int? EstimateMinutes { get; set; } + public int? SpentMinutes { get; set; } + public DateTime? DueDate { get; set; } + public int TenantId { get; set; } = 1; + + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); [ForeignKey("CategorieTaskId")] public virtual CategorieTaskEntity? Category { get; set; } @@ -20,5 +31,4 @@ public class TaskEntity public virtual UserEntity? User { get; set; } public int UserId { get; set; } - } diff --git a/Entitys/TaskState.cs b/Entitys/TaskState.cs new file mode 100644 index 0000000..9c05af1 --- /dev/null +++ b/Entitys/TaskState.cs @@ -0,0 +1,9 @@ +namespace Todo.Domain +{ + public enum TaskState + { + Todo = 0, + InProgress = 1, + Done = 2 + } +} diff --git a/Entitys/UserEntity.cs b/Entitys/UserEntity.cs index 866204d..66f2163 100644 --- a/Entitys/UserEntity.cs +++ b/Entitys/UserEntity.cs @@ -9,6 +9,7 @@ public class UserEntity public string Password { get; set; } public bool IsAdmin { get; set; } public bool IsLogged { get; set; } + public int TenantId { get; set; } = 1; public byte[] ProfilePicture { get; set; } public DateTime CreatedAt { get; set; } = DateTime.Now; public virtual ICollection Tasks { get; set; } = new List(); diff --git a/Migrations/20260220000100_TaskAutosaveAndConcurrency.cs b/Migrations/20260220000100_TaskAutosaveAndConcurrency.cs new file mode 100644 index 0000000..5fe727b --- /dev/null +++ b/Migrations/20260220000100_TaskAutosaveAndConcurrency.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Todo.Migrations +{ + public partial class TaskAutosaveAndConcurrency : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DueDate", + table: "Tasks", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EstimateMinutes", + table: "Tasks", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "Order", + table: "Tasks", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "Tasks", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "SpentMinutes", + table: "Tasks", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "State", + table: "Tasks", + type: "nvarchar(max)", + nullable: false, + defaultValue: "Todo"); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "Tasks", + type: "int", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "Tasks", + type: "datetime2", + nullable: false, + defaultValueSql: "GETUTCDATE()"); + + migrationBuilder.AddColumn( + name: "UpdatedBy", + table: "Tasks", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AlterColumn( + name: "FinishedAt", + table: "Tasks", + type: "datetime2", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "Users", + type: "int", + nullable: false, + defaultValue: 1); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "DueDate", table: "Tasks"); + migrationBuilder.DropColumn(name: "EstimateMinutes", table: "Tasks"); + migrationBuilder.DropColumn(name: "Order", table: "Tasks"); + migrationBuilder.DropColumn(name: "RowVersion", table: "Tasks"); + migrationBuilder.DropColumn(name: "SpentMinutes", table: "Tasks"); + migrationBuilder.DropColumn(name: "State", table: "Tasks"); + migrationBuilder.DropColumn(name: "TenantId", table: "Tasks"); + migrationBuilder.DropColumn(name: "UpdatedAt", table: "Tasks"); + migrationBuilder.DropColumn(name: "UpdatedBy", table: "Tasks"); + migrationBuilder.DropColumn(name: "TenantId", table: "Users"); + + migrationBuilder.AlterColumn( + name: "FinishedAt", + table: "Tasks", + type: "datetime2", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "datetime2", + oldNullable: true); + } + } +} diff --git a/Models/TaskModel.cs b/Models/TaskModel.cs index b557459..ebe9cbc 100644 --- a/Models/TaskModel.cs +++ b/Models/TaskModel.cs @@ -7,9 +7,13 @@ public class TaskModel public string? Description { get; set; } public bool Done { get; set; } public DateTime CreatedAt { get; set; } - public DateTime FinishedAt { get; set; } + public DateTime? FinishedAt { get; set; } public int CategorieTaskId { get; set; } public int UserId { get; set; } + public int? EstimateMinutes { get; set; } + public int? SpentMinutes { get; set; } + public DateTime? DueDate { get; set; } + public string? RowVersion { get; set; } public string? Category { get; set; } public string? UserName { get; set; } diff --git a/Models/TaskMoveRequest.cs b/Models/TaskMoveRequest.cs new file mode 100644 index 0000000..bf918f9 --- /dev/null +++ b/Models/TaskMoveRequest.cs @@ -0,0 +1,11 @@ +namespace Todo.Models +{ + public class TaskMoveRequest + { + public int? SourceColumnId { get; set; } + public int TargetColumnId { get; set; } + public int TargetOrder { get; set; } + public string? TargetState { get; set; } + public string? RowVersion { get; set; } + } +} diff --git a/Models/TaskPatchRequest.cs b/Models/TaskPatchRequest.cs new file mode 100644 index 0000000..3117bff --- /dev/null +++ b/Models/TaskPatchRequest.cs @@ -0,0 +1,16 @@ +namespace Todo.Models +{ + public class TaskPatchRequest + { + public string? Title { get; set; } + public string? Description { get; set; } + public bool? Done { get; set; } + public int? CategorieTaskId { get; set; } + public int? Order { get; set; } + public string? State { get; set; } + public int? EstimateMinutes { get; set; } + public int? SpentMinutes { get; set; } + public DateTime? DueDate { get; set; } + public string? RowVersion { get; set; } + } +} diff --git a/Models/TaskResponse.cs b/Models/TaskResponse.cs new file mode 100644 index 0000000..14c45c6 --- /dev/null +++ b/Models/TaskResponse.cs @@ -0,0 +1,21 @@ +namespace Todo.Models +{ + public class TaskResponse + { + public int Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public bool Done { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? FinishedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public int UpdatedBy { get; set; } + public int CategorieTaskId { get; set; } + public int Order { get; set; } + public string State { get; set; } = "Todo"; + public int? EstimateMinutes { get; set; } + public int? SpentMinutes { get; set; } + public DateTime? DueDate { get; set; } + public string RowVersion { get; set; } = string.Empty; + } +} diff --git a/Models/TaskTimeRequest.cs b/Models/TaskTimeRequest.cs new file mode 100644 index 0000000..3130fb7 --- /dev/null +++ b/Models/TaskTimeRequest.cs @@ -0,0 +1,10 @@ +namespace Todo.Models +{ + public class TaskTimeRequest + { + public int? EstimateMinutes { get; set; } + public int? SpentMinutes { get; set; } + public DateTime? DueDate { get; set; } + public string? RowVersion { get; set; } + } +} diff --git a/Program.cs b/Program.cs index 1a6077a..b342c11 100644 --- a/Program.cs +++ b/Program.cs @@ -1,7 +1,5 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using System.Text; @@ -14,39 +12,64 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); +builder.Services.AddProblemDetails(); builder.Services.AddCors(options => { - options.AddDefaultPolicy(builder => + options.AddDefaultPolicy(policy => { - builder.AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod(); + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); }); }); - builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Task Manager", Version = "v1" }); + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT" + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } + }, + Array.Empty() + } + }); }); +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? Environment.GetEnvironmentVariable("CONNECTION_STRING") + ?? "Server=localhost\\SQLEXPRESS01;Database=TaskManagerPro;Trusted_Connection=True;TrustServerCertificate=True;"; -builder.Services.AddDbContext(options => -{ - options.UseSqlServer("Server=localhost\\SQLEXPRESS01;Database=TaskManagerPro;Trusted_Connection=True;TrustServerCertificate=True;"); -}); +builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +var jwtKey = Environment.GetEnvironmentVariable("AUTH_JWT_KEY") + ?? builder.Configuration["Jwt:Secret"] + ?? Settings.Secret; +var jwtIssuer = Environment.GetEnvironmentVariable("AUTH_JWT_ISSUER") + ?? builder.Configuration["Jwt:Issuer"]; +var jwtAudience = Environment.GetEnvironmentVariable("AUTH_JWT_AUDIENCE") + ?? builder.Configuration["Jwt:Audience"]; -var key = Encoding.ASCII.GetBytes(Settings.Secret); +var key = Encoding.ASCII.GetBytes(jwtKey); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -54,17 +77,17 @@ { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), - ValidateIssuer = false, - ValidateAudience = false, + ValidateIssuer = !string.IsNullOrWhiteSpace(jwtIssuer), + ValidIssuer = jwtIssuer, + ValidateAudience = !string.IsNullOrWhiteSpace(jwtAudience), + ValidAudience = jwtAudience, }; }); var app = builder.Build(); +app.UseExceptionHandler(); app.UseSwagger(); -app.UseSwaggerUI(c => -{ - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Task Manager"); -}); +app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Task Manager")); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/README.md b/README.md index 8334ca6..4254482 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,77 @@

Todo List backend

-
-

Acompanhe a Produtividade do Projeto

- wakatime -

Visão Geral do Tempo Investido

-
- -
- -# Iniciando o projeto -Para rodar o projeto você precisa antes de algumas ferramentas instaladas: -* Visual Studio (Preferência) -* ter o Frontend em sua máquina -#### Alguns pacotes do NuGet como: -* Microsoft.EntityFrameworkCore -* Microsoft.EntityFrameworkCore.Design -* Microsoft.EntityFrameworkCore.SqlLight -* Microsoft.EntityFrameworkCore.Tools - -## Clonando o projeto -```bash -git clone https://github.com/Romulo-Queiroz/todoListFront + +## Iniciando o projeto +Para rodar o projeto você precisa: +- .NET 7 SDK +- SQL Server (ou ajustar `CONNECTION_STRING`) + +### Configuração de ambiente +As variáveis abaixo suportam autenticação JWT e autosave: + +- `CONNECTION_STRING` +- `AUTH_JWT_ISSUER` +- `AUTH_JWT_AUDIENCE` +- `AUTH_JWT_KEY` +- `FEATURE_AUTOSAVE_ENABLED=true` + +Também é possível configurar via `appsettings.json`: + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "..." + }, + "Jwt": { + "Issuer": "TaskManager", + "Audience": "TaskManagerClient", + "Secret": "..." + }, + "Features": { + "AutosaveEnabled": true + } +} +``` + +## Endpoints de autosave/kanban + +### PATCH `/api/tasks/{id}` +Atualização parcial com controle de concorrência otimista. + +Request: +```json +{ + "title": "Novo título", + "description": "Atualização de autosave", + "state": "InProgress", + "order": 2, + "estimateMinutes": 120, + "spentMinutes": 45, + "dueDate": "2026-03-10T14:00:00Z", + "rowVersion": "AAAAAAAAB9E=" +} +``` + +### POST `/api/tasks/{id}/move` +Movimenta card entre colunas e recalcula ordenação em transação. + +Request: +```json +{ + "targetColumnId": 4, + "targetOrder": 1, + "targetState": "Done", + "rowVersion": "AAAAAAAAB9E=" +} ``` -# Autor -
+### PUT `/api/tasks/{id}/time` +Atualiza campos de tempo da tarefa. -| [
@Romulo-Queiroz](https://github.com/Romulo-Queiroz) | -| :-------------------------------------------------------------------------------------------------------------------------------------: | +### GET `/api/boards/{boardId}/tasks` +Lista tarefas de uma coluna/board com `state`, `order` e `rowVersion`. -
+## Concorrência +Se a `rowVersion` enviada divergir da atual, a API retorna `409 Conflict` com `ProblemDetails` e `currentRowVersion`. ## Architecture Diagram See [docs/architecture.md](docs/architecture.md) for a high level flow of how the API handles requests. diff --git a/Services/JwtTokenService.cs b/Services/JwtTokenService.cs index ccad80e..f0699b0 100644 --- a/Services/JwtTokenService.cs +++ b/Services/JwtTokenService.cs @@ -31,7 +31,8 @@ public string Generate(UserEntity user) { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), - new Claim(ClaimTypes.Role, user.IsAdmin ? "Admin" : "User") + new Claim(ClaimTypes.Role, user.IsAdmin ? "Admin" : "User"), + new Claim("tenant_id", user.TenantId.ToString()) }; var tokenDescriptor = new SecurityTokenDescriptor diff --git a/Services/TaskManagerServices.cs b/Services/TaskManagerServices.cs index 8012b7a..2ff6033 100644 --- a/Services/TaskManagerServices.cs +++ b/Services/TaskManagerServices.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Todo.Data; using Todo.Domain; using Todo.Models; @@ -6,33 +7,24 @@ namespace Todo.Services { - public class TaskManagerServices: ControllerBase + public class TaskManagerServices : ControllerBase { private readonly AppDbContext context; private readonly TaskDal _taskDal; + private readonly ILogger _logger; - public TaskManagerServices(AppDbContext dbContext, TaskDal taskDal) + public TaskManagerServices(AppDbContext dbContext, TaskDal taskDal, ILogger logger) { context = dbContext; _taskDal = taskDal; + _logger = logger; } public IActionResult GetTasksToDo() { var tasks = context.Tasks .Where(x => x.Done == false) - .Select(task => new - { - TaskId = task.Id, - TaskTitle = task.Title, - TaskDescription = task.Description, - Done = task.Done, - CreatedAt = task.CreatedAt, - CategoryName = context.CategorieTasks - .Where(category => category.Id == task.CategorieTaskId) - .Select(category => category.Name) - .FirstOrDefault() - }) + .Select(MapLegacyList()) .ToList(); return Ok(tasks); } @@ -40,19 +32,8 @@ public IActionResult GetTasksToDo() public IActionResult GetTaskDone() { var tasks = context.Tasks - .Where(x => x.Done == true) - .Select(task => new - { - TaskId = task.Id, - TaskTitle = task.Title, - TaskDescription = task.Description, - Done = task.Done, - CreatedAt = task.CreatedAt, - CategoryName = context.CategorieTasks - .Where(category => category.Id == task.CategorieTaskId) - .Select(category => category.Name) - .FirstOrDefault() - }) + .Where(x => x.Done) + .Select(MapLegacyList()) .ToList(); return Ok(tasks); } @@ -60,60 +41,44 @@ public IActionResult GetTaskDone() public IActionResult GetAllTasks() { var tasks = context.Tasks - .Select(task => new - { - TaskId = task.Id, - TaskTitle = task.Title, - TaskDescription = task.Description, - Done = task.Done, - CreatedAt = task.CreatedAt, - CategoryName = context.CategorieTasks - .Where(category => category.Id == task.CategorieTaskId) - .Select(category => category.Name) - .FirstOrDefault() - }) + .Select(MapLegacyList()) .ToList(); return Ok(tasks); } - public IActionResult GetTasksByUser (int userId) + public IActionResult GetTasksByUser(int userId) { var tasks = context.Tasks .Where(x => x.UserId == userId) - .Select(task => new - { - TaskId = task.Id, - TaskTitle = task.Title, - TaskDescription = task.Description, - Done = task.Done, - CreatedAt = task.CreatedAt, - CategoryName = context.CategorieTasks - .Where(category => category.Id == task.CategorieTaskId) - .Select(category => category.Name) - .FirstOrDefault() - }) + .Select(MapLegacyList()) .ToList(); return Ok(tasks); } public IActionResult GetById(int id) { - TaskEntity task = context.Tasks.FirstOrDefault(x => x.Id == id); + TaskEntity? task = context.Tasks.FirstOrDefault(x => x.Id == id); if (task == null) return new NotFoundResult(); - TaskModel taskDetails = new TaskModel() + TaskModel taskDetails = new() { + Id = task.Id, Title = task.Title, Description = task.Description, CreatedAt = task.CreatedAt, - Done = task.Done + Done = task.Done, + CategorieTaskId = task.CategorieTaskId, + EstimateMinutes = task.EstimateMinutes, + SpentMinutes = task.SpentMinutes, + DueDate = task.DueDate, + RowVersion = Convert.ToBase64String(task.RowVersion) }; return Ok(taskDetails); } - public IActionResult InsertTask (TaskModel model, int userId) + public IActionResult InsertTask(TaskModel model, int userId) { try { @@ -124,15 +89,29 @@ public IActionResult InsertTask (TaskModel model, int userId) return BadRequest("Categoria ou modelo inválido."); } - TaskEntity newTask = new TaskEntity + var user = context.Users.FirstOrDefault(u => u.Id == userId); + var maxOrder = context.Tasks + .Where(t => t.CategorieTaskId == model.CategorieTaskId) + .Select(t => (int?)t.Order) + .Max() ?? -1; + + TaskEntity newTask = new() { Title = model.Title, Description = model.Description, Done = false, - CreatedAt = DateTime.Now, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + UpdatedBy = userId, CategorieTaskId = model.CategorieTaskId, Category = category, - UserId = userId + UserId = userId, + TenantId = user?.TenantId ?? 1, + Order = maxOrder + 1, + State = TaskState.Todo, + EstimateMinutes = model.EstimateMinutes, + SpentMinutes = model.SpentMinutes, + DueDate = model.DueDate }; var newId = _taskDal.InsertTask(newTask); @@ -141,14 +120,12 @@ public IActionResult InsertTask (TaskModel model, int userId) } catch (Exception ex) { - - Console.WriteLine($"Erro ao inserir tarefa: {ex}"); - + _logger.LogError(ex, "Erro ao inserir tarefa"); return StatusCode(500, "Ocorreu um erro ao criar a tarefa."); } } - public IActionResult EditTask (TaskModel model, int id) + public IActionResult EditTask(TaskModel model, int id) { var taskToEdit = context.Tasks.FirstOrDefault(x => x.Id == id); @@ -157,17 +134,19 @@ public IActionResult EditTask (TaskModel model, int id) return new NotFoundResult(); } - taskToEdit.Title = model.Title; taskToEdit.Description = model.Description; + taskToEdit.UpdatedAt = DateTime.UtcNow; + taskToEdit.UpdatedBy = model.UserId; _taskDal.UpdateTask(taskToEdit); return Ok(taskToEdit); } - public IActionResult DeleteTask (int id) + + public IActionResult DeleteTask(int id) { - TaskEntity taskToDelete = context.Tasks.FirstOrDefault(x => x.Id == id); + TaskEntity? taskToDelete = context.Tasks.FirstOrDefault(x => x.Id == id); if (taskToDelete == null) return new NotFoundResult(); @@ -175,9 +154,10 @@ public IActionResult DeleteTask (int id) return Ok(); } - public IActionResult DoneTask (int id) + + public IActionResult DoneTask(int id) { - TaskEntity task = context.Tasks.FirstOrDefault(x => x.Id == id); + TaskEntity? task = context.Tasks.FirstOrDefault(x => x.Id == id); if (task == null) return new BadRequestResult(); @@ -185,27 +165,233 @@ public IActionResult DoneTask (int id) return updated == null ? BadRequest() : Ok(updated); } - public IActionResult AsignTask (TaskModel model) + + public IActionResult AsignTask(TaskModel model) { var user = context.Users.FirstOrDefault(x => x.Id == model.UserId); var category = context.CategorieTasks.FirstOrDefault(x => x.Id == model.CategorieTaskId); if (user == null) return new BadRequestResult(); - TaskEntity taskToAsign = new TaskEntity() + TaskEntity taskToAsign = new() { Title = model.Title, Description = model.Description, Done = false, - CreatedAt = DateTime.Now, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + UpdatedBy = model.UserId, CategorieTaskId = model.CategorieTaskId, Category = category, - UserId = model.UserId + UserId = model.UserId, + TenantId = user.TenantId, + State = TaskState.Todo }; _taskDal.InsertTask(taskToAsign); return Ok(taskToAsign); } + + public async Task<(bool Success, IActionResult Result)> PatchTaskAsync(int id, TaskPatchRequest request, int userId, int tenantId, byte[]? ifMatchToken) + { + var task = await context.Tasks.FirstOrDefaultAsync(t => t.Id == id); + if (task == null) + return (false, NotFound(new ProblemDetails { Title = "Task não encontrada", Status = 404 })); + + if (!CanAccessTask(task, userId, tenantId)) + return (false, Forbid()); + + var expectedVersion = ifMatchToken ?? ParseRowVersion(request.RowVersion); + if (expectedVersion == null) + return (false, BadRequest(new ProblemDetails { Title = "RowVersion é obrigatória", Status = 400 })); + + if (!task.RowVersion.SequenceEqual(expectedVersion)) + return (false, Conflict(BuildConcurrencyProblem(task))); + + if (request.Title != null) task.Title = request.Title; + if (request.Description != null) task.Description = request.Description; + if (request.Done.HasValue) task.Done = request.Done.Value; + if (request.CategorieTaskId.HasValue) task.CategorieTaskId = request.CategorieTaskId.Value; + if (request.Order.HasValue) task.Order = request.Order.Value; + if (!string.IsNullOrWhiteSpace(request.State) && Enum.TryParse(request.State, true, out var parsed)) + task.State = parsed; + if (request.EstimateMinutes.HasValue) task.EstimateMinutes = request.EstimateMinutes; + if (request.SpentMinutes.HasValue) task.SpentMinutes = request.SpentMinutes; + if (request.DueDate.HasValue) task.DueDate = request.DueDate; + + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = userId; + + _logger.LogInformation("Task {TaskId} patch by {UserId} tenant {TenantId}", id, userId, tenantId); + + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + await context.Entry(task).ReloadAsync(); + return (false, Conflict(BuildConcurrencyProblem(task))); + } + + return (true, Ok(ToTaskResponse(task))); + } + + public async Task<(bool Success, IActionResult Result)> MoveTaskAsync(int id, TaskMoveRequest request, int userId, int tenantId) + { + await using var trx = await context.Database.BeginTransactionAsync(); + + var task = await context.Tasks.FirstOrDefaultAsync(t => t.Id == id); + if (task == null) + return (false, NotFound(new ProblemDetails { Title = "Task não encontrada", Status = 404 })); + + if (!CanAccessTask(task, userId, tenantId)) + return (false, Forbid()); + + var expectedVersion = ParseRowVersion(request.RowVersion); + if (expectedVersion == null) + return (false, BadRequest(new ProblemDetails { Title = "RowVersion é obrigatória", Status = 400 })); + + if (!task.RowVersion.SequenceEqual(expectedVersion)) + return (false, Conflict(BuildConcurrencyProblem(task))); + + var sourceColumn = task.CategorieTaskId; + var sourceOrder = task.Order; + + var sourceTasks = await context.Tasks + .Where(t => t.CategorieTaskId == sourceColumn && t.Id != task.Id) + .OrderBy(t => t.Order) + .ToListAsync(); + + foreach (var sourceTask in sourceTasks.Where(x => x.Order > sourceOrder)) + { + sourceTask.Order -= 1; + } + + var targetTasks = await context.Tasks + .Where(t => t.CategorieTaskId == request.TargetColumnId && t.Id != task.Id) + .OrderBy(t => t.Order) + .ToListAsync(); + + var safeOrder = Math.Max(0, Math.Min(request.TargetOrder, targetTasks.Count)); + + foreach (var targetTask in targetTasks.Where(x => x.Order >= safeOrder)) + { + targetTask.Order += 1; + } + + task.CategorieTaskId = request.TargetColumnId; + task.Order = safeOrder; + if (!string.IsNullOrWhiteSpace(request.TargetState) && Enum.TryParse(request.TargetState, true, out var parsedState)) + task.State = parsedState; + task.Done = task.State == TaskState.Done; + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = userId; + + try + { + await context.SaveChangesAsync(); + await trx.CommitAsync(); + } + catch (DbUpdateConcurrencyException) + { + await context.Entry(task).ReloadAsync(); + return (false, Conflict(BuildConcurrencyProblem(task))); + } + + _logger.LogInformation("Task {TaskId} move from {SourceColumn} to {TargetColumn} by {UserId} tenant {TenantId}", id, sourceColumn, request.TargetColumnId, userId, tenantId); + + return (true, Ok(ToTaskResponse(task))); + } + + public async Task<(bool Success, IActionResult Result)> UpdateTaskTimeAsync(int id, TaskTimeRequest request, int userId, int tenantId) + { + var task = await context.Tasks.FirstOrDefaultAsync(t => t.Id == id); + if (task == null) + return (false, NotFound(new ProblemDetails { Title = "Task não encontrada", Status = 404 })); + + if (!CanAccessTask(task, userId, tenantId)) + return (false, Forbid()); + + var expectedVersion = ParseRowVersion(request.RowVersion); + if (expectedVersion == null) + return (false, BadRequest(new ProblemDetails { Title = "RowVersion é obrigatória", Status = 400 })); + + if (!task.RowVersion.SequenceEqual(expectedVersion)) + return (false, Conflict(BuildConcurrencyProblem(task))); + + if (request.EstimateMinutes.HasValue) task.EstimateMinutes = request.EstimateMinutes; + if (request.SpentMinutes.HasValue) task.SpentMinutes = request.SpentMinutes; + if (request.DueDate.HasValue) task.DueDate = request.DueDate; + + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = userId; + + await context.SaveChangesAsync(); + + return (true, Ok(ToTaskResponse(task))); + } + + public async Task GetBoardTasksAsync(int boardId, int userId, int tenantId) + { + var tasks = await context.Tasks + .Where(t => t.CategorieTaskId == boardId && t.TenantId == tenantId && t.UserId == userId) + .OrderBy(t => t.Order) + .Select(t => ToTaskResponse(t)) + .ToListAsync(); + + return Ok(tasks); + } + + public static byte[]? ParseRowVersion(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) return null; + try { return Convert.FromBase64String(base64); } + catch { return null; } + } + + private bool CanAccessTask(TaskEntity task, int userId, int tenantId) + => task.UserId == userId && task.TenantId == tenantId; + + private static ProblemDetails BuildConcurrencyProblem(TaskEntity task) + => new() + { + Title = "Conflito de concorrência", + Detail = "A tarefa foi alterada por outro processo.", + Status = 409, + Extensions = { ["currentRowVersion"] = Convert.ToBase64String(task.RowVersion) } + }; + + private static TaskResponse ToTaskResponse(TaskEntity t) => new() + { + Id = t.Id, + Title = t.Title, + Description = t.Description, + Done = t.Done, + CreatedAt = t.CreatedAt, + FinishedAt = t.FinishedAt, + UpdatedAt = t.UpdatedAt, + UpdatedBy = t.UpdatedBy, + CategorieTaskId = t.CategorieTaskId, + Order = t.Order, + State = t.State.ToString(), + EstimateMinutes = t.EstimateMinutes, + SpentMinutes = t.SpentMinutes, + DueDate = t.DueDate, + RowVersion = Convert.ToBase64String(t.RowVersion) + }; + + private static System.Linq.Expressions.Expression> MapLegacyList() + => task => new + { + TaskId = task.Id, + TaskTitle = task.Title, + TaskDescription = task.Description, + Done = task.Done, + CreatedAt = task.CreatedAt, + State = task.State, + Order = task.Order + }; } } diff --git a/Todo.Tests/Services/TaskManagerServicesTests.cs b/Todo.Tests/Services/TaskManagerServicesTests.cs index 0439528..c7f3aa9 100644 --- a/Todo.Tests/Services/TaskManagerServicesTests.cs +++ b/Todo.Tests/Services/TaskManagerServicesTests.cs @@ -6,7 +6,7 @@ using Todo.Models; using Todo.Domain; using Microsoft.AspNetCore.Mvc; -using System.Linq; +using Microsoft.Extensions.Logging.Abstractions; namespace Todo.Tests.Services { @@ -15,7 +15,7 @@ public class TaskManagerServicesTests private AppDbContext GetContext() { var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: System.Guid.NewGuid().ToString()) + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; return new AppDbContext(options); } @@ -25,18 +25,75 @@ public void InsertTask_Should_Add_Task_And_Return_Ok() { using var context = GetContext(); context.CategorieTasks.Add(new CategorieTaskEntity { Id = 1, Name = "Test" }); + context.Users.Add(new UserEntity { Id = 1, Username = "user", Password = "pwd", IsAdmin = false, IsLogged = true, ProfilePicture = Array.Empty(), TenantId = 1 }); context.SaveChanges(); var taskDal = new TaskDal(context); - var service = new TaskManagerServices(context, taskDal); + var service = new TaskManagerServices(context, taskDal, NullLogger.Instance); var model = new TaskModel { Title = "Sample", Description = "Desc", CategorieTaskId = 1 }; var result = service.InsertTask(model, 1); - var okResult = Assert.IsType(result); + Assert.IsType(result); Assert.Equal(1, context.Tasks.Count()); var task = context.Tasks.First(); Assert.Equal("Sample", task.Title); + Assert.Equal(TaskState.Todo, task.State); + } + + [Fact] + public async Task MoveTaskAsync_Should_Reorder_And_Move_Task() + { + using var context = GetContext(); + context.Tasks.AddRange( + new TaskEntity { Id = 1, Title = "T1", CategorieTaskId = 10, UserId = 7, TenantId = 3, Order = 0, RowVersion = Convert.FromBase64String("AQIDBA==") }, + new TaskEntity { Id = 2, Title = "T2", CategorieTaskId = 10, UserId = 7, TenantId = 3, Order = 1, RowVersion = Convert.FromBase64String("AgMEBQ==") }, + new TaskEntity { Id = 3, Title = "T3", CategorieTaskId = 20, UserId = 7, TenantId = 3, Order = 0, RowVersion = Convert.FromBase64String("AwQFBg==") } + ); + await context.SaveChangesAsync(); + + var service = new TaskManagerServices(context, new TaskDal(context), NullLogger.Instance); + var req = new TaskMoveRequest + { + TargetColumnId = 20, + TargetOrder = 0, + TargetState = "InProgress", + RowVersion = Convert.ToBase64String(context.Tasks.First(t => t.Id == 1).RowVersion) + }; + + var (_, result) = await service.MoveTaskAsync(1, req, 7, 3); + Assert.IsType(result); + + var moved = context.Tasks.First(t => t.Id == 1); + var sourceRemaining = context.Tasks.First(t => t.Id == 2); + var targetExisting = context.Tasks.First(t => t.Id == 3); + + Assert.Equal(20, moved.CategorieTaskId); + Assert.Equal(0, moved.Order); + Assert.Equal(0, sourceRemaining.Order); + Assert.Equal(1, targetExisting.Order); + } + + [Fact] + public async Task PatchTaskAsync_Should_Return_Conflict_When_RowVersion_Differs() + { + using var context = GetContext(); + context.Tasks.Add(new TaskEntity + { + Id = 99, + Title = "Concurrency", + CategorieTaskId = 1, + UserId = 2, + TenantId = 2, + RowVersion = Convert.FromBase64String("AQID") + }); + await context.SaveChangesAsync(); + + var service = new TaskManagerServices(context, new TaskDal(context), NullLogger.Instance); + var patch = new TaskPatchRequest { Title = "Changed", RowVersion = Convert.ToBase64String(Convert.FromBase64String("BAUG")) }; + + var (_, result) = await service.PatchTaskAsync(99, patch, 2, 2, null); + Assert.IsType(result); } } } diff --git a/Todo.Tests/Todo.Tests.csproj b/Todo.Tests/Todo.Tests.csproj index 29f0991..50d6d38 100644 --- a/Todo.Tests/Todo.Tests.csproj +++ b/Todo.Tests/Todo.Tests.csproj @@ -1,6 +1,6 @@ - net8.0 + net7.0 false diff --git a/appsettings.Development.json b/appsettings.Development.json index 0c208ae..1ec3092 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -1,4 +1,15 @@ { + "ConnectionStrings": { + "DefaultConnection": "Server=localhost\\SQLEXPRESS01;Database=TaskManagerPro;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "Jwt": { + "Issuer": "TaskManager", + "Audience": "TaskManagerClient", + "Secret": "AUTH_JWT_KEY_PLACEHOLDER_CHANGE_ME" + }, + "Features": { + "AutosaveEnabled": true + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/appsettings.json b/appsettings.json index 10f68b8..4a966a7 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,4 +1,15 @@ { + "ConnectionStrings": { + "DefaultConnection": "Server=localhost\\SQLEXPRESS01;Database=TaskManagerPro;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "Jwt": { + "Issuer": "TaskManager", + "Audience": "TaskManagerClient", + "Secret": "AUTH_JWT_KEY_PLACEHOLDER_CHANGE_ME" + }, + "Features": { + "AutosaveEnabled": true + }, "Logging": { "LogLevel": { "Default": "Information",