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
4 changes: 3 additions & 1 deletion src/Api/Data/IssueRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ public static IssueEntity FromDomain(Issue issue)
Status = issue.Status.ToString(),
CreatedAt = issue.CreatedAt,
UpdatedAt = issue.UpdatedAt,
IsArchived = false,
IsArchived = issue.IsArchived,
ArchivedBy = issue.ArchivedBy,
ArchivedAt = issue.ArchivedAt,
Labels = issue.Labels?.Select(l => new LabelEntity { Name = l.Name, Color = l.Color }).ToList()
};
}
Expand Down
19 changes: 17 additions & 2 deletions src/Api/Handlers/DeleteIssueHandler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using FluentValidation;
using IssueManager.Api.Data;
using IssueManager.Shared.Validators;
using global::Shared.Exceptions;

namespace IssueManager.Api.Handlers;

Expand Down Expand Up @@ -33,7 +34,21 @@ public async Task<bool> Handle(DeleteIssueCommand command, CancellationToken can
throw new ValidationException(validationResult.Errors);
}

// Archive the issue (soft-delete)
return await _repository.ArchiveAsync(command.Id, cancellationToken);
// Get the existing issue
var existingIssue = await _repository.GetByIdAsync(command.Id, cancellationToken);
if (existingIssue is null)
{
throw new NotFoundException($"Issue with ID '{command.Id}' was not found.");
}

// If already archived, this is idempotent - return success without updating
if (existingIssue.IsArchived)
{
return true;
}

// Archive the issue via the dedicated archive operation
await _repository.ArchiveAsync(command.Id, cancellationToken);
return true;
}
}
20 changes: 16 additions & 4 deletions src/Api/Handlers/UpdateIssueHandler.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
using FluentValidation;
using IssueManager.Api.Data;
using IssueManager.Shared.Domain;
using global::Shared.Domain;
using IssueManager.Shared.Validators;
using global::Shared.Exceptions;

namespace IssueManager.Api.Handlers;

Expand All @@ -25,7 +26,7 @@ public UpdateIssueHandler(IIssueRepository repository, UpdateIssueValidator vali
/// <summary>
/// Handles the update of an existing issue.
/// </summary>
public async Task<Issue?> Handle(UpdateIssueCommand command, CancellationToken cancellationToken = default)
public async Task<Issue> Handle(UpdateIssueCommand command, CancellationToken cancellationToken = default)
{
Comment on lines +29 to 30
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle now returns Task<Issue> (non-nullable), but the repository contract (IIssueRepository.UpdateAsync) returns Task<Issue?>. Since the implementation returns null when ModifiedCount == 0, this handler can still end up returning null despite the signature. Consider either making UpdateAsync return a non-null Issue when the record exists, or keep the handler return type nullable / throw when the update result is null.

Copilot uses AI. Check for mistakes.
// Validate the command
var validationResult = await _validator.ValidateAsync(command, cancellationToken);
Expand All @@ -38,13 +39,24 @@ public UpdateIssueHandler(IIssueRepository repository, UpdateIssueValidator vali
var existingIssue = await _repository.GetByIdAsync(command.Id, cancellationToken);
if (existingIssue is null)
{
return null;
throw new NotFoundException($"Issue with ID '{command.Id}' was not found.");
}

// Cannot update an archived issue
if (existingIssue.IsArchived)
{
throw new ConflictException($"Issue with ID '{command.Id}' is archived and cannot be updated.");
}

// Update the issue using the domain method
var updatedIssue = existingIssue.Update(command.Title, command.Description);

// Persist the updated issue
return await _repository.UpdateAsync(updatedIssue, cancellationToken);
var result = await _repository.UpdateAsync(updatedIssue, cancellationToken);
if (result is null)
{
throw new NotFoundException($"Issue with ID '{command.Id}' could not be updated.");
}
return result;
}
}
22 changes: 18 additions & 4 deletions src/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using IssueManager.Api.Data;
using IssueManager.Api.Handlers;
using IssueManager.Shared.Validators;
using global::Shared.Validators;
using IssueManager.Shared.Domain.DTOs;
using static IssueManager.Api.Handlers.GetIssueHandler;

Expand Down Expand Up @@ -46,6 +47,18 @@
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
await context.Response.WriteAsJsonAsync(new { title = "Validation failed", errors });
}
else if (ex is global::Shared.Exceptions.NotFoundException notFoundEx)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new { title = "Not Found", detail = notFoundEx.Message });
}
else if (ex is global::Shared.Exceptions.ConflictException conflictEx)
{
context.Response.StatusCode = StatusCodes.Status409Conflict;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new { title = "Conflict", detail = conflictEx.Message });
}
else
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
Expand All @@ -56,7 +69,7 @@
app.MapOpenApi();

// Issue API Endpoints
var issuesApi = app.MapGroup("/api/v1/issues")

Check warning on line 72 in src/Api/Program.cs

View workflow job for this annotation

GitHub Actions / Build Solution

'OpenApiEndpointConventionBuilderExtensions.WithOpenApi<TBuilder>(TBuilder)' is obsolete: 'WithOpenApi is deprecated and will be removed in a future release. For more information, visit https://aka.ms/aspnet/deprecate/002.' (https://aka.ms/aspnet/deprecate/002)

Check warning on line 72 in src/Api/Program.cs

View workflow job for this annotation

GitHub Actions / Build Solution

'OpenApiEndpointConventionBuilderExtensions.WithOpenApi<TBuilder>(TBuilder)' is obsolete: 'WithOpenApi is deprecated and will be removed in a future release. For more information, visit https://aka.ms/aspnet/deprecate/002.' (https://aka.ms/aspnet/deprecate/002)
.WithTags("Issues")
.WithOpenApi();

Expand Down Expand Up @@ -100,20 +113,21 @@
{
var commandWithId = command with { Id = id };
var result = await handler.Handle(commandWithId);
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PATCH endpoint now always returns 200 OK (Results.Ok(result)) and relies on exceptions for 404/409. If UpdateIssueHandler can still return null (because UpdateAsync returns Issue?), this will produce a 200 with a null body instead of a proper error. Either ensure the handler/repository cannot return null on success, or reintroduce a null-to-404 mapping here.

Suggested change
var result = await handler.Handle(commandWithId);
var result = await handler.Handle(commandWithId);
if (result is null)
{
return Results.NotFound();
}

Copilot uses AI. Check for mistakes.
return result is not null ? Results.Ok(result) : Results.NotFound();
return Results.Ok(result);
})
.WithName("UpdateIssue")
.WithSummary("Update an existing issue")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);

// Delete Issue (soft-delete)
issuesApi.MapDelete("{id}", async (string id, DeleteIssueHandler handler) =>
{
var command = new DeleteIssueCommand { Id = id };
var result = await handler.Handle(command);
return result ? Results.NoContent() : Results.NotFound();
await handler.Handle(command);
return Results.NoContent();
})
.WithName("DeleteIssue")
.WithSummary("Delete (archive) an issue")
Expand Down
8 changes: 4 additions & 4 deletions src/Shared/Domain/Issue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,22 @@ public record Issue(
/// <summary>
/// Gets the detailed description of the issue.
/// </summary>
public string? Description { get; init; }
public string? Description { get; init; } = Description;

/// <summary>
/// Gets the current status of the issue.
/// </summary>
public IssueStatus Status { get; init; }
public IssueStatus Status { get; init; } = Status;

/// <summary>
/// Gets the timestamp when the issue was created.
/// </summary>
public DateTime CreatedAt { get; init; }
public DateTime CreatedAt { get; init; } = CreatedAt;

/// <summary>
/// Gets the timestamp when the issue was last updated.
/// </summary>
public DateTime UpdatedAt { get; init; }
public DateTime UpdatedAt { get; init; } = UpdatedAt;

/// <summary>
/// Gets the collection of labels attached to the issue.
Expand Down
2 changes: 1 addition & 1 deletion src/Web/Components/IssueForm.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@using Shared.Domain
@using global::Shared.Domain
@namespace IssueManager.Web.Components

<div class="form-container">
Expand Down
1 change: 1 addition & 0 deletions src/Web/_Imports.razor
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
@using IssueManager.Web.Layout
@using IssueManager.Web.Pages
@using IssueManager.Web.Components
@using global::Shared.Domain
Loading
Loading