Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c6760d7
Initial plan
Copilot Feb 12, 2026
9b2a7ef
Add domain entities for archive, chat, and ops CLI features
Copilot Feb 12, 2026
aabd795
Add repository interfaces for new domain entities
Copilot Feb 12, 2026
db43587
Implement repository classes for new entities
Copilot Feb 12, 2026
5514aa1
Add EF Core entity configurations for new entities and fix repository…
Copilot Feb 12, 2026
73c1202
Fix operator precedence bug in UserContext authentication check
Copilot Feb 12, 2026
6db8a88
Change ID fields from string to Guid in new domain entities
Copilot Feb 12, 2026
afdcd3e
Add database migration for automation, archive, chat, and ops entities
Copilot Feb 12, 2026
8239f1e
Implement AutomationProposalService with DTOs and comprehensive tests
Copilot Feb 12, 2026
8887c02
Implement ArchiveRecoveryService with comprehensive tests
Copilot Feb 12, 2026
ad071d6
Implement core automation services: PolicyEngine, Planner, and Executor
Copilot Feb 12, 2026
b53c679
Add API controllers for AutomationProposals and Archive with integrat…
Copilot Feb 12, 2026
89c8c05
Add limit parameter to GetByBoardIdAsync
Chris0Jeky Feb 13, 2026
16f45d9
Add GetArchiveItemByEntityAsync method
Chris0Jeky Feb 13, 2026
2df3e7b
Include Operations in AutomationProposal queries
Chris0Jeky Feb 13, 2026
80862a9
Set default limit and refine proposal filters
Chris0Jeky Feb 13, 2026
198716e
Add entity lookup, validations, and restore checks
Chris0Jeky Feb 13, 2026
8449bb2
Use proposal status for idempotency
Chris0Jeky Feb 13, 2026
fde4b69
Require auth; add executor and idempotency
Chris0Jeky Feb 13, 2026
344b70e
Require auth and use user context for restore
Chris0Jeky Feb 13, 2026
dea845d
Use interfaces for planner & executor DI
Chris0Jeky Feb 13, 2026
203c714
Include page size in GetByBoardIdAsync mock
Chris0Jeky Feb 13, 2026
0e1ab70
Register IAuthorizationService in DI container
Chris0Jeky Feb 13, 2026
bd1b04d
Use auth and owned boards in automation tests
Chris0Jeky Feb 13, 2026
7894b2c
Use auth helper in Archive API tests
Chris0Jeky Feb 13, 2026
944ac7a
Apply ordering/limit after query materialization
Chris0Jeky Feb 13, 2026
398e2fc
Generate diff preview from proposal operations
Chris0Jeky Feb 13, 2026
cdff00d
Add test for invalid archive restore type
Chris0Jeky Feb 13, 2026
2b2b6fd
Add API tests for automation proposals
Chris0Jeky Feb 13, 2026
861b375
Add test for failed authorization during restore
Chris0Jeky Feb 13, 2026
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
215 changes: 215 additions & 0 deletions backend/src/Taskdeck.Api/Controllers/ArchiveController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Exceptions;

namespace Taskdeck.Api.Controllers;

/// <summary>
/// API endpoints for managing archived items and restoring them.
/// </summary>
[ApiController]
[Authorize]
[Route("api/archive")]
public class ArchiveController : ControllerBase
{
private readonly IArchiveRecoveryService _archiveService;
private readonly IUserContext _userContext;

public ArchiveController(
IArchiveRecoveryService archiveService,
IUserContext userContext)
{
_archiveService = archiveService;
_userContext = userContext;
}

/// <summary>
/// Gets a list of archived items with optional filters.
/// </summary>
/// <param name="entityType">Filter by entity type (board, column, card)</param>
/// <param name="boardId">Filter by board ID</param>
/// <param name="status">Filter by restore status</param>
/// <param name="limit">Maximum number of results (default: 100)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of archive items</returns>
[HttpGet("items")]
public async Task<IActionResult> GetArchiveItems(
[FromQuery] string? entityType,
[FromQuery] Guid? boardId,
[FromQuery] RestoreStatus? status,
[FromQuery] int limit = 100,
CancellationToken cancellationToken = default)
{
var result = await _archiveService.GetArchiveItemsAsync(
entityType,
boardId,
status,
limit,
cancellationToken);

if (result.IsSuccess)
return Ok(result.Value);

return result.ErrorCode switch
{
"NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
_ => Problem(result.ErrorMessage, statusCode: 500)
};
}

/// <summary>
/// Gets a specific archive item by ID.
/// </summary>
/// <param name="id">Archive item ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Archive item details</returns>
[HttpGet("items/{id}")]
public async Task<IActionResult> GetArchiveItem(Guid id, CancellationToken cancellationToken = default)
{
var result = await _archiveService.GetArchiveItemByIdAsync(id, cancellationToken);

if (!result.IsSuccess)
{
return result.ErrorCode == "NotFound"
? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage })
: Problem(result.ErrorMessage, statusCode: 500);
}

