diff --git a/Controllers/WorkshopsController.cs b/Controllers/WorkshopsController.cs index aaee027..8b37c04 100644 --- a/Controllers/WorkshopsController.cs +++ b/Controllers/WorkshopsController.cs @@ -16,25 +16,198 @@ public class WorkshopsController : ControllerBase /// Lista workshops com filtros opcionais. [HttpGet] - public Task GetAll([FromQuery] DateTimeOffset? from, [FromQuery] DateTimeOffset? to, [FromQuery] string? q, CancellationToken ct) + public async Task GetAll([FromQuery] DateTimeOffset? from, [FromQuery] DateTimeOffset? to, [FromQuery] string? q, CancellationToken ct) { - // TODO: implementar usando _repo.GetAllAsync e mapear para WorkshopResponse - return Task.FromResult(Ok(Array.Empty())); + var workshops = await _repo.GetAllAsync(from, to, q, ct); + + var response = workshops.Select(w => new WorkshopResponse( + w.Id, + w.Title, + w.Description, + w.StartAt, + w.EndAt, + w.Location, + w.Capacity, + w.IsOnline + )).ToList(); + + return Ok(response); } /// Obtém um workshop por Id. [HttpGet("{id:guid}")] - public Task GetById(Guid id, CancellationToken ct) + public async Task GetById(Guid id, CancellationToken ct) { - // TODO: implementar usando _repo.GetByIdAsync - return Task.FromResult(NotFound()); + var workshop = await _repo.GetByIdAsync(id, ct); + + if (workshop == null) + { + return NotFound(); + } + + var response = new WorkshopResponse( + workshop.Id, + workshop.Title, + workshop.Description, + workshop.StartAt, + workshop.EndAt, + workshop.Location, + workshop.Capacity, + workshop.IsOnline + ); + + return Ok(response); } /// Cria um novo workshop. [HttpPost] - public Task Create([FromBody] CreateWorkshopRequest body, CancellationToken ct) + public async Task Create([FromBody] CreateWorkshopRequest body, CancellationToken ct) { - // TODO: validar ModelState, regras de negócio e chamar _repo.AddAsync; retornar CreatedAtAction - return Task.FromResult(BadRequest()); + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + // Validações de negócio + if (body.StartAt >= body.EndAt) + { + return BadRequest("Data de início deve ser anterior à data de fim."); + } + + if (body.StartAt < DateTimeOffset.UtcNow) + { + return BadRequest("Data de início deve ser no futuro."); + } + + if (!body.IsOnline && string.IsNullOrWhiteSpace(body.Location)) + { + return BadRequest("Location é obrigatório para workshops presenciais."); + } + + if (body.Capacity < 1) + { + return BadRequest("Capacidade deve ser pelo menos 1."); + } + + var workshop = new Workshop + { + Id = Guid.NewGuid(), + Title = body.Title, + Description = body.Description, + StartAt = body.StartAt, + EndAt = body.EndAt, + Location = body.Location, + Capacity = body.Capacity, + IsOnline = body.IsOnline + }; + + var createdWorkshop = await _repo.AddAsync(workshop, ct); + + var response = new WorkshopResponse( + createdWorkshop.Id, + createdWorkshop.Title, + createdWorkshop.Description, + createdWorkshop.StartAt, + createdWorkshop.EndAt, + createdWorkshop.Location, + createdWorkshop.Capacity, + createdWorkshop.IsOnline + ); + + return CreatedAtAction(nameof(GetById), new { id = createdWorkshop.Id }, response); + } + + /// Atualiza parcialmente um workshop existente. + [HttpPatch("{id:guid}")] + public async Task Patch(Guid id, [FromBody] PatchWorkshopRequest body, CancellationToken ct) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var existingWorkshop = await _repo.GetByIdAsync(id, ct); + if (existingWorkshop == null) + { + return NotFound(); + } + + // Aplicar apenas os campos fornecidos + if (body.Title != null) + existingWorkshop.Title = body.Title; + + if (body.Description != null) + existingWorkshop.Description = body.Description; + + if (body.StartAt.HasValue) + { + if (body.StartAt.Value < DateTimeOffset.UtcNow) + { + return BadRequest("Data de início deve ser no futuro."); + } + existingWorkshop.StartAt = body.StartAt.Value; + } + + if (body.EndAt.HasValue) + existingWorkshop.EndAt = body.EndAt.Value; + + if (body.Location != null) + existingWorkshop.Location = body.Location; + + if (body.Capacity.HasValue) + { + if (body.Capacity.Value < 1) + { + return BadRequest("Capacidade deve ser pelo menos 1."); + } + existingWorkshop.Capacity = body.Capacity.Value; + } + + if (body.IsOnline.HasValue) + existingWorkshop.IsOnline = body.IsOnline.Value; + + // Validações de negócio após aplicar mudanças + if (existingWorkshop.StartAt >= existingWorkshop.EndAt) + { + return BadRequest("Data de início deve ser anterior à data de fim."); + } + + if (!existingWorkshop.IsOnline && string.IsNullOrWhiteSpace(existingWorkshop.Location)) + { + return BadRequest("Location é obrigatório para workshops presenciais."); + } + + var updatedWorkshop = await _repo.UpdateAsync(existingWorkshop, ct); + if (updatedWorkshop == null) + { + return NotFound(); + } + + var response = new WorkshopResponse( + updatedWorkshop.Id, + updatedWorkshop.Title, + updatedWorkshop.Description, + updatedWorkshop.StartAt, + updatedWorkshop.EndAt, + updatedWorkshop.Location, + updatedWorkshop.Capacity, + updatedWorkshop.IsOnline + ); + + return Ok(response); + } + + /// Remove um workshop. + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var workshop = await _repo.GetByIdAsync(id, ct); + if (workshop == null) + { + return NotFound(); + } + + await _repo.DeleteAsync(id, ct); + return NoContent(); } } diff --git a/Dtos/CreateWorkshopRequest.cs b/Dtos/CreateWorkshopRequest.cs index 7686335..86cd69d 100644 --- a/Dtos/CreateWorkshopRequest.cs +++ b/Dtos/CreateWorkshopRequest.cs @@ -1,13 +1,28 @@ +using System.ComponentModel.DataAnnotations; + namespace CampusWorkshops.Api.Dtos; -// DTO para criação de Workshop. +// DTO para criação de Workshop com validações public record CreateWorkshopRequest( - string? Title, + [Required(ErrorMessage = "Título é obrigatório")] + [StringLength(120, MinimumLength = 3, ErrorMessage = "Título deve ter entre 3 e 120 caracteres")] + string Title, + + [StringLength(2000, ErrorMessage = "Descrição não pode exceder 2000 caracteres")] string? Description, + + [Required(ErrorMessage = "Data de início é obrigatória")] DateTimeOffset StartAt, + + [Required(ErrorMessage = "Data de fim é obrigatória")] DateTimeOffset EndAt, + + [StringLength(200, ErrorMessage = "Localização não pode exceder 200 caracteres")] string? Location, + + [Range(1, 1000, ErrorMessage = "Capacidade deve estar entre 1 e 1000")] int Capacity, + bool IsOnline ); diff --git a/Dtos/PatchWorkshopRequest.cs b/Dtos/PatchWorkshopRequest.cs new file mode 100644 index 0000000..d1be497 --- /dev/null +++ b/Dtos/PatchWorkshopRequest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace CampusWorkshops.Api.Dtos; + +// DTO para atualização parcial de Workshop. +public record PatchWorkshopRequest( + [StringLength(120, MinimumLength = 3, ErrorMessage = "Título deve ter entre 3 e 120 caracteres")] + string? Title, + + [StringLength(2000, ErrorMessage = "Descrição não pode exceder 2000 caracteres")] + string? Description, + + DateTimeOffset? StartAt, + DateTimeOffset? EndAt, + + [StringLength(200, ErrorMessage = "Localização não pode exceder 200 caracteres")] + string? Location, + + [Range(1, 1000, ErrorMessage = "Capacidade deve estar entre 1 e 1000")] + int? Capacity, + + bool? IsOnline +); diff --git a/Models/Workshop.cs b/Models/Workshop.cs index 64a19d2..4f3816d 100644 --- a/Models/Workshop.cs +++ b/Models/Workshop.cs @@ -1,28 +1,32 @@ +using System.ComponentModel.DataAnnotations; + namespace CampusWorkshops.Api.Models; -// Modelo mínimo para Workshop.Completar as validações -// e ajustar os tipos/atributos durante a atividade. +// Modelo mínimo para Workshop com validações public class Workshop { // Identificador único public Guid Id { get; set; } = Guid.NewGuid(); - // TODO: adicionar [Required], [StringLength(120, MinimumLength = 3)] + [Required(ErrorMessage = "Título é obrigatório")] + [StringLength(120, MinimumLength = 3, ErrorMessage = "Título deve ter entre 3 e 120 caracteres")] public string? Title { get; set; } - // TODO: limitar tamanho (ex.: 2000) + [StringLength(2000, ErrorMessage = "Descrição não pode exceder 2000 caracteres")] public string? Description { get; set; } - // TODO: usar DateTimeOffset com formato ISO 8601 + [Required(ErrorMessage = "Data de início é obrigatória")] public DateTimeOffset StartAt { get; set; } + [Required(ErrorMessage = "Data de fim é obrigatória")] public DateTimeOffset EndAt { get; set; } - // Location deve ser obrigatório se IsOnline == false + // Location deve ser obrigatório se IsOnline == false (validado no controller) + [StringLength(200, ErrorMessage = "Localização não pode exceder 200 caracteres")] public string? Location { get; set; } - // TODO: validar Capacity >= 1 + [Range(1, 1000, ErrorMessage = "Capacidade deve estar entre 1 e 1000")] public int Capacity { get; set; } = 1; public bool IsOnline { get; set; } diff --git a/Program.cs b/Program.cs index 29f9166..7fa8063 100644 --- a/Program.cs +++ b/Program.cs @@ -1,10 +1,12 @@ // Note: repository implementation removed for workshop exercise (TODOs in project files) using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using CampusWorkshops.Api.Repositories; var builder = WebApplication.CreateBuilder(args); // Add services +builder.Services.AddSingleton(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => diff --git a/Repositories/IWorkshopRepository.cs b/Repositories/IWorkshopRepository.cs index ff8bfda..e36013f 100644 --- a/Repositories/IWorkshopRepository.cs +++ b/Repositories/IWorkshopRepository.cs @@ -9,4 +9,8 @@ public interface IWorkshopRepository Task> GetAllAsync(DateTimeOffset? from, DateTimeOffset? to, string? q, CancellationToken ct); Task GetByIdAsync(Guid id, CancellationToken ct); Task AddAsync(Workshop workshop, CancellationToken ct); + Task UpdateAsync(Workshop workshop, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); + Task ExistsAsync(Guid id, CancellationToken ct); + Task UpdatePartialAsync(Guid id, Action updateAction, CancellationToken ct); } diff --git a/Repositories/InMemoryWorkshopRepository.cs b/Repositories/InMemoryWorkshopRepository.cs index e887e44..cc831fc 100644 --- a/Repositories/InMemoryWorkshopRepository.cs +++ b/Repositories/InMemoryWorkshopRepository.cs @@ -6,26 +6,215 @@ namespace CampusWorkshops.Api.Repositories; // In-memory repository stub public class InMemoryWorkshopRepository : IWorkshopRepository { + List _workshops = new(); + + private void Seed() + { + // Base de datas: sempre a partir da próxima semana, para não “envelhecer” o seed + var baseDate = DateTimeOffset.UtcNow.Date.AddDays(7); + + var w1 = new Workshop + { + Id = Guid.Parse("8a0b9f1b-6c9b-4a2a-9d5e-1c2f3a4b5c6d"), + Title = "APIs RESTful — visão geral", + Description = "Modelagem de recursos, status codes e boas práticas.", + StartAt = baseDate.AddHours(12), // daqui a 7 dias, 12:00 UTC + EndAt = baseDate.AddHours(15), // 15:00 UTC + Location = "Lab 101", + Capacity = 25, + IsOnline = false + }; + + var w2 = new Workshop + { + Id = Guid.Parse("1c3d5e7f-9a2b-4c6d-8e0f-1a2b3c4d5e6f"), + Title = "Entity Framework — mapeamentos e LINQ", + Description = "Relações, consultas eficientes e boas práticas.", + StartAt = baseDate.AddDays(2).AddHours(12), // +2 dias, 12:00 UTC + EndAt = baseDate.AddDays(2).AddHours(14), // 14:00 UTC + Location = null, // online + Capacity = 40, + IsOnline = true + }; + + var w3 = new Workshop + { + Id = Guid.Parse("f0e1d2c3-b4a5-6789-9a8b-7c6d5e4f3a2b"), + Title = "Construindo contratos com Swagger/OpenAPI", + Description = "Documentação viva, exemplos e testes via Swagger UI.", + StartAt = baseDate.AddDays(5).AddHours(13), // +5 dias, 13:00 UTC + EndAt = baseDate.AddDays(5).AddHours(16), // 16:00 UTC + Location = "Auditório Central", + Capacity = 80, + IsOnline = false + }; + + _workshops.Add(w1); + _workshops.Add(w2); + _workshops.Add(w3); + } + + public InMemoryWorkshopRepository() { - // TODO: Adicionar workshops iniciais + Seed(); } public Task> GetAllAsync(DateTimeOffset? from, DateTimeOffset? to, string? q, CancellationToken ct) { - // TODO: retornar uma lista filtrada e ordenada por StartAt - return Task.FromResult>(Array.Empty()); + var query = _workshops.AsQueryable(); + + // Filtro por data início (from) + if (from.HasValue) + { + query = query.Where(w => w.StartAt >= from.Value); + } + + // Filtro por data fim (to) + if (to.HasValue) + { + query = query.Where(w => w.StartAt <= to.Value); + } + + // Filtro por texto (q) - busca no título e descrição + if (!string.IsNullOrWhiteSpace(q)) + { + var searchTerm = q.Trim().ToLowerInvariant(); + query = query.Where(w => + (w.Title != null && w.Title.ToLowerInvariant().Contains(searchTerm)) || + (w.Description != null && w.Description.ToLowerInvariant().Contains(searchTerm)) + ); + } + + // Ordenar por data de início + var result = query.OrderBy(w => w.StartAt).ToList(); + + return Task.FromResult>(result); } public Task GetByIdAsync(Guid id, CancellationToken ct) { - // TODO: buscar por id - return Task.FromResult(null); + var workshop = _workshops.FirstOrDefault(w => w.Id == id); + return Task.FromResult(workshop); } public Task AddAsync(Workshop workshop, CancellationToken ct) { - // TODO: adicionar à lista em memória e retornar criado + // Gerar novo ID se não foi fornecido ou se já existe + if (workshop.Id == Guid.Empty || _workshops.Any(w => w.Id == workshop.Id)) + { + workshop.Id = Guid.NewGuid(); + } + + // Validações de negócio que serão úteis quando migrar para banco + ValidateWorkshop(workshop); + + _workshops.Add(workshop); return Task.FromResult(workshop); } + + public Task UpdateAsync(Workshop workshop, CancellationToken ct) + { + var existingIndex = _workshops.FindIndex(w => w.Id == workshop.Id); + if (existingIndex < 0) + { + return Task.FromResult(null); + } + + // Validações de negócio + ValidateWorkshop(workshop); + + _workshops[existingIndex] = workshop; + return Task.FromResult(workshop); + } + + public Task DeleteAsync(Guid id, CancellationToken ct) + { + var workshop = _workshops.FirstOrDefault(w => w.Id == id); + if (workshop == null) + { + return Task.FromResult(false); + } + + _workshops.Remove(workshop); + return Task.FromResult(true); + } + + public Task ExistsAsync(Guid id, CancellationToken ct) + { + var exists = _workshops.Any(w => w.Id == id); + return Task.FromResult(exists); + } + + public Task UpdatePartialAsync(Guid id, Action updateAction, CancellationToken ct) + { + var workshop = _workshops.FirstOrDefault(w => w.Id == id); + if (workshop == null) + { + return Task.FromResult(null); + } + + // Aplicar as modificações + updateAction(workshop); + + // Validar após modificações + ValidateWorkshop(workshop); + + return Task.FromResult(workshop); + } + + private void ValidateWorkshop(Workshop workshop) + { + var errors = new List(); + + // Validação de título + if (string.IsNullOrWhiteSpace(workshop.Title)) + { + errors.Add("Title is required"); + } + else if (workshop.Title.Length < 3 || workshop.Title.Length > 120) + { + errors.Add("Title must be between 3 and 120 characters"); + } + + // Validação de descrição + if (!string.IsNullOrWhiteSpace(workshop.Description) && workshop.Description.Length > 2000) + { + errors.Add("Description cannot exceed 2000 characters"); + } + + // Validação de datas + if (workshop.EndAt <= workshop.StartAt) + { + errors.Add("End date must be after start date"); + } + + if (workshop.StartAt <= DateTimeOffset.UtcNow) + { + errors.Add("Start date must be in the future"); + } + + // Validação de capacidade + if (workshop.Capacity < 1) + { + errors.Add("Capacity must be at least 1"); + } + + // Validação de localização para workshops presenciais + if (!workshop.IsOnline && string.IsNullOrWhiteSpace(workshop.Location)) + { + errors.Add("Location is required for in-person workshops"); + } + + // Validação de localização para workshops online + if (workshop.IsOnline && !string.IsNullOrWhiteSpace(workshop.Location)) + { + errors.Add("Location should not be specified for online workshops"); + } + + if (errors.Any()) + { + throw new ArgumentException($"Workshop validation failed: {string.Join(", ", errors)}"); + } + } }