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)}");
+ }
+ }
}