return Ok(result.Value);
}

/// <summary>
/// Restores an archived item.
/// </summary>
/// <param name="entityType">Entity type (board, column, card)</param>
/// <param name="entityId">Entity ID to restore</param>
/// <param name="dto">Restore options</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Restore result</returns>
[HttpPost("{entityType}/{entityId}/restore")]
public async Task<IActionResult> RestoreArchivedItem(
string entityType,
Guid entityId,
[FromBody] RestoreArchiveItemDto dto,
CancellationToken cancellationToken = default)
{
if (!TryNormalizeEntityType(entityType, out var normalizedEntityType, out var invalidTypeResult))
return invalidTypeResult!;

if (!TryGetCurrentUserId(out var restoredByUserId, out var userErrorResult))
return userErrorResult!;

var archiveItemResult = await _archiveService.GetArchiveItemByEntityAsync(
normalizedEntityType,
entityId,
cancellationToken);
Comment on lines +96 to +110
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

entityType is used as-is for filtering, but ArchiveItem enforces lowercase values ("board", "column", "card"). Routes/docs suggest values like "Card", which will never match and makes restore brittle/case-sensitive. Normalize entityType (e.g., ToLowerInvariant()) and/or validate against the allowed set before querying.

Copilot uses AI. Check for mistakes.

if (!archiveItemResult.IsSuccess)
{
return archiveItemResult.ErrorCode switch
{
"NotFound" => NotFound(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
"ValidationError" => BadRequest(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
"AuthenticationFailed" => Unauthorized(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
"Unauthorized" => Unauthorized(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
"Forbidden" => StatusCode(403, new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
_ => Problem(archiveItemResult.ErrorMessage, statusCode: 500)
};
}

var archiveItem = archiveItemResult.Value;
if (archiveItem.RestoreStatus != RestoreStatus.Available)
{
return Conflict(new
{
errorCode = ErrorCodes.InvalidOperation,
message = $"Archive item for {normalizedEntityType} with ID {entityId} is in status {archiveItem.RestoreStatus}"
});
}

var result = await _archiveService.RestoreArchiveItemAsync(
archiveItem.Id,
dto,
restoredByUserId,
cancellationToken);

if (!result.IsSuccess)
{
return result.ErrorCode switch
{
"NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

RestoreArchiveItemAsync can return Forbidden, InvalidOperation, and WipLimitExceeded, but the controller only maps NotFound/ValidationError/Conflict. Unhandled error codes will become 500 responses, which makes API behavior inconsistent and breaks clients/tests that expect 403/409/etc. Add explicit mappings for these domain error codes.

Suggested change
"Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"Forbidden" => StatusCode(StatusCodes.Status403Forbidden, new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"InvalidOperation" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"WipLimitExceeded" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),

Copilot uses AI. Check for mistakes.
ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
"Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
_ => Problem(result.ErrorMessage, statusCode: 500)
};
}

return Ok(result.Value);
}

private static bool TryNormalizeEntityType(string entityType, out string normalizedEntityType, out IActionResult? errorResult)
{
normalizedEntityType = string.Empty;
errorResult = null;

if (string.IsNullOrWhiteSpace(entityType))
{
errorResult = new BadRequestObjectResult(new
{
errorCode = ErrorCodes.ValidationError,
message = "EntityType is required"
});
return false;
}

normalizedEntityType = entityType.Trim().ToLowerInvariant();
if (normalizedEntityType != "board" && normalizedEntityType != "column" && normalizedEntityType != "card")
{
errorResult = new BadRequestObjectResult(new
{
errorCode = ErrorCodes.ValidationError,
message = "EntityType must be 'board', 'column', or 'card'"
});
return false;
}

return true;
}

private bool TryGetCurrentUserId(out Guid userId, out IActionResult? errorResult)
{
userId = Guid.Empty;
errorResult = null;

if (!_userContext.IsAuthenticated || string.IsNullOrWhiteSpace(_userContext.UserId))
{
errorResult = Unauthorized(new
{
errorCode = ErrorCodes.AuthenticationFailed,
message = "Authenticated user context is required"
});
return false;
}

if (!Guid.TryParse(_userContext.UserId, out userId))
{
errorResult = Unauthorized(new
{
errorCode = ErrorCodes.AuthenticationFailed,
message = "Authenticated user id claim is invalid"
});
return false;
}

return true;
}
}
Loading
Loading