diff --git a/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs new file mode 100644 index 000000000..55c0d1b28 --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs @@ -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; + +/// +/// API endpoints for managing archived items and restoring them. +/// +[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; + } + + /// + /// Gets a list of archived items with optional filters. + /// + /// Filter by entity type (board, column, card) + /// Filter by board ID + /// Filter by restore status + /// Maximum number of results (default: 100) + /// Cancellation token + /// List of archive items + [HttpGet("items")] + public async Task 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) + }; + } + + /// + /// Gets a specific archive item by ID. + /// + /// Archive item ID + /// Cancellation token + /// Archive item details + [HttpGet("items/{id}")] + public async Task 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); + } + + /// + /// Restores an archived item. + /// + /// Entity type (board, column, card) + /// Entity ID to restore + /// Restore options + /// Cancellation token + /// Restore result + [HttpPost("{entityType}/{entityId}/restore")] + public async Task 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); + + 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 }), + 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; + } +} diff --git a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs new file mode 100644 index 000000000..622983e1d --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs @@ -0,0 +1,295 @@ +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; + +/// +/// API endpoints for managing automation proposals and their lifecycle. +/// +[ApiController] +[Authorize] +[Route("api/automation/proposals")] +public class AutomationProposalsController : ControllerBase +{ + private readonly IAutomationProposalService _proposalService; + private readonly IAutomationExecutorService _executorService; + private readonly IUserContext _userContext; + + public AutomationProposalsController( + IAutomationProposalService proposalService, + IAutomationExecutorService executorService, + IUserContext userContext) + { + _proposalService = proposalService; + _executorService = executorService; + _userContext = userContext; + } + + /// + /// Gets a list of automation proposals with optional filters. + /// + /// Filter by proposal status + /// Filter by board ID + /// Filter by user ID + /// Filter by risk level + /// Maximum number of results (default: 100) + /// Cancellation token + /// List of proposals + [HttpGet] + public async Task GetProposals( + [FromQuery] ProposalStatus? status, + [FromQuery] Guid? boardId, + [FromQuery] Guid? userId, + [FromQuery] RiskLevel? riskLevel, + [FromQuery] int limit = 100, + CancellationToken cancellationToken = default) + { + var filter = new ProposalFilterDto(status, boardId, userId, riskLevel, limit); + var result = await _proposalService.GetProposalsAsync(filter, 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) + }; + } + + /// + /// Gets a specific automation proposal by ID with all operations. + /// + /// Proposal ID + /// Cancellation token + /// Proposal details + [HttpGet("{id}")] + public async Task GetProposal(Guid id, CancellationToken cancellationToken = default) + { + var result = await _proposalService.GetProposalByIdAsync(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); + } + + /// + /// Creates a new automation proposal with operations. + /// + /// Proposal creation request + /// Cancellation token + /// Created proposal + [HttpPost] + public async Task CreateProposal([FromBody] CreateProposalDto dto, CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var requestedByUserId, out var errorResult)) + return errorResult!; + + var createDto = dto with + { + RequestedByUserId = requestedByUserId + }; + + var result = await _proposalService.CreateProposalAsync(createDto, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode switch + { + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "NotFound" => NotFound(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 CreatedAtAction(nameof(GetProposal), new { id = result.Value.Id }, result.Value); + } + + /// + /// Approves a pending automation proposal. + /// + /// Proposal ID + /// Cancellation token + /// Updated proposal + [HttpPost("{id}/approve")] + public async Task ApproveProposal( + Guid id, + CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var decidedByUserId, out var errorResult)) + return errorResult!; + + var result = await _proposalService.ApproveProposalAsync(id, decidedByUserId, 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 }), + 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); + } + + /// + /// Rejects a pending automation proposal. + /// + /// Proposal ID + /// Rejection details (reason required for High/Critical risk) + /// Cancellation token + /// Updated proposal + [HttpPost("{id}/reject")] + public async Task RejectProposal( + Guid id, + [FromBody] UpdateProposalStatusDto dto, + CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var decidedByUserId, out var errorResult)) + return errorResult!; + + var result = await _proposalService.RejectProposalAsync(id, decidedByUserId, dto, 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 }), + 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); + } + + /// + /// Executes an approved automation proposal through the automation executor. + /// + /// Proposal ID + /// Cancellation token + /// Updated proposal + [HttpPost("{id}/execute")] + public async Task ExecuteProposal(Guid id, CancellationToken cancellationToken = default) + { + if (!Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyHeader) || + string.IsNullOrWhiteSpace(idempotencyHeader)) + { + return BadRequest(new + { + errorCode = ErrorCodes.ValidationError, + message = "Idempotency-Key header is required" + }); + } + + var executionResult = await _executorService.ExecuteProposalAsync(id, idempotencyHeader.ToString(), cancellationToken); + if (!executionResult.IsSuccess) + { + return executionResult.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "Conflict" => Conflict(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + ErrorCodes.InvalidOperation => Conflict(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + _ => Problem(executionResult.ErrorMessage, statusCode: 500) + }; + } + + var result = await _proposalService.GetProposalByIdAsync(id, 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 }), + 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); + } + + /// + /// Gets a diff preview for a proposal showing what changes will be made. + /// + /// Proposal ID + /// Cancellation token + /// Diff preview text + [HttpGet("{id}/diff")] + public async Task GetProposalDiff(Guid id, CancellationToken cancellationToken = default) + { + var result = await _proposalService.GetProposalDiffAsync(id, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode == "NotFound" + ? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }) + : Problem(result.ErrorMessage, statusCode: 500); + } + + return Ok(new { diff = result.Value }); + } + + 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; + } +} diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 6bfb64534..b18b58637 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -23,11 +23,21 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); 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(); + +// Add IUserContext for claim-based identity +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); // Add JwtSettings (required by AuthenticationService) var jwtSettings = builder.Configuration.GetSection("Jwt").Get() ?? new JwtSettings(); diff --git a/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs b/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs new file mode 100644 index 000000000..7982dd28c --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs @@ -0,0 +1,55 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.DTOs; + +public record CreateArchiveItemDto( + string EntityType, + Guid EntityId, + Guid BoardId, + string Name, + Guid ArchivedByUserId, + string SnapshotJson, + string? Reason +); + +public record ArchiveItemDto( + Guid Id, + string EntityType, + Guid EntityId, + Guid BoardId, + string Name, + Guid ArchivedByUserId, + DateTime ArchivedAt, + string? Reason, + RestoreStatus RestoreStatus, + DateTime? RestoredAt, + Guid? RestoredByUserId, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt +); + +public record RestoreArchiveItemDto( + Guid? TargetBoardId, + RestoreMode RestoreMode, + ConflictStrategy ConflictStrategy +); + +public record RestoreResult( + bool Success, + Guid? RestoredEntityId, + string? ErrorMessage, + string? ResolvedName +); + +public enum RestoreMode +{ + InPlace, + Copy +} + +public enum ConflictStrategy +{ + Fail, + Rename, + AppendSuffix +} diff --git a/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs b/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs new file mode 100644 index 000000000..947cb42b6 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs @@ -0,0 +1,71 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.DTOs; + +public record ProposalDto( + Guid Id, + ProposalSourceType SourceType, + string? SourceReferenceId, + Guid? BoardId, + Guid RequestedByUserId, + ProposalStatus Status, + RiskLevel RiskLevel, + string Summary, + string? DiffPreview, + string? ValidationIssues, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTime ExpiresAt, + DateTime? DecidedAt, + Guid? DecidedByUserId, + DateTime? AppliedAt, + string? FailureReason, + string CorrelationId, + List Operations +); + +public record ProposalOperationDto( + Guid Id, + Guid ProposalId, + int Sequence, + string ActionType, + string TargetType, + string? TargetId, + string Parameters, + string IdempotencyKey, + string? ExpectedVersion +); + +public record CreateProposalDto( + ProposalSourceType SourceType, + Guid RequestedByUserId, + string Summary, + RiskLevel RiskLevel, + string CorrelationId, + Guid? BoardId = null, + string? SourceReferenceId = null, + int ExpiryMinutes = 1440, + List? Operations = null +); + +public record CreateProposalOperationDto( + int Sequence, + string ActionType, + string TargetType, + string Parameters, + string IdempotencyKey, + string? TargetId = null, + string? ExpectedVersion = null +); + +public record UpdateProposalStatusDto( + string? Reason = null +); + +public record ProposalFilterDto( + ProposalStatus? Status = null, + Guid? BoardId = null, + Guid? UserId = null, + RiskLevel? RiskLevel = null, + int Limit = 100 +); diff --git a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs new file mode 100644 index 000000000..83109cac8 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs @@ -0,0 +1,12 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IArchiveItemRepository : IRepository +{ + Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByEntityTypeAsync(string entityType, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs new file mode 100644 index 000000000..4794910a4 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs @@ -0,0 +1,14 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IAutomationProposalRepository : IRepository +{ + Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default); + Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default); + Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); + Task> GetExpiredAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs new file mode 100644 index 000000000..66154c103 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs @@ -0,0 +1,9 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IChatMessageRepository : IRepository +{ + Task> GetBySessionIdAsync(Guid sessionId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByProposalIdAsync(Guid proposalId, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs new file mode 100644 index 000000000..e490cb499 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs @@ -0,0 +1,11 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IChatSessionRepository : IRepository +{ + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(ChatSessionStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs new file mode 100644 index 000000000..a6785ced4 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs @@ -0,0 +1,12 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface ICommandRunRepository : IRepository +{ + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(CommandRunStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByTemplateNameAsync(string templateName, int limit = 100, CancellationToken cancellationToken = default); + Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); + Task GetByIdWithLogsAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs index 0dcf90fe7..32ca3d45f 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs @@ -10,6 +10,11 @@ public interface IUnitOfWork IBoardAccessRepository BoardAccesses { get; } IAuditLogRepository AuditLogs { get; } ILlmQueueRepository LlmQueue { get; } + IAutomationProposalRepository AutomationProposals { get; } + IArchiveItemRepository ArchiveItems { get; } + IChatSessionRepository ChatSessions { get; } + IChatMessageRepository ChatMessages { get; } + ICommandRunRepository CommandRuns { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); Task BeginTransactionAsync(CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs b/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs new file mode 100644 index 000000000..13c2def2b --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs @@ -0,0 +1,25 @@ +namespace Taskdeck.Application.Interfaces; + +/// +/// Provides access to the current authenticated user's identity from JWT claims. +/// This is the single source of truth for actor identity - never trust client-supplied user IDs. +/// +public interface IUserContext +{ + /// + /// Gets the current authenticated user's ID from JWT claims. + /// Returns null if no user is authenticated. + /// + string? UserId { get; } + + /// + /// Gets whether a user is currently authenticated. + /// + bool IsAuthenticated { get; } + + /// + /// Gets the current user's role from JWT claims. + /// Returns null if no role is present. + /// + string? Role { get; } +} diff --git a/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs new file mode 100644 index 000000000..9f6a590d4 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs @@ -0,0 +1,553 @@ +using System.Text.Json; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class ArchiveRecoveryService : IArchiveRecoveryService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAuthorizationService? _authorizationService; + + public ArchiveRecoveryService( + IUnitOfWork unitOfWork, + IAuthorizationService? authorizationService = null) + { + _unitOfWork = unitOfWork; + _authorizationService = authorizationService; + } + + public async Task> CreateArchiveItemAsync( + CreateArchiveItemDto dto, + CancellationToken cancellationToken = default) + { + try + { + var archiveItem = new ArchiveItem( + dto.EntityType, + dto.EntityId, + dto.BoardId, + dto.Name, + dto.ArchivedByUserId, + dto.SnapshotJson, + dto.Reason); + + await _unitOfWork.ArchiveItems.AddAsync(archiveItem, cancellationToken); + + // Create audit log + var auditLog = new AuditLog( + "ArchiveItem", + archiveItem.Id, + AuditAction.Created, + dto.ArchivedByUserId, + $"Archived {dto.EntityType} '{dto.Name}' (ID: {dto.EntityId})"); + await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(archiveItem)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task>> GetArchiveItemsAsync( + string? entityType = null, + Guid? boardId = null, + RestoreStatus? status = null, + int limit = 100, + CancellationToken cancellationToken = default) + { + try + { + if (limit <= 0 || limit > 1000) + { + return Result.Failure>( + ErrorCodes.ValidationError, + "Limit must be between 1 and 1000"); + } + + if (!string.IsNullOrWhiteSpace(entityType)) + { + entityType = entityType.Trim().ToLowerInvariant(); + if (entityType != "board" && entityType != "column" && entityType != "card") + { + return Result.Failure>( + ErrorCodes.ValidationError, + "EntityType must be 'board', 'column', or 'card'"); + } + } + + IEnumerable items; + + if (entityType != null && boardId != null && status != null) + { + // Combined filter - need to implement custom query + var allItems = await _unitOfWork.ArchiveItems.GetAllAsync(cancellationToken); + items = allItems + .Where(i => i.EntityType == entityType + && i.BoardId == boardId + && i.RestoreStatus == status.Value) + .Take(limit); + } + else if (entityType != null) + { + items = await _unitOfWork.ArchiveItems.GetByEntityTypeAsync(entityType, limit, cancellationToken); + } + else if (boardId != null) + { + items = await _unitOfWork.ArchiveItems.GetByBoardIdAsync(boardId.Value, limit, cancellationToken); + } + else if (status != null) + { + items = await _unitOfWork.ArchiveItems.GetByStatusAsync(status.Value, limit, cancellationToken); + } + else + { + var allItems = await _unitOfWork.ArchiveItems.GetAllAsync(cancellationToken); + items = allItems.Take(limit); + } + + // Apply additional filters if needed + if (entityType != null && boardId == null && status != null) + { + items = items.Where(i => i.RestoreStatus == status.Value); + } + else if (entityType == null && boardId != null && status != null) + { + items = items.Where(i => i.RestoreStatus == status.Value); + } + else if (entityType != null && boardId != null && status == null) + { + items = items.Where(i => i.BoardId == boardId.Value); + } + + return Result.Success(items.Select(MapToDto)); + } + catch (Exception ex) + { + return Result.Failure>( + ErrorCodes.UnexpectedError, + $"Failed to retrieve archive items: {ex.Message}"); + } + } + + public async Task> GetArchiveItemByIdAsync( + Guid id, + CancellationToken cancellationToken = default) + { + var archiveItem = await _unitOfWork.ArchiveItems.GetByIdAsync(id, cancellationToken); + if (archiveItem == null) + return Result.Failure(ErrorCodes.NotFound, $"Archive item with ID {id} not found"); + + return Result.Success(MapToDto(archiveItem)); + } + + public async Task> GetArchiveItemByEntityAsync( + string entityType, + Guid entityId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(entityType)) + return Result.Failure(ErrorCodes.ValidationError, "EntityType cannot be empty"); + if (entityId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "EntityId cannot be empty"); + + var normalizedType = entityType.Trim().ToLowerInvariant(); + if (normalizedType != "board" && normalizedType != "column" && normalizedType != "card") + return Result.Failure(ErrorCodes.ValidationError, "EntityType must be 'board', 'column', or 'card'"); + + var archiveItem = await _unitOfWork.ArchiveItems.GetByEntityAsync(normalizedType, entityId, cancellationToken); + if (archiveItem == null) + return Result.Failure(ErrorCodes.NotFound, $"Archive item for {normalizedType} with entity ID {entityId} not found"); + + return Result.Success(MapToDto(archiveItem)); + } + + public async Task> RestoreArchiveItemAsync( + Guid id, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken = default) + { + try + { + // 1. Get archive item + var archiveItem = await _unitOfWork.ArchiveItems.GetByIdAsync(id, cancellationToken); + if (archiveItem == null) + return Result.Failure(ErrorCodes.NotFound, $"Archive item with ID {id} not found"); + + if (archiveItem.RestoreStatus != RestoreStatus.Available) + return Result.Failure( + ErrorCodes.InvalidOperation, + $"Cannot restore archive item with status {archiveItem.RestoreStatus}"); + + // 2. Determine target board + var targetBoardId = dto.TargetBoardId ?? archiveItem.BoardId; + + // 3. Check permissions + if (_authorizationService != null) + { + var canWriteResult = await _authorizationService.CanWriteBoardAsync(restoredByUserId, targetBoardId); + if (!canWriteResult.IsSuccess) + { + return Result.Failure( + canWriteResult.ErrorCode, + canWriteResult.ErrorMessage); + } + + if (!canWriteResult.Value) + { + return Result.Failure( + ErrorCodes.Forbidden, + "User does not have permission to restore to target board"); + } + } + + // 4. Validate and restore based on entity type + Result restoreResult; + switch (archiveItem.EntityType) + { + case "board": + restoreResult = await RestoreBoardAsync(archiveItem, dto, restoredByUserId, cancellationToken); + break; + case "column": + { + var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken); + if (targetBoard == null) + return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found"); + if (targetBoard.IsArchived) + return Result.Failure(ErrorCodes.InvalidOperation, "Cannot restore to an archived board"); + + restoreResult = await RestoreColumnAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken); + break; + } + case "card": + { + var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken); + if (targetBoard == null) + return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found"); + if (targetBoard.IsArchived) + return Result.Failure(ErrorCodes.InvalidOperation, "Cannot restore to an archived board"); + + restoreResult = await RestoreCardAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken); + break; + } + default: + return Result.Failure( + ErrorCodes.ValidationError, + $"Unknown entity type: {archiveItem.EntityType}"); + } + + if (!restoreResult.IsSuccess) + return restoreResult; + + // 6. Mark archive item as restored + archiveItem.MarkAsRestored(restoredByUserId); + + // 7. Create audit log + var auditLog = new AuditLog( + "ArchiveItem", + archiveItem.Id, + AuditAction.Updated, + restoredByUserId, + $"Restored {archiveItem.EntityType} '{restoreResult.Value.ResolvedName ?? archiveItem.Name}' " + + $"(Original ID: {archiveItem.EntityId}, Restored ID: {restoreResult.Value.RestoredEntityId})"); + await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return restoreResult; + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + catch (Exception ex) + { + return Result.Failure( + ErrorCodes.UnexpectedError, + $"Failed to restore archive item: {ex.Message}"); + } + } + + private async Task> RestoreBoardAsync( + ArchiveItem archiveItem, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken) + { + try + { + // Deserialize snapshot + var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson); + if (snapshot == null) + return Result.Failure( + ErrorCodes.ValidationError, + "Failed to deserialize board snapshot"); + + // Check for naming conflicts + var existingBoards = await _unitOfWork.Boards.SearchAsync(snapshot.Name, includeArchived: false, cancellationToken); + var conflictExists = existingBoards.Any(b => b.Name == snapshot.Name); + + string resolvedName = snapshot.Name; + if (conflictExists) + { + if (dto.ConflictStrategy == ConflictStrategy.Fail) + { + return Result.Failure( + ErrorCodes.Conflict, + $"A board with name '{snapshot.Name}' already exists"); + } + else if (dto.ConflictStrategy == ConflictStrategy.Rename) + { + resolvedName = $"{snapshot.Name} (Restored)"; + } + else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix) + { + resolvedName = $"{snapshot.Name} - {DateTime.UtcNow:yyyyMMdd-HHmmss}"; + } + } + + // For InPlace mode, unarchive existing board if it's archived + if (dto.RestoreMode == RestoreMode.InPlace) + { + var existingBoard = await _unitOfWork.Boards.GetByIdAsync(archiveItem.EntityId, cancellationToken); + if (existingBoard != null && existingBoard.IsArchived) + { + existingBoard.Unarchive(); + existingBoard.Update(resolvedName, snapshot.Description); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(new RestoreResult( + true, + existingBoard.Id, + null, + resolvedName)); + } + } + + // Create new board (Copy mode or InPlace when original doesn't exist) + var newBoard = new Board(resolvedName, snapshot.Description, restoredByUserId); + await _unitOfWork.Boards.AddAsync(newBoard, cancellationToken); + + return Result.Success(new RestoreResult( + true, + newBoard.Id, + null, + resolvedName)); + } + catch (JsonException ex) + { + return Result.Failure( + ErrorCodes.ValidationError, + $"Invalid snapshot format: {ex.Message}"); + } + } + + private async Task> RestoreColumnAsync( + ArchiveItem archiveItem, + Guid targetBoardId, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken) + { + try + { + // Deserialize snapshot + var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson); + if (snapshot == null) + return Result.Failure( + ErrorCodes.ValidationError, + "Failed to deserialize column snapshot"); + + // Get board and existing columns + var board = await _unitOfWork.Boards.GetByIdWithDetailsAsync(targetBoardId, cancellationToken); + if (board == null) + return Result.Failure(ErrorCodes.NotFound, $"Board with ID {targetBoardId} not found"); + + // Check for naming conflicts + var conflictExists = board.Columns.Any(c => c.Name == snapshot.Name); + + string resolvedName = snapshot.Name; + if (conflictExists) + { + if (dto.ConflictStrategy == ConflictStrategy.Fail) + { + return Result.Failure( + ErrorCodes.Conflict, + $"A column with name '{snapshot.Name}' already exists"); + } + else if (dto.ConflictStrategy == ConflictStrategy.Rename) + { + resolvedName = $"{snapshot.Name} (Restored)"; + } + else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix) + { + resolvedName = $"{snapshot.Name} - {DateTime.UtcNow:yyyyMMdd-HHmmss}"; + } + } + + // Determine position (add to end) + var maxPosition = board.Columns.Any() ? board.Columns.Max(c => c.Position) : -1; + var newPosition = maxPosition + 1; + + // Create new column + var newColumn = new Column(targetBoardId, resolvedName, newPosition, snapshot.WipLimit); + await _unitOfWork.Columns.AddAsync(newColumn, cancellationToken); + + return Result.Success(new RestoreResult( + true, + newColumn.Id, + null, + resolvedName)); + } + catch (JsonException ex) + { + return Result.Failure( + ErrorCodes.ValidationError, + $"Invalid snapshot format: {ex.Message}"); + } + } + + private async Task> RestoreCardAsync( + ArchiveItem archiveItem, + Guid targetBoardId, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken) + { + try + { + // Deserialize snapshot + var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson); + if (snapshot == null) + return Result.Failure( + ErrorCodes.ValidationError, + "Failed to deserialize card snapshot"); + + // Get board with details + var board = await _unitOfWork.Boards.GetByIdWithDetailsAsync(targetBoardId, cancellationToken); + if (board == null) + return Result.Failure(ErrorCodes.NotFound, $"Board with ID {targetBoardId} not found"); + + // Find target column + Column? targetColumn = null; + if (snapshot.ColumnId != Guid.Empty) + { + targetColumn = board.Columns.FirstOrDefault(c => c.Id == snapshot.ColumnId); + } + + // If original column doesn't exist, use first available column + if (targetColumn == null) + { + targetColumn = board.Columns.OrderBy(c => c.Position).FirstOrDefault(); + if (targetColumn == null) + return Result.Failure( + ErrorCodes.InvalidOperation, + "Target board has no columns to restore card to"); + } + + // Get column with cards to check WIP limit and position + var columnWithCards = await _unitOfWork.Columns.GetByIdWithCardsAsync(targetColumn.Id, cancellationToken); + if (columnWithCards == null) + return Result.Failure(ErrorCodes.NotFound, $"Column with ID {targetColumn.Id} not found"); + + // Check WIP limit + if (columnWithCards.WouldExceedWipLimitIfAdded()) + return Result.Failure( + ErrorCodes.WipLimitExceeded, + $"Cannot restore card, column '{columnWithCards.Name}' has reached its WIP limit"); + + // Check for title conflicts + var existingCards = columnWithCards.Cards.ToList(); + var conflictExists = existingCards.Any(c => c.Title == snapshot.Title); + + string resolvedTitle = snapshot.Title; + if (conflictExists) + { + if (dto.ConflictStrategy == ConflictStrategy.Fail) + { + return Result.Failure( + ErrorCodes.Conflict, + $"A card with title '{snapshot.Title}' already exists in the target column"); + } + else if (dto.ConflictStrategy == ConflictStrategy.Rename) + { + resolvedTitle = $"{snapshot.Title} (Restored)"; + } + else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix) + { + resolvedTitle = $"{snapshot.Title} - {DateTime.UtcNow:yyyyMMdd-HHmmss}"; + } + } + + // Determine position (add to bottom) + var maxPosition = existingCards.Any() ? existingCards.Max(c => c.Position) : -1; + var newPosition = maxPosition + 1; + + // Create new card + var newCard = new Card( + targetBoardId, + columnWithCards.Id, + resolvedTitle, + snapshot.Description, + snapshot.DueDate, + newPosition); + + if (snapshot.IsBlocked && !string.IsNullOrEmpty(snapshot.BlockReason)) + { + newCard.Block(snapshot.BlockReason); + } + + await _unitOfWork.Cards.AddAsync(newCard, cancellationToken); + + return Result.Success(new RestoreResult( + true, + newCard.Id, + null, + resolvedTitle)); + } + catch (JsonException ex) + { + return Result.Failure( + ErrorCodes.ValidationError, + $"Invalid snapshot format: {ex.Message}"); + } + } + + private static ArchiveItemDto MapToDto(ArchiveItem item) + { + return new ArchiveItemDto( + item.Id, + item.EntityType, + item.EntityId, + item.BoardId, + item.Name, + item.ArchivedByUserId, + item.ArchivedAt, + item.Reason, + item.RestoreStatus, + item.RestoredAt, + item.RestoredByUserId, + item.CreatedAt, + item.UpdatedAt); + } +} + +// Snapshot DTOs for deserialization +internal record BoardSnapshot(string Name, string? Description); +internal record ColumnSnapshot(string Name, int Position, int? WipLimit); +internal record CardSnapshot( + string Title, + string? Description, + DateTimeOffset? DueDate, + bool IsBlocked, + string? BlockReason, + Guid ColumnId); diff --git a/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs new file mode 100644 index 000000000..31118e98a --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs @@ -0,0 +1,361 @@ +using System.Text.Json; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationExecutorService : IAutomationExecutorService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAutomationProposalService _proposalService; + private readonly IAutomationPolicyEngine _policyEngine; + private readonly CardService _cardService; + private readonly BoardService _boardService; + private readonly ColumnService _columnService; + + public AutomationExecutorService( + IUnitOfWork unitOfWork, + IAutomationProposalService proposalService, + IAutomationPolicyEngine policyEngine, + CardService cardService, + BoardService boardService, + ColumnService columnService) + { + _unitOfWork = unitOfWork; + _proposalService = proposalService; + _policyEngine = policyEngine; + _cardService = cardService; + _boardService = boardService; + _columnService = columnService; + } + + public async Task ExecuteProposalAsync(Guid proposalId, string idempotencyKey, CancellationToken cancellationToken = default) + { + if (proposalId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "ProposalId cannot be empty"); + + if (string.IsNullOrWhiteSpace(idempotencyKey)) + return Result.Failure(ErrorCodes.ValidationError, "IdempotencyKey cannot be empty"); + + // Get proposal + var proposalResult = await _proposalService.GetProposalByIdAsync(proposalId, cancellationToken); + if (!proposalResult.IsSuccess) + return Result.Failure(proposalResult.ErrorCode, proposalResult.ErrorMessage); + + var proposal = proposalResult.Value; + + // Idempotent behavior across requests/processes: already-applied proposals are treated as success. + if (proposal.Status == ProposalStatus.Applied) + return Result.Success(); + + // Verify proposal is approved + if (proposal.Status != ProposalStatus.Approved) + return Result.Failure(ErrorCodes.InvalidOperation, $"Cannot execute proposal in status {proposal.Status}"); + + // Revalidate policy before execution + var policyResult = _policyEngine.ValidatePolicy(proposal); + if (!policyResult.IsSuccess) + return Result.Failure(policyResult.ErrorCode, policyResult.ErrorMessage); + + // Revalidate permissions + var permissionResult = await _policyEngine.ValidatePermissionsAsync( + proposal.RequestedByUserId, + proposal.BoardId, + proposal.Operations, + cancellationToken); + if (!permissionResult.IsSuccess) + return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage); + + try + { + await _unitOfWork.BeginTransactionAsync(cancellationToken); + + // Execute operations in sequence order + var orderedOperations = proposal.Operations.OrderBy(o => o.Sequence).ToList(); + var failedOperation = -1; + var failureReason = ""; + + foreach (var operation in orderedOperations) + { + var executionResult = await ExecuteOperationAsync(operation, proposal.RequestedByUserId, cancellationToken); + if (!executionResult.IsSuccess) + { + failedOperation = operation.Sequence; + failureReason = $"Operation {operation.Sequence} failed: {executionResult.ErrorMessage}"; + break; + } + + // Create audit log for the operation + await CreateAuditLogAsync(operation, proposal, cancellationToken); + } + + if (failedOperation >= 0) + { + // Mark proposal as failed and rollback transaction + await _unitOfWork.RollbackTransactionAsync(cancellationToken); + + // Update proposal status + var updateResult = await UpdateProposalStatusAsync(proposalId, ProposalStatus.Failed, failureReason, cancellationToken); + if (!updateResult.IsSuccess) + return Result.Failure(updateResult.ErrorCode, updateResult.ErrorMessage); + + return Result.Failure(ErrorCodes.UnexpectedError, failureReason); + } + + // Mark proposal as applied + await _unitOfWork.CommitTransactionAsync(cancellationToken); + + var markResult = await UpdateProposalStatusAsync(proposalId, ProposalStatus.Applied, null, cancellationToken); + if (!markResult.IsSuccess) + return Result.Failure(markResult.ErrorCode, markResult.ErrorMessage); + + return Result.Success(); + } + catch (Exception ex) + { + await _unitOfWork.RollbackTransactionAsync(cancellationToken); + await UpdateProposalStatusAsync(proposalId, ProposalStatus.Failed, ex.Message, cancellationToken); + return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to execute proposal: {ex.Message}"); + } + } + + private async Task ExecuteOperationAsync(ProposalOperationDto operation, Guid userId, CancellationToken cancellationToken) + { + var actionType = operation.ActionType.ToLowerInvariant(); + var targetType = operation.TargetType.ToLowerInvariant(); + + try + { + if (targetType == "card") + { + return await ExecuteCardOperationAsync(actionType, operation, cancellationToken); + } + else if (targetType == "board") + { + return await ExecuteBoardOperationAsync(actionType, operation, cancellationToken); + } + else if (targetType == "column") + { + return await ExecuteColumnOperationAsync(actionType, operation, cancellationToken); + } + else + { + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported target type: {targetType}"); + } + } + catch (Exception ex) + { + return Result.Failure(ErrorCodes.UnexpectedError, $"Operation execution failed: {ex.Message}"); + } + } + + private async Task ExecuteCardOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken) + { + var parameters = JsonSerializer.Deserialize(operation.Parameters); + + switch (actionType) + { + case "create": + return await CreateCardAsync(parameters, cancellationToken); + + case "update": + return await UpdateCardAsync(parameters, cancellationToken); + + case "move": + return await MoveCardAsync(parameters, cancellationToken); + + case "archive": + return await ArchiveCardAsync(parameters, cancellationToken); + + default: + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported card action: {actionType}"); + } + } + + private async Task CreateCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var title = parameters.GetProperty("title").GetString(); + var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null; + var columnIdStr = parameters.GetProperty("columnId").GetString(); + var boardIdStr = parameters.GetProperty("boardId").GetString(); + + if (!Guid.TryParse(columnIdStr, out var columnId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId"); + + if (!Guid.TryParse(boardIdStr, out var boardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid boardId"); + + var dto = new CreateCardDto(boardId, columnId, title!, description, null, null); + var result = await _cardService.CreateCardAsync(dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task UpdateCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var cardIdStr = parameters.GetProperty("cardId").GetString(); + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId"); + + var title = parameters.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null; + var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null; + + var dto = new UpdateCardDto(title, description, null, null, null, null); + var result = await _cardService.UpdateCardAsync(cardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task MoveCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var cardIdStr = parameters.GetProperty("cardId").GetString(); + var columnIdStr = parameters.GetProperty("columnId").GetString(); + + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId"); + + if (!Guid.TryParse(columnIdStr, out var columnId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId"); + + // Get current cards in target column to determine position + var targetColumn = await _unitOfWork.Columns.GetByIdWithCardsAsync(columnId, cancellationToken); + if (targetColumn == null) + return Result.Failure(ErrorCodes.NotFound, $"Column {columnId} not found"); + + var position = targetColumn.Cards.Any() ? targetColumn.Cards.Max(c => c.Position) + 1 : 0; + var dto = new MoveCardDto(columnId, position); + var result = await _cardService.MoveCardAsync(cardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task ArchiveCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var cardIdStr = parameters.GetProperty("cardId").GetString(); + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId"); + + var dto = new UpdateCardDto(null, null, null, true, null, null); + var result = await _cardService.UpdateCardAsync(cardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task ExecuteBoardOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken) + { + var parameters = JsonSerializer.Deserialize(operation.Parameters); + + switch (actionType) + { + case "update": + return await UpdateBoardAsync(parameters, cancellationToken); + + default: + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported board action: {actionType}"); + } + } + + private async Task UpdateBoardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var boardIdStr = parameters.GetProperty("boardId").GetString(); + if (!Guid.TryParse(boardIdStr, out var boardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid boardId"); + + var name = parameters.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null; + + var dto = new UpdateBoardDto(name, description, null); + var result = await _boardService.UpdateBoardAsync(boardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task ExecuteColumnOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken) + { + var parameters = JsonSerializer.Deserialize(operation.Parameters); + + switch (actionType) + { + case "reorder": + return await ReorderColumnAsync(parameters, cancellationToken); + + default: + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported column action: {actionType}"); + } + } + + private async Task ReorderColumnAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var columnIdStr = parameters.GetProperty("columnId").GetString(); + var newPosition = parameters.GetProperty("position").GetInt32(); + + if (!Guid.TryParse(columnIdStr, out var columnId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId"); + + var dto = new UpdateColumnDto(null, newPosition, null); + var result = await _columnService.UpdateColumnAsync(columnId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task CreateAuditLogAsync(ProposalOperationDto operation, ProposalDto proposal, CancellationToken cancellationToken) + { + var actionMap = new Dictionary + { + { "create", AuditAction.Created }, + { "update", AuditAction.Updated }, + { "archive", AuditAction.Archived }, + { "move", AuditAction.Moved } + }; + + var auditAction = actionMap.ContainsKey(operation.ActionType.ToLowerInvariant()) + ? actionMap[operation.ActionType.ToLowerInvariant()] + : AuditAction.Updated; + + var entityId = !string.IsNullOrEmpty(operation.TargetId) && Guid.TryParse(operation.TargetId, out var id) + ? id + : Guid.NewGuid(); // For creates, we'd need to capture the created ID + + var changes = $"Automation Proposal {proposal.Id}: {operation.ActionType} {operation.TargetType}. Parameters: {operation.Parameters}"; + + var auditLog = new AuditLog( + operation.TargetType, + entityId, + auditAction, + proposal.RequestedByUserId, + changes + ); + + await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken); + } + + private async Task UpdateProposalStatusAsync(Guid proposalId, ProposalStatus status, string? failureReason, CancellationToken cancellationToken) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {proposalId} not found"); + + try + { + if (status == ProposalStatus.Applied) + { + proposal.MarkAsApplied(); + } + else if (status == ProposalStatus.Failed) + { + proposal.MarkAsFailed(failureReason ?? "Unknown error"); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + return Result.Success(); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } +} diff --git a/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs new file mode 100644 index 000000000..30fe0e38a --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationPlannerService : IAutomationPlannerService +{ + private readonly IAutomationProposalService _proposalService; + private readonly IAutomationPolicyEngine _policyEngine; + private readonly IUnitOfWork _unitOfWork; + + public AutomationPlannerService( + IAutomationProposalService proposalService, + IAutomationPolicyEngine policyEngine, + IUnitOfWork unitOfWork) + { + _proposalService = proposalService; + _policyEngine = policyEngine; + _unitOfWork = unitOfWork; + } + + public async Task> ParseInstructionAsync(string instruction, Guid userId, Guid? boardId = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(instruction)) + return Result.Failure(ErrorCodes.ValidationError, "Instruction cannot be empty"); + + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + try + { + var operations = new List(); + var instructionLower = instruction.ToLowerInvariant(); + var sequence = 0; + + // Pattern: "create card 'title' in column 'column name'" or "create card 'title'" + var createCardMatch = Regex.Match(instruction, @"create card ['""]([^'""]+)['""](?:\s+in column ['""]([^'""]+)['""])?(?:\s+with description ['""]([^'""]+)['""])?", RegexOptions.IgnoreCase); + if (createCardMatch.Success) + { + var title = createCardMatch.Groups[1].Value; + var columnName = createCardMatch.Groups.Count > 2 ? createCardMatch.Groups[2].Value : null; + var description = createCardMatch.Groups.Count > 3 ? createCardMatch.Groups[3].Value : null; + + if (!boardId.HasValue) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations"); + + // Find column ID if column name is specified + Guid? columnId = null; + if (!string.IsNullOrEmpty(columnName)) + { + var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken); + var column = columns.FirstOrDefault(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + if (column == null) + return Result.Failure(ErrorCodes.NotFound, $"Column '{columnName}' not found in board"); + + columnId = column.Id; + } + else + { + // Use first column as default + var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken); + var firstColumn = columns.OrderBy(c => c.Position).FirstOrDefault(); + if (firstColumn == null) + return Result.Failure(ErrorCodes.NotFound, "No columns found in board"); + + columnId = firstColumn.Id; + } + + var parameters = JsonSerializer.Serialize(new + { + title, + description, + columnId, + boardId + }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "create", + "card", + parameters, + Guid.NewGuid().ToString() + )); + } + + // Pattern: "move card {id} to column 'column name'" + var moveCardMatch = Regex.Match(instruction, @"move card ([a-f0-9-]+) to column ['""]([^'""]+)['""]", RegexOptions.IgnoreCase); + if (moveCardMatch.Success) + { + var cardIdStr = moveCardMatch.Groups[1].Value; + var columnName = moveCardMatch.Groups[2].Value; + + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}"); + + if (!boardId.HasValue) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations"); + + // Find column + var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken); + var column = columns.FirstOrDefault(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + if (column == null) + return Result.Failure(ErrorCodes.NotFound, $"Column '{columnName}' not found in board"); + + var parameters = JsonSerializer.Serialize(new + { + cardId, + columnId = column.Id + }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "move", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: cardId.ToString() + )); + } + + // Pattern: "archive card {id}" or "archive cards matching 'pattern'" + var archiveCardMatch = Regex.Match(instruction, @"archive card ([a-f0-9-]+)", RegexOptions.IgnoreCase); + if (archiveCardMatch.Success) + { + var cardIdStr = archiveCardMatch.Groups[1].Value; + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}"); + + var parameters = JsonSerializer.Serialize(new { cardId }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "archive", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: cardId.ToString() + )); + } + + // Pattern: "archive cards matching 'pattern'" + var archiveCardsMatch = Regex.Match(instruction, @"archive cards matching ['""]([^'""]+)['""]", RegexOptions.IgnoreCase); + if (archiveCardsMatch.Success) + { + var pattern = archiveCardsMatch.Groups[1].Value; + + if (!boardId.HasValue) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations"); + + // Find matching cards + var cards = await _unitOfWork.Cards.GetByBoardIdAsync(boardId.Value, cancellationToken); + var matchingCards = cards.Where(c => + c.Title.Contains(pattern, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (!matchingCards.Any()) + return Result.Failure(ErrorCodes.NotFound, $"No cards matching '{pattern}' found"); + + foreach (var card in matchingCards) + { + var parameters = JsonSerializer.Serialize(new { cardId = card.Id }); + operations.Add(new CreateProposalOperationDto( + sequence++, + "archive", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: card.Id.ToString() + )); + } + } + + // Pattern: "update card {id} title 'new title'" or "update card {id} description 'new desc'" + var updateCardMatch = Regex.Match(instruction, @"update card ([a-f0-9-]+)\s+(title|description) ['""]([^'""]+)['""]", RegexOptions.IgnoreCase); + if (updateCardMatch.Success) + { + var cardIdStr = updateCardMatch.Groups[1].Value; + var field = updateCardMatch.Groups[2].Value.ToLower(); + var value = updateCardMatch.Groups[3].Value; + + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}"); + + var parameters = field == "title" + ? JsonSerializer.Serialize(new { cardId, title = value }) + : JsonSerializer.Serialize(new { cardId, description = value }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "update", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: cardId.ToString() + )); + } + + if (!operations.Any()) + return Result.Failure(ErrorCodes.ValidationError, + "Could not parse instruction. Supported patterns: 'create card \"title\"', 'move card {id} to column \"name\"', 'archive card {id}', 'archive cards matching \"pattern\"', 'update card {id} title/description \"value\"'"); + + // Classify risk + var operationDtos = operations.Select(o => new ProposalOperationDto( + Guid.NewGuid(), + Guid.Empty, + o.Sequence, + o.ActionType, + o.TargetType, + o.TargetId, + o.Parameters, + o.IdempotencyKey, + o.ExpectedVersion + )).ToList(); + + var riskLevel = _policyEngine.ClassifyRisk(operationDtos); + + // Create proposal + var createDto = new CreateProposalDto( + ProposalSourceType.Manual, + userId, + instruction.Length > 500 ? instruction.Substring(0, 497) + "..." : instruction, + riskLevel, + Guid.NewGuid().ToString(), + boardId, + null, + 1440, + operations + ); + + var result = await _proposalService.CreateProposalAsync(createDto, cancellationToken); + if (!result.IsSuccess) + return Result.Failure(result.ErrorCode, result.ErrorMessage); + + // Validate permissions + var permissionResult = await _policyEngine.ValidatePermissionsAsync(userId, boardId, operationDtos, cancellationToken); + if (!permissionResult.IsSuccess) + { + return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage); + } + + return Result.Success(result.Value); + } + catch (Exception ex) + { + return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to parse instruction: {ex.Message}"); + } + } +} diff --git a/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs b/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs new file mode 100644 index 000000000..ac9f87343 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs @@ -0,0 +1,133 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationPolicyEngine : IAutomationPolicyEngine +{ + private readonly IUnitOfWork _unitOfWork; + private const int MaxOperationCount = 50; + private const int MaxParametersLength = 10000; + + public AutomationPolicyEngine(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public RiskLevel ClassifyRisk(IEnumerable operations) + { + var opList = operations.ToList(); + + if (!opList.Any()) + return RiskLevel.Low; + + var hasDelete = opList.Any(o => o.ActionType.Contains("delete", StringComparison.OrdinalIgnoreCase)); + var hasArchive = opList.Any(o => o.ActionType.Contains("archive", StringComparison.OrdinalIgnoreCase)); + var hasUpdate = opList.Any(o => o.ActionType.Contains("update", StringComparison.OrdinalIgnoreCase)); + var hasBoardOperation = opList.Any(o => o.TargetType.Equals("board", StringComparison.OrdinalIgnoreCase)); + var operationCount = opList.Count; + + // Critical: Delete board or many operations + if (hasBoardOperation && hasDelete) + return RiskLevel.Critical; + + if (operationCount > 20) + return RiskLevel.Critical; + + // High: Delete operations, board updates, or many operations + if (hasDelete || (hasBoardOperation && hasUpdate)) + return RiskLevel.High; + + if (operationCount > 10) + return RiskLevel.High; + + // Medium: Archive operations or moderate operation count + if (hasArchive) + return RiskLevel.Medium; + + if (operationCount > 5) + return RiskLevel.Medium; + + // Low: Simple creates and updates with few operations + return RiskLevel.Low; + } + + public async Task ValidatePermissionsAsync(Guid userId, Guid? boardId, IEnumerable operations, CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + // Verify user exists + var user = await _unitOfWork.Users.GetByIdAsync(userId, cancellationToken); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, $"User with ID {userId} not found"); + + var opList = operations.ToList(); + if (!opList.Any()) + return Result.Success(); + + // If board-scoped, verify board exists and user has access + if (boardId.HasValue) + { + var board = await _unitOfWork.Boards.GetByIdAsync(boardId.Value, cancellationToken); + if (board == null) + return Result.Failure(ErrorCodes.NotFound, $"Board with ID {boardId} not found"); + + var hasAccess = await _unitOfWork.BoardAccesses.HasAccessAsync(boardId.Value, userId, null, cancellationToken); + if (!hasAccess) + return Result.Failure(ErrorCodes.Forbidden, $"User does not have access to board {boardId}"); + } + + // Validate each operation targets entities within the board scope + foreach (var operation in opList) + { + if (boardId.HasValue && operation.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(operation.TargetId)) + { + if (Guid.TryParse(operation.TargetId, out var cardId)) + { + var card = await _unitOfWork.Cards.GetByIdAsync(cardId, cancellationToken); + if (card != null && card.BoardId != boardId.Value) + return Result.Failure(ErrorCodes.Forbidden, $"Card {cardId} does not belong to board {boardId}"); + } + } + } + + return Result.Success(); + } + + public Result ValidatePolicy(ProposalDto proposal) + { + if (proposal == null) + return Result.Failure(ErrorCodes.ValidationError, "Proposal cannot be null"); + + if (proposal.Operations == null || !proposal.Operations.Any()) + return Result.Failure(ErrorCodes.ValidationError, "Proposal must contain at least one operation"); + + if (proposal.Operations.Count > MaxOperationCount) + return Result.Failure(ErrorCodes.ValidationError, $"Proposal exceeds maximum operation count of {MaxOperationCount}"); + + // Validate operation sequences are unique and non-negative + var sequences = proposal.Operations.Select(o => o.Sequence).ToList(); + if (sequences.Distinct().Count() != sequences.Count) + return Result.Failure(ErrorCodes.ValidationError, "Operation sequences must be unique"); + + if (sequences.Any(s => s < 0)) + return Result.Failure(ErrorCodes.ValidationError, "Operation sequences must be non-negative"); + + // Validate parameters size + foreach (var operation in proposal.Operations) + { + if (operation.Parameters.Length > MaxParametersLength) + return Result.Failure(ErrorCodes.ValidationError, $"Operation parameters exceed maximum length of {MaxParametersLength}"); + } + + // Validate proposal hasn't expired + if (DateTime.UtcNow > proposal.ExpiresAt) + return Result.Failure(ErrorCodes.ValidationError, "Proposal has expired"); + + return Result.Success(); + } +} diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs new file mode 100644 index 000000000..788942a9f --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs @@ -0,0 +1,284 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationProposalService : IAutomationProposalService +{ + private readonly IUnitOfWork _unitOfWork; + + public AutomationProposalService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task> CreateProposalAsync(CreateProposalDto dto, CancellationToken cancellationToken = default) + { + try + { + var proposal = new AutomationProposal( + dto.SourceType, + dto.RequestedByUserId, + dto.Summary, + dto.RiskLevel, + dto.CorrelationId, + dto.BoardId, + dto.SourceReferenceId, + dto.ExpiryMinutes); + + await _unitOfWork.AutomationProposals.AddAsync(proposal, cancellationToken); + + // Add operations if provided + if (dto.Operations != null) + { + foreach (var opDto in dto.Operations) + { + var operation = new AutomationProposalOperation( + proposal.Id, + opDto.Sequence, + opDto.ActionType, + opDto.TargetType, + opDto.Parameters, + opDto.IdempotencyKey, + opDto.TargetId, + opDto.ExpectedVersion); + + proposal.AddOperation(operation); + } + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> GetProposalByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + return Result.Success(MapToDto(proposal)); + } + + public async Task>> GetProposalsAsync(ProposalFilterDto? filter = null, CancellationToken cancellationToken = default) + { + filter ??= new ProposalFilterDto(); + var limit = filter.Limit <= 0 ? 100 : filter.Limit; + + IEnumerable proposals; + + // Apply filters in order of specificity + if (filter.Status.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(filter.Status.Value, limit, cancellationToken); + } + else if (filter.BoardId.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByBoardIdAsync(filter.BoardId.Value, limit, cancellationToken); + } + else if (filter.UserId.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByUserIdAsync(filter.UserId.Value, limit, cancellationToken); + } + else if (filter.RiskLevel.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByRiskLevelAsync(filter.RiskLevel.Value, limit, cancellationToken); + } + else + { + // Get all by status Pending if no filters provided + proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(ProposalStatus.PendingReview, limit, cancellationToken); + } + + // Apply remaining filters in-memory when multiple filters are specified. + if (filter.Status.HasValue) + proposals = proposals.Where(p => p.Status == filter.Status.Value); + + if (filter.BoardId.HasValue) + proposals = proposals.Where(p => p.BoardId == filter.BoardId.Value); + + if (filter.UserId.HasValue) + proposals = proposals.Where(p => p.RequestedByUserId == filter.UserId.Value); + + if (filter.RiskLevel.HasValue) + proposals = proposals.Where(p => p.RiskLevel == filter.RiskLevel.Value); + + proposals = proposals.Take(limit); + + return Result.Success(proposals.Select(MapToDto)); + } + + public async Task> ApproveProposalAsync(Guid id, Guid decidedByUserId, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.Approve(decidedByUserId); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> RejectProposalAsync(Guid id, Guid decidedByUserId, UpdateProposalStatusDto dto, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.Reject(decidedByUserId, dto.Reason); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> MarkAsAppliedAsync(Guid id, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.MarkAsApplied(); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> MarkAsFailedAsync(Guid id, string failureReason, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.MarkAsFailed(failureReason); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> ExpireProposalsAsync(CancellationToken cancellationToken = default) + { + try + { + var expiredProposals = await _unitOfWork.AutomationProposals.GetExpiredAsync(cancellationToken); + int count = 0; + + foreach (var proposal in expiredProposals) + { + proposal.Expire(); + count++; + } + + if (count > 0) + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(count); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + if (!string.IsNullOrWhiteSpace(proposal.DiffPreview)) + return Result.Success(proposal.DiffPreview); + + if (proposal.Operations.Count == 0) + return Result.Failure(ErrorCodes.NotFound, "Diff preview not available for this proposal"); + + var generatedDiff = string.Join( + Environment.NewLine, + proposal.Operations + .OrderBy(o => o.Sequence) + .Select(o => + { + var target = string.IsNullOrWhiteSpace(o.TargetId) ? o.TargetType : $"{o.TargetType}:{o.TargetId}"; + return $"{o.Sequence}. {o.ActionType} {target}"; + })); + + return Result.Success(generatedDiff); + } + + private static ProposalDto MapToDto(AutomationProposal proposal) + { + return new ProposalDto( + proposal.Id, + proposal.SourceType, + proposal.SourceReferenceId, + proposal.BoardId, + proposal.RequestedByUserId, + proposal.Status, + proposal.RiskLevel, + proposal.Summary, + proposal.DiffPreview, + proposal.ValidationIssues, + proposal.CreatedAt, + proposal.UpdatedAt, + proposal.ExpiresAt, + proposal.DecidedAt, + proposal.DecidedByUserId, + proposal.AppliedAt, + proposal.FailureReason, + proposal.CorrelationId, + proposal.Operations.Select(MapOperationToDto).ToList() + ); + } + + private static ProposalOperationDto MapOperationToDto(AutomationProposalOperation operation) + { + return new ProposalOperationDto( + operation.Id, + operation.ProposalId, + operation.Sequence, + operation.ActionType, + operation.TargetType, + operation.TargetId, + operation.Parameters, + operation.IdempotencyKey, + operation.ExpectedVersion + ); + } +} diff --git a/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs new file mode 100644 index 000000000..37b406855 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs @@ -0,0 +1,34 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Services; + +public interface IArchiveRecoveryService +{ + Task> CreateArchiveItemAsync( + CreateArchiveItemDto dto, + CancellationToken cancellationToken = default); + + Task>> GetArchiveItemsAsync( + string? entityType = null, + Guid? boardId = null, + RestoreStatus? status = null, + int limit = 100, + CancellationToken cancellationToken = default); + + Task> GetArchiveItemByIdAsync( + Guid id, + CancellationToken cancellationToken = default); + + Task> GetArchiveItemByEntityAsync( + string entityType, + Guid entityId, + CancellationToken cancellationToken = default); + + Task> RestoreArchiveItemAsync( + Guid id, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs b/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs new file mode 100644 index 000000000..3ef5150df --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs @@ -0,0 +1,8 @@ +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +public interface IAutomationExecutorService +{ + Task ExecuteProposalAsync(Guid proposalId, string idempotencyKey, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs b/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs new file mode 100644 index 000000000..4a6d60dd4 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs @@ -0,0 +1,9 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +public interface IAutomationPlannerService +{ + Task> ParseInstructionAsync(string instruction, Guid userId, Guid? boardId = null, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs b/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs new file mode 100644 index 000000000..3b44ce646 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs @@ -0,0 +1,12 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Services; + +public interface IAutomationPolicyEngine +{ + RiskLevel ClassifyRisk(IEnumerable operations); + Task ValidatePermissionsAsync(Guid userId, Guid? boardId, IEnumerable operations, CancellationToken cancellationToken = default); + Result ValidatePolicy(ProposalDto proposal); +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs new file mode 100644 index 000000000..b3691d489 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs @@ -0,0 +1,53 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Services; + +public interface IAutomationProposalService +{ + /// + /// Creates a new automation proposal with operations. + /// + Task> CreateProposalAsync(CreateProposalDto dto, CancellationToken cancellationToken = default); + + /// + /// Gets a proposal by ID with all operations. + /// + Task> GetProposalByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Gets proposals with optional filters. + /// + Task>> GetProposalsAsync(ProposalFilterDto? filter = null, CancellationToken cancellationToken = default); + + /// + /// Approves a pending proposal. + /// + Task> ApproveProposalAsync(Guid id, Guid decidedByUserId, CancellationToken cancellationToken = default); + + /// + /// Rejects a pending proposal with optional reason (required for High/Critical risk). + /// + Task> RejectProposalAsync(Guid id, Guid decidedByUserId, UpdateProposalStatusDto dto, CancellationToken cancellationToken = default); + + /// + /// Marks an approved proposal as applied. + /// + Task> MarkAsAppliedAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Marks an approved proposal as failed with reason. + /// + Task> MarkAsFailedAsync(Guid id, string failureReason, CancellationToken cancellationToken = default); + + /// + /// Expires all stale pending proposals. + /// + Task> ExpireProposalsAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the diff preview for a proposal. + /// + Task> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs new file mode 100644 index 000000000..85dfa947c --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs @@ -0,0 +1,106 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class ArchiveItem : Entity +{ + public string EntityType { get; private set; } + public Guid EntityId { get; private set; } + public Guid BoardId { get; private set; } + public string Name { get; private set; } + public Guid ArchivedByUserId { get; private set; } + public DateTime ArchivedAt { get; private set; } + public string? Reason { get; private set; } + public string SnapshotJson { get; private set; } + public RestoreStatus RestoreStatus { get; private set; } + public DateTime? RestoredAt { get; private set; } + public Guid? RestoredByUserId { get; private set; } + + private ArchiveItem() { } // EF Core + + public ArchiveItem( + string entityType, + Guid entityId, + Guid boardId, + string name, + Guid archivedByUserId, + string snapshotJson, + string? reason = null) + { + if (string.IsNullOrWhiteSpace(entityType)) + throw new DomainException(ErrorCodes.ValidationError, "EntityType cannot be empty"); + if (entityType != "board" && entityType != "column" && entityType != "card") + throw new DomainException(ErrorCodes.ValidationError, "EntityType must be 'board', 'column', or 'card'"); + if (entityId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "EntityId cannot be empty"); + if (boardId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "BoardId cannot be empty"); + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException(ErrorCodes.ValidationError, "Name cannot be empty"); + if (name.Length > 200) + throw new DomainException(ErrorCodes.ValidationError, "Name cannot exceed 200 characters"); + if (archivedByUserId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "ArchivedByUserId cannot be empty"); + if (string.IsNullOrWhiteSpace(snapshotJson)) + throw new DomainException(ErrorCodes.ValidationError, "SnapshotJson cannot be empty"); + + EntityType = entityType; + EntityId = entityId; + BoardId = boardId; + Name = name; + ArchivedByUserId = archivedByUserId; + ArchivedAt = DateTime.UtcNow; + Reason = reason; + SnapshotJson = snapshotJson; + RestoreStatus = RestoreStatus.Available; + } + + public void MarkAsRestored(Guid restoredByUserId) + { + if (restoredByUserId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "RestoredByUserId cannot be empty"); + if (RestoreStatus != RestoreStatus.Available) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot restore archive item with status {RestoreStatus}"); + + RestoreStatus = RestoreStatus.Restored; + RestoredAt = DateTime.UtcNow; + RestoredByUserId = restoredByUserId; + Touch(); + } + + public void MarkAsExpired() + { + if (RestoreStatus != RestoreStatus.Available) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot expire archive item with status {RestoreStatus}"); + + RestoreStatus = RestoreStatus.Expired; + Touch(); + } + + public void MarkAsConflict() + { + if (RestoreStatus != RestoreStatus.Available) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot mark conflict for archive item with status {RestoreStatus}"); + + RestoreStatus = RestoreStatus.Conflict; + Touch(); + } + + public void ResetToAvailable() + { + if (RestoreStatus == RestoreStatus.Restored) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot reset already restored archive item"); + + RestoreStatus = RestoreStatus.Available; + Touch(); + } +} + +public enum RestoreStatus +{ + Available, + Restored, + Expired, + Conflict +} diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs new file mode 100644 index 000000000..301a6e58d --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs @@ -0,0 +1,176 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class AutomationProposal : Entity +{ + public ProposalSourceType SourceType { get; private set; } + public string? SourceReferenceId { get; private set; } + public Guid? BoardId { get; private set; } + public Guid RequestedByUserId { get; private set; } + public ProposalStatus Status { get; private set; } + public RiskLevel RiskLevel { get; private set; } + public string Summary { get; private set; } + public string? DiffPreview { get; private set; } + public string? ValidationIssues { get; private set; } + public DateTime ExpiresAt { get; private set; } + public DateTime? DecidedAt { get; private set; } + public Guid? DecidedByUserId { get; private set; } + public DateTime? AppliedAt { get; private set; } + public string? FailureReason { get; private set; } + public string CorrelationId { get; private set; } + + private readonly List _operations = new(); + public IReadOnlyList Operations => _operations.AsReadOnly(); + + private AutomationProposal() { } // EF Core + + public AutomationProposal( + ProposalSourceType sourceType, + Guid requestedByUserId, + string summary, + RiskLevel riskLevel, + string correlationId, + Guid? boardId = null, + string? sourceReferenceId = null, + int expiryMinutes = 1440) + { + if (requestedByUserId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty"); + if (string.IsNullOrWhiteSpace(summary)) + throw new DomainException(ErrorCodes.ValidationError, "Summary cannot be empty"); + if (summary.Length > 500) + throw new DomainException(ErrorCodes.ValidationError, "Summary cannot exceed 500 characters"); + if (string.IsNullOrWhiteSpace(correlationId)) + throw new DomainException(ErrorCodes.ValidationError, "CorrelationId cannot be empty"); + if (expiryMinutes <= 0) + throw new DomainException(ErrorCodes.ValidationError, "ExpiryMinutes must be positive"); + + SourceType = sourceType; + SourceReferenceId = sourceReferenceId; + BoardId = boardId; + RequestedByUserId = requestedByUserId; + Status = ProposalStatus.PendingReview; + RiskLevel = riskLevel; + Summary = summary; + CorrelationId = correlationId; + ExpiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes); + } + + public void AddOperation(AutomationProposalOperation operation) + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot add operations after proposal has been decided"); + + _operations.Add(operation); + Touch(); + } + + public void Approve(Guid decidedByUserId) + { + if (decidedByUserId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty"); + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot approve proposal in status {Status}"); + if (DateTime.UtcNow > ExpiresAt) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot approve expired proposal"); + + Status = ProposalStatus.Approved; + DecidedByUserId = decidedByUserId; + DecidedAt = DateTime.UtcNow; + Touch(); + } + + public void Reject(Guid decidedByUserId, string? reason = null) + { + if (decidedByUserId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty"); + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot reject proposal in status {Status}"); + + // High and Critical risk proposals require a reason for rejection + if ((RiskLevel == RiskLevel.High || RiskLevel == RiskLevel.Critical) && string.IsNullOrWhiteSpace(reason)) + throw new DomainException(ErrorCodes.ValidationError, "Rejection reason is required for High and Critical risk proposals"); + + Status = ProposalStatus.Rejected; + DecidedByUserId = decidedByUserId; + DecidedAt = DateTime.UtcNow; + FailureReason = reason; + Touch(); + } + + public void MarkAsApplied() + { + if (Status != ProposalStatus.Approved) + throw new DomainException(ErrorCodes.InvalidOperation, "Only approved proposals can be marked as applied"); + + Status = ProposalStatus.Applied; + AppliedAt = DateTime.UtcNow; + Touch(); + } + + public void MarkAsFailed(string failureReason) + { + if (string.IsNullOrWhiteSpace(failureReason)) + throw new DomainException(ErrorCodes.ValidationError, "FailureReason cannot be empty"); + if (Status != ProposalStatus.Approved) + throw new DomainException(ErrorCodes.InvalidOperation, "Only approved proposals can be marked as failed"); + + Status = ProposalStatus.Failed; + FailureReason = failureReason; + Touch(); + } + + public void Expire() + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot expire proposal in status {Status}"); + + Status = ProposalStatus.Expired; + Touch(); + } + + public void SetDiffPreview(string diffPreview) + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot update diff preview after proposal has been decided"); + + DiffPreview = diffPreview; + Touch(); + } + + public void SetValidationIssues(string validationIssues) + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot update validation issues after proposal has been decided"); + + ValidationIssues = validationIssues; + Touch(); + } +} + +public enum ProposalSourceType +{ + Queue, + Chat, + Manual +} + +public enum ProposalStatus +{ + PendingReview, + Approved, + Rejected, + Applied, + Failed, + Expired +} + +public enum RiskLevel +{ + Low, + Medium, + High, + Critical +} diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs new file mode 100644 index 000000000..871b01f66 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs @@ -0,0 +1,54 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class AutomationProposalOperation : Entity +{ + public Guid ProposalId { get; private set; } + public int Sequence { get; private set; } + public string ActionType { get; private set; } + public string TargetType { get; private set; } + public string? TargetId { get; private set; } + public string Parameters { get; private set; } // JSON payload + public string IdempotencyKey { get; private set; } + public string? ExpectedVersion { get; private set; } + + // Navigation + public AutomationProposal Proposal { get; private set; } = null!; + + private AutomationProposalOperation() { } // EF Core + + public AutomationProposalOperation( + Guid proposalId, + int sequence, + string actionType, + string targetType, + string parameters, + string idempotencyKey, + string? targetId = null, + string? expectedVersion = null) + { + if (proposalId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty"); + if (sequence < 0) + throw new DomainException(ErrorCodes.ValidationError, "Sequence must be non-negative"); + if (string.IsNullOrWhiteSpace(actionType)) + throw new DomainException(ErrorCodes.ValidationError, "ActionType cannot be empty"); + if (string.IsNullOrWhiteSpace(targetType)) + throw new DomainException(ErrorCodes.ValidationError, "TargetType cannot be empty"); + if (string.IsNullOrWhiteSpace(parameters)) + throw new DomainException(ErrorCodes.ValidationError, "Parameters cannot be empty"); + if (string.IsNullOrWhiteSpace(idempotencyKey)) + throw new DomainException(ErrorCodes.ValidationError, "IdempotencyKey cannot be empty"); + + ProposalId = proposalId; + Sequence = sequence; + ActionType = actionType; + TargetType = targetType; + TargetId = targetId; + Parameters = parameters; + IdempotencyKey = idempotencyKey; + ExpectedVersion = expectedVersion; + } +} diff --git a/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs new file mode 100644 index 000000000..bac4f0079 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs @@ -0,0 +1,71 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class ChatMessage : Entity +{ + public Guid SessionId { get; private set; } + public ChatMessageRole Role { get; private set; } + public string Content { get; private set; } + public string MessageType { get; private set; } + public Guid? ProposalId { get; private set; } + public int? TokenUsage { get; private set; } + + // Navigation + public ChatSession Session { get; private set; } = null!; + + private ChatMessage() { } // EF Core + + public ChatMessage( + Guid sessionId, + ChatMessageRole role, + string content, + string messageType = "text", + Guid? proposalId = null, + int? tokenUsage = null) + { + if (sessionId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "SessionId cannot be empty"); + if (string.IsNullOrWhiteSpace(content)) + throw new DomainException(ErrorCodes.ValidationError, "Content cannot be empty"); + if (string.IsNullOrWhiteSpace(messageType)) + throw new DomainException(ErrorCodes.ValidationError, "MessageType cannot be empty"); + if (messageType != "text" && messageType != "proposal-reference" && messageType != "error" && messageType != "status") + throw new DomainException(ErrorCodes.ValidationError, "MessageType must be 'text', 'proposal-reference', 'error', or 'status'"); + if (tokenUsage.HasValue && tokenUsage.Value < 0) + throw new DomainException(ErrorCodes.ValidationError, "TokenUsage must be non-negative"); + + SessionId = sessionId; + Role = role; + Content = content; + MessageType = messageType; + ProposalId = proposalId; + TokenUsage = tokenUsage; + } + + public void SetTokenUsage(int tokenUsage) + { + if (tokenUsage < 0) + throw new DomainException(ErrorCodes.ValidationError, "TokenUsage must be non-negative"); + + TokenUsage = tokenUsage; + Touch(); + } + + public void SetProposalId(Guid proposalId) + { + if (proposalId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty"); + + ProposalId = proposalId; + Touch(); + } +} + +public enum ChatMessageRole +{ + User, + Assistant, + System +} diff --git a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs new file mode 100644 index 000000000..b3737343f --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs @@ -0,0 +1,79 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class ChatSession : Entity +{ + public Guid UserId { get; private set; } + public Guid? BoardId { get; private set; } + public string Title { get; private set; } + public ChatSessionStatus Status { get; private set; } + + private readonly List _messages = new(); + public IReadOnlyList Messages => _messages.AsReadOnly(); + + private ChatSession() { } // EF Core + + public ChatSession( + Guid userId, + string title, + Guid? boardId = null) + { + if (userId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "UserId cannot be empty"); + if (string.IsNullOrWhiteSpace(title)) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot be empty"); + if (title.Length > 200) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot exceed 200 characters"); + + UserId = userId; + BoardId = boardId; + Title = title; + Status = ChatSessionStatus.Active; + } + + public void UpdateTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot be empty"); + if (title.Length > 200) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot exceed 200 characters"); + + Title = title; + Touch(); + } + + public void Archive() + { + if (Status == ChatSessionStatus.Archived) + throw new DomainException(ErrorCodes.InvalidOperation, "Session is already archived"); + + Status = ChatSessionStatus.Archived; + Touch(); + } + + public void Reactivate() + { + if (Status == ChatSessionStatus.Active) + throw new DomainException(ErrorCodes.InvalidOperation, "Session is already active"); + + Status = ChatSessionStatus.Active; + Touch(); + } + + public void AddMessage(ChatMessage message) + { + if (Status == ChatSessionStatus.Archived) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot add messages to archived session"); + + _messages.Add(message); + Touch(); + } +} + +public enum ChatSessionStatus +{ + Active, + Archived +} diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRun.cs b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs new file mode 100644 index 000000000..a0420da74 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs @@ -0,0 +1,127 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class CommandRun : Entity +{ + public string TemplateName { get; private set; } + public Guid RequestedByUserId { get; private set; } + public CommandRunStatus Status { get; private set; } + public DateTime? StartedAt { get; private set; } + public DateTime? CompletedAt { get; private set; } + public int? ExitCode { get; private set; } + public bool Truncated { get; private set; } + public string CorrelationId { get; private set; } + public string? ErrorMessage { get; private set; } + public string? OutputPreview { get; private set; } + + private readonly List _logs = new(); + public IReadOnlyList Logs => _logs.AsReadOnly(); + + private CommandRun() { } // EF Core + + public CommandRun( + string templateName, + Guid requestedByUserId, + string correlationId) + { + if (string.IsNullOrWhiteSpace(templateName)) + throw new DomainException(ErrorCodes.ValidationError, "TemplateName cannot be empty"); + if (requestedByUserId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty"); + if (string.IsNullOrWhiteSpace(correlationId)) + throw new DomainException(ErrorCodes.ValidationError, "CorrelationId cannot be empty"); + + TemplateName = templateName; + RequestedByUserId = requestedByUserId; + Status = CommandRunStatus.Queued; + CorrelationId = correlationId; + Truncated = false; + } + + public void Start() + { + if (Status != CommandRunStatus.Queued) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot start command run in status {Status}"); + + Status = CommandRunStatus.Running; + StartedAt = DateTime.UtcNow; + Touch(); + } + + public void Complete(int exitCode) + { + if (Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot complete command run in status {Status}"); + + Status = CommandRunStatus.Completed; + CompletedAt = DateTime.UtcNow; + ExitCode = exitCode; + Touch(); + } + + public void Fail(string errorMessage) + { + if (string.IsNullOrWhiteSpace(errorMessage)) + throw new DomainException(ErrorCodes.ValidationError, "ErrorMessage cannot be empty"); + if (Status != CommandRunStatus.Queued && Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot fail command run in status {Status}"); + + Status = CommandRunStatus.Failed; + CompletedAt = DateTime.UtcNow; + ErrorMessage = errorMessage; + Touch(); + } + + public void Timeout() + { + if (Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot timeout command run in status {Status}"); + + Status = CommandRunStatus.TimedOut; + CompletedAt = DateTime.UtcNow; + Touch(); + } + + public void Cancel() + { + if (Status != CommandRunStatus.Queued && Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot cancel command run in status {Status}"); + + Status = CommandRunStatus.Cancelled; + CompletedAt = DateTime.UtcNow; + Touch(); + } + + public void SetTruncated() + { + Truncated = true; + Touch(); + } + + public void SetOutputPreview(string outputPreview) + { + if (outputPreview != null && outputPreview.Length > 1000) + throw new DomainException(ErrorCodes.ValidationError, "OutputPreview cannot exceed 1000 characters"); + + OutputPreview = outputPreview; + Touch(); + } + + public void AddLog(CommandRunLog log) + { + _logs.Add(log); + Touch(); + } +} + +public enum CommandRunStatus +{ + Queued, + Running, + Completed, + Failed, + TimedOut, + Cancelled +} diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs new file mode 100644 index 000000000..664cbe2f2 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs @@ -0,0 +1,45 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class CommandRunLog : Entity +{ + public Guid CommandRunId { get; private set; } + public DateTime Timestamp { get; private set; } + public string Level { get; private set; } + public string Source { get; private set; } + public string Message { get; private set; } + public string? Metadata { get; private set; } // JSON + + // Navigation + public CommandRun CommandRun { get; private set; } = null!; + + private CommandRunLog() { } // EF Core + + public CommandRunLog( + Guid commandRunId, + string level, + string source, + string message, + string? metadata = null) + { + if (commandRunId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "CommandRunId cannot be empty"); + if (string.IsNullOrWhiteSpace(level)) + throw new DomainException(ErrorCodes.ValidationError, "Level cannot be empty"); + if (level != "Debug" && level != "Info" && level != "Warning" && level != "Error") + throw new DomainException(ErrorCodes.ValidationError, "Level must be 'Debug', 'Info', 'Warning', or 'Error'"); + if (string.IsNullOrWhiteSpace(source)) + throw new DomainException(ErrorCodes.ValidationError, "Source cannot be empty"); + if (string.IsNullOrWhiteSpace(message)) + throw new DomainException(ErrorCodes.ValidationError, "Message cannot be empty"); + + CommandRunId = commandRunId; + Timestamp = DateTime.UtcNow; + Level = level; + Source = source; + Message = message; + Metadata = metadata; + } +} diff --git a/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs b/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs index 986ec476f..51a485da0 100644 --- a/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs +++ b/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs @@ -26,4 +26,5 @@ public static class ErrorCodes public const string Unauthorized = "Unauthorized"; public const string Forbidden = "Forbidden"; public const string AuthenticationFailed = "AuthenticationFailed"; + public const string InvalidOperation = "InvalidOperation"; } diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs index 249365d8f..f8c1eaf67 100644 --- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs +++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs @@ -25,6 +25,11 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs new file mode 100644 index 000000000..d3b5760ac --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Taskdeck.Application.Interfaces; + +namespace Taskdeck.Infrastructure.Identity; + +/// +/// Implementation of IUserContext that extracts user identity from HTTP context claims. +/// +public class UserContext : IUserContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public UserContext(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? UserId + { + get + { + var user = _httpContextAccessor.HttpContext?.User; + if (user == null || user.Identity?.IsAuthenticated != true) + return null; + + // Try both 'sub' (standard JWT claim) and 'nameid' (alternative) + return user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? user.FindFirst("sub")?.Value; + } + } + + public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true; + + public string? Role => _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.Role)?.Value; +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs new file mode 100644 index 000000000..60499a545 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs @@ -0,0 +1,951 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Taskdeck.Infrastructure.Persistence; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + [DbContext(typeof(TaskdeckDbContext))] + [Migration("20260212200649_AddAutomationArchiveChatOpsEntities")] + partial class AddAutomationArchiveChatOpsEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ArchiveItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedAt") + .HasColumnType("TEXT"); + + b.Property("ArchivedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RestoreStatus") + .HasColumnType("INTEGER"); + + b.Property("RestoredAt") + .HasColumnType("TEXT"); + + b.Property("RestoredByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArchivedAt"); + + b.HasIndex("ArchivedByUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("RestoreStatus"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("ArchiveItems", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DiffPreview") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("INTEGER"); + + b.Property("SourceReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValidationIssues") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("AutomationProposals", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedVersion") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("ProposalId"); + + b.HasIndex("ProposalId", "Sequence"); + + b.ToTable("AutomationProposalOperations", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Boards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedBy") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BoardId", "UserId") + .IsUnique(); + + b.ToTable("BoardAccesses", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BlockReason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColumnId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("IsBlocked") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("ColumnId"); + + b.ToTable("Cards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.Property("CardId") + .HasColumnType("TEXT"); + + b.Property("LabelId") + .HasColumnType("TEXT"); + + b.HasKey("CardId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("CardLabels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ProposalId"); + + b.HasIndex("SessionId"); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("ChatSessions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WipLimit") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BoardId", "Position") + .IsUnique(); + + b.ToTable("Columns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("OutputPreview") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Truncated") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("CommandRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CommandRunId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommandRunId"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.ToTable("CommandRunLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColorHex") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Labels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProcessedAt") + .HasColumnType("TEXT"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("LlmRequests", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultRole") + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Operations") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("BoardAccesses") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Cards") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Column", "Column") + .WithMany("Cards") + .HasForeignKey("ColumnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("Column"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.HasOne("Taskdeck.Domain.Entities.Card", "Card") + .WithMany("CardLabels") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Label", "Label") + .WithMany("CardLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.HasOne("Taskdeck.Domain.Entities.ChatSession", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Columns") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.CommandRun", "CommandRun") + .WithMany("Logs") + .HasForeignKey("CommandRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CommandRun"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Labels") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Navigation("Operations"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Navigation("BoardAccesses"); + + b.Navigation("Cards"); + + b.Navigation("Columns"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Navigation("CardLabels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Navigation("CardLabels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs new file mode 100644 index 000000000..fb9c99671 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs @@ -0,0 +1,345 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + /// + public partial class AddAutomationArchiveChatOpsEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArchiveItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EntityType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + EntityId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + BoardId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + ArchivedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + ArchivedAt = table.Column(type: "TEXT", nullable: false), + Reason = table.Column(type: "TEXT", maxLength: 500, nullable: true), + SnapshotJson = table.Column(type: "TEXT", nullable: false), + RestoreStatus = table.Column(type: "INTEGER", nullable: false), + RestoredAt = table.Column(type: "TEXT", nullable: true), + RestoredByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ArchiveItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AutomationProposals", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SourceType = table.Column(type: "INTEGER", nullable: false), + SourceReferenceId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + BoardId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + RequestedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + RiskLevel = table.Column(type: "INTEGER", nullable: false), + Summary = table.Column(type: "TEXT", maxLength: 500, nullable: false), + DiffPreview = table.Column(type: "TEXT", nullable: true), + ValidationIssues = table.Column(type: "TEXT", nullable: true), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + DecidedAt = table.Column(type: "TEXT", nullable: true), + DecidedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + AppliedAt = table.Column(type: "TEXT", nullable: true), + FailureReason = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + CorrelationId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AutomationProposals", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ChatSessions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + BoardId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatSessions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CommandRuns", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TemplateName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + RequestedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + StartedAt = table.Column(type: "TEXT", nullable: true), + CompletedAt = table.Column(type: "TEXT", nullable: true), + ExitCode = table.Column(type: "INTEGER", nullable: true), + Truncated = table.Column(type: "INTEGER", nullable: false), + CorrelationId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + ErrorMessage = table.Column(type: "TEXT", maxLength: 2000, nullable: true), + OutputPreview = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CommandRuns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AutomationProposalOperations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProposalId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Sequence = table.Column(type: "INTEGER", nullable: false), + ActionType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + TargetType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + TargetId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Parameters = table.Column(type: "TEXT", nullable: false), + IdempotencyKey = table.Column(type: "TEXT", maxLength: 100, nullable: false), + ExpectedVersion = table.Column(type: "TEXT", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AutomationProposalOperations", x => x.Id); + table.ForeignKey( + name: "FK_AutomationProposalOperations_AutomationProposals_ProposalId", + column: x => x.ProposalId, + principalTable: "AutomationProposals", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChatMessages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SessionId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + MessageType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + ProposalId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + TokenUsage = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatMessages", x => x.Id); + table.ForeignKey( + name: "FK_ChatMessages_ChatSessions_SessionId", + column: x => x.SessionId, + principalTable: "ChatSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CommandRunLogs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CommandRunId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Timestamp = table.Column(type: "TEXT", nullable: false), + Level = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Source = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Message = table.Column(type: "TEXT", nullable: false), + Metadata = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CommandRunLogs", x => x.Id); + table.ForeignKey( + name: "FK_CommandRunLogs_CommandRuns_CommandRunId", + column: x => x.CommandRunId, + principalTable: "CommandRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_ArchivedAt", + table: "ArchiveItems", + column: "ArchivedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_ArchivedByUserId", + table: "ArchiveItems", + column: "ArchivedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_BoardId", + table: "ArchiveItems", + column: "BoardId"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_EntityType_EntityId", + table: "ArchiveItems", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_RestoreStatus", + table: "ArchiveItems", + column: "RestoreStatus"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposalOperations_IdempotencyKey", + table: "AutomationProposalOperations", + column: "IdempotencyKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposalOperations_ProposalId", + table: "AutomationProposalOperations", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposalOperations_ProposalId_Sequence", + table: "AutomationProposalOperations", + columns: new[] { "ProposalId", "Sequence" }); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_BoardId", + table: "AutomationProposals", + column: "BoardId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_CorrelationId", + table: "AutomationProposals", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_ExpiresAt", + table: "AutomationProposals", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_RequestedByUserId", + table: "AutomationProposals", + column: "RequestedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_Status", + table: "AutomationProposals", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_CreatedAt", + table: "ChatMessages", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_ProposalId", + table: "ChatMessages", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_SessionId", + table: "ChatMessages", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_BoardId", + table: "ChatSessions", + column: "BoardId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_CreatedAt", + table: "ChatSessions", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_Status", + table: "ChatSessions", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_UserId", + table: "ChatSessions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRunLogs_CommandRunId", + table: "CommandRunLogs", + column: "CommandRunId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRunLogs_Level", + table: "CommandRunLogs", + column: "Level"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRunLogs_Timestamp", + table: "CommandRunLogs", + column: "Timestamp"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_CorrelationId", + table: "CommandRuns", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_CreatedAt", + table: "CommandRuns", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_RequestedByUserId", + table: "CommandRuns", + column: "RequestedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_Status", + table: "CommandRuns", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveItems"); + + migrationBuilder.DropTable( + name: "AutomationProposalOperations"); + + migrationBuilder.DropTable( + name: "ChatMessages"); + + migrationBuilder.DropTable( + name: "CommandRunLogs"); + + migrationBuilder.DropTable( + name: "AutomationProposals"); + + migrationBuilder.DropTable( + name: "ChatSessions"); + + migrationBuilder.DropTable( + name: "CommandRuns"); + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs index 6d1ef9269..57e5cfa7f 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs @@ -17,6 +17,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder.Entity("Taskdeck.Domain.Entities.ArchiveItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedAt") + .HasColumnType("TEXT"); + + b.Property("ArchivedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RestoreStatus") + .HasColumnType("INTEGER"); + + b.Property("RestoredAt") + .HasColumnType("TEXT"); + + b.Property("RestoredByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArchivedAt"); + + b.HasIndex("ArchivedByUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("RestoreStatus"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("ArchiveItems", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => { b.Property("Id") @@ -61,6 +130,143 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuditLogs", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DiffPreview") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("INTEGER"); + + b.Property("SourceReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValidationIssues") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("AutomationProposals", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedVersion") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("ProposalId"); + + b.HasIndex("ProposalId", "Sequence"); + + b.ToTable("AutomationProposalOperations", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => { b.Property("Id") @@ -195,6 +401,91 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CardLabels", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ProposalId"); + + b.HasIndex("SessionId"); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("ChatSessions", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => { b.Property("Id") @@ -228,6 +519,113 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Columns", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("OutputPreview") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Truncated") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("CommandRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CommandRunId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommandRunId"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.ToTable("CommandRunLogs", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => { b.Property("Id") @@ -366,6 +764,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Operations") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => { b.HasOne("Taskdeck.Domain.Entities.User", null) @@ -431,6 +840,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Label"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.HasOne("Taskdeck.Domain.Entities.ChatSession", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => { b.HasOne("Taskdeck.Domain.Entities.Board", "Board") @@ -442,6 +862,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Board"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.CommandRun", "CommandRun") + .WithMany("Logs") + .HasForeignKey("CommandRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CommandRun"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => { b.HasOne("Taskdeck.Domain.Entities.Board", "Board") @@ -471,6 +902,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Navigation("Operations"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => { b.Navigation("BoardAccesses"); @@ -487,11 +923,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("CardLabels"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => { b.Navigation("Cards"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Navigation("Logs"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => { b.Navigation("CardLabels"); diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs new file mode 100644 index 000000000..6521fb17f --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class ArchiveItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ArchiveItems"); + + builder.HasKey(ai => ai.Id); + + builder.Property(ai => ai.Id) + .ValueGeneratedNever(); + + builder.Property(ai => ai.EntityType) + .IsRequired() + .HasMaxLength(50); + + builder.Property(ai => ai.EntityId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ai => ai.BoardId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ai => ai.Name) + .IsRequired() + .HasMaxLength(200); + + builder.Property(ai => ai.ArchivedByUserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ai => ai.ArchivedAt) + .IsRequired(); + + builder.Property(ai => ai.Reason) + .HasMaxLength(500); + + builder.Property(ai => ai.SnapshotJson) + .IsRequired(); + + builder.Property(ai => ai.RestoreStatus) + .IsRequired() + .HasConversion(); + + builder.Property(ai => ai.RestoredAt); + + builder.Property(ai => ai.RestoredByUserId) + .HasMaxLength(100); + + builder.Property(ai => ai.CreatedAt) + .IsRequired(); + + builder.Property(ai => ai.UpdatedAt) + .IsRequired(); + + builder.HasIndex(ai => ai.BoardId); + builder.HasIndex(ai => new { ai.EntityType, ai.EntityId }); + builder.HasIndex(ai => ai.ArchivedByUserId); + builder.HasIndex(ai => ai.RestoreStatus); + builder.HasIndex(ai => ai.ArchivedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs new file mode 100644 index 000000000..59a6f452b --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class AutomationProposalConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AutomationProposals"); + + builder.HasKey(ap => ap.Id); + + builder.Property(ap => ap.Id) + .ValueGeneratedNever(); + + builder.Property(ap => ap.SourceType) + .IsRequired() + .HasConversion(); + + builder.Property(ap => ap.SourceReferenceId) + .HasMaxLength(100); + + builder.Property(ap => ap.BoardId) + .HasMaxLength(100); + + builder.Property(ap => ap.RequestedByUserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ap => ap.Status) + .IsRequired() + .HasConversion(); + + builder.Property(ap => ap.RiskLevel) + .IsRequired() + .HasConversion(); + + builder.Property(ap => ap.Summary) + .IsRequired() + .HasMaxLength(500); + + builder.Property(ap => ap.DiffPreview); + + builder.Property(ap => ap.ValidationIssues); + + builder.Property(ap => ap.ExpiresAt) + .IsRequired(); + + builder.Property(ap => ap.DecidedAt); + + builder.Property(ap => ap.DecidedByUserId) + .HasMaxLength(100); + + builder.Property(ap => ap.AppliedAt); + + builder.Property(ap => ap.FailureReason) + .HasMaxLength(1000); + + builder.Property(ap => ap.CorrelationId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ap => ap.CreatedAt) + .IsRequired(); + + builder.Property(ap => ap.UpdatedAt) + .IsRequired(); + + builder.HasMany(ap => ap.Operations) + .WithOne(o => o.Proposal) + .HasForeignKey(o => o.ProposalId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(ap => ap.Status); + builder.HasIndex(ap => ap.RequestedByUserId); + builder.HasIndex(ap => ap.BoardId); + builder.HasIndex(ap => ap.CorrelationId); + builder.HasIndex(ap => ap.ExpiresAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs new file mode 100644 index 000000000..e6e6e0c54 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class AutomationProposalOperationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AutomationProposalOperations"); + + builder.HasKey(apo => apo.Id); + + builder.Property(apo => apo.Id) + .ValueGeneratedNever(); + + builder.Property(apo => apo.ProposalId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.Sequence) + .IsRequired(); + + builder.Property(apo => apo.ActionType) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.TargetType) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.TargetId) + .HasMaxLength(100); + + builder.Property(apo => apo.Parameters) + .IsRequired(); + + builder.Property(apo => apo.IdempotencyKey) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.ExpectedVersion) + .HasMaxLength(100); + + builder.Property(apo => apo.CreatedAt) + .IsRequired(); + + builder.Property(apo => apo.UpdatedAt) + .IsRequired(); + + builder.HasIndex(apo => apo.ProposalId); + builder.HasIndex(apo => new { apo.ProposalId, apo.Sequence }); + builder.HasIndex(apo => apo.IdempotencyKey) + .IsUnique(); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs new file mode 100644 index 000000000..979fd1a4d --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class ChatMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ChatMessages"); + + builder.HasKey(cm => cm.Id); + + builder.Property(cm => cm.Id) + .ValueGeneratedNever(); + + builder.Property(cm => cm.SessionId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cm => cm.Role) + .IsRequired() + .HasConversion(); + + builder.Property(cm => cm.Content) + .IsRequired(); + + builder.Property(cm => cm.MessageType) + .IsRequired() + .HasMaxLength(50); + + builder.Property(cm => cm.ProposalId) + .HasMaxLength(100); + + builder.Property(cm => cm.TokenUsage); + + builder.Property(cm => cm.CreatedAt) + .IsRequired(); + + builder.Property(cm => cm.UpdatedAt) + .IsRequired(); + + builder.HasIndex(cm => cm.SessionId); + builder.HasIndex(cm => cm.ProposalId); + builder.HasIndex(cm => cm.CreatedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs new file mode 100644 index 000000000..1e1815bf3 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class ChatSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ChatSessions"); + + builder.HasKey(cs => cs.Id); + + builder.Property(cs => cs.Id) + .ValueGeneratedNever(); + + builder.Property(cs => cs.UserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cs => cs.BoardId) + .HasMaxLength(100); + + builder.Property(cs => cs.Title) + .IsRequired() + .HasMaxLength(200); + + builder.Property(cs => cs.Status) + .IsRequired() + .HasConversion(); + + builder.Property(cs => cs.CreatedAt) + .IsRequired(); + + builder.Property(cs => cs.UpdatedAt) + .IsRequired(); + + builder.HasMany(cs => cs.Messages) + .WithOne(m => m.Session) + .HasForeignKey(m => m.SessionId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(cs => cs.UserId); + builder.HasIndex(cs => cs.BoardId); + builder.HasIndex(cs => cs.Status); + builder.HasIndex(cs => cs.CreatedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs new file mode 100644 index 000000000..70e1b3598 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class CommandRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CommandRuns"); + + builder.HasKey(cr => cr.Id); + + builder.Property(cr => cr.Id) + .ValueGeneratedNever(); + + builder.Property(cr => cr.TemplateName) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cr => cr.RequestedByUserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cr => cr.Status) + .IsRequired() + .HasConversion(); + + builder.Property(cr => cr.StartedAt); + + builder.Property(cr => cr.CompletedAt); + + builder.Property(cr => cr.ExitCode); + + builder.Property(cr => cr.Truncated) + .IsRequired(); + + builder.Property(cr => cr.CorrelationId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cr => cr.ErrorMessage) + .HasMaxLength(2000); + + builder.Property(cr => cr.OutputPreview) + .HasMaxLength(1000); + + builder.Property(cr => cr.CreatedAt) + .IsRequired(); + + builder.Property(cr => cr.UpdatedAt) + .IsRequired(); + + builder.HasMany(cr => cr.Logs) + .WithOne(l => l.CommandRun) + .HasForeignKey(l => l.CommandRunId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(cr => cr.Status); + builder.HasIndex(cr => cr.RequestedByUserId); + builder.HasIndex(cr => cr.CorrelationId); + builder.HasIndex(cr => cr.CreatedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs new file mode 100644 index 000000000..bbe7ff7ad --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class CommandRunLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CommandRunLogs"); + + builder.HasKey(crl => crl.Id); + + builder.Property(crl => crl.Id) + .ValueGeneratedNever(); + + builder.Property(crl => crl.CommandRunId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(crl => crl.Timestamp) + .IsRequired(); + + builder.Property(crl => crl.Level) + .IsRequired() + .HasMaxLength(50); + + builder.Property(crl => crl.Source) + .IsRequired() + .HasMaxLength(200); + + builder.Property(crl => crl.Message) + .IsRequired(); + + builder.Property(crl => crl.Metadata); + + builder.Property(crl => crl.CreatedAt) + .IsRequired(); + + builder.Property(crl => crl.UpdatedAt) + .IsRequired(); + + builder.HasIndex(crl => crl.CommandRunId); + builder.HasIndex(crl => crl.Timestamp); + builder.HasIndex(crl => crl.Level); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs index ee267dafd..b5dae738a 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs @@ -18,6 +18,13 @@ public TaskdeckDbContext(DbContextOptions options) : base(opt public DbSet BoardAccesses => Set(); public DbSet AuditLogs => Set(); public DbSet LlmRequests => Set(); + public DbSet AutomationProposals => Set(); + public DbSet AutomationProposalOperations => Set(); + public DbSet ArchiveItems => Set(); + public DbSet ChatSessions => Set(); + public DbSet ChatMessages => Set(); + public DbSet CommandRuns => Set(); + public DbSet CommandRunLogs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs new file mode 100644 index 000000000..49d5f5545 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class ArchiveItemRepository : Repository, IArchiveItemRepository +{ + public ArchiveItemRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.BoardId == boardId) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByEntityTypeAsync(string entityType, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.EntityType == entityType) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.RestoreStatus == status) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default) + { + return await _dbSet + .FirstOrDefaultAsync(a => a.EntityType == entityType && a.EntityId == entityId, cancellationToken); + } + + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.ArchivedByUserId == userId) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs new file mode 100644 index 000000000..e59d26fa7 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class AutomationProposalRepository : Repository, IAutomationProposalRepository +{ + public AutomationProposalRepository(TaskdeckDbContext context) : base(context) + { + } + + public override async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(p => p.Operations) + .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + } + + public async Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + var proposals = await _dbSet + .Include(p => p.Operations) + .Where(p => p.Status == status) + .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); + } + + public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default) + { + var proposals = await _dbSet + .Include(p => p.Operations) + .Where(p => p.BoardId == boardId) + .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); + } + + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) + { + var proposals = await _dbSet + .Include(p => p.Operations) + .Where(p => p.RequestedByUserId == userId) + .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); + } + + public async Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default) + { + var proposals = await _dbSet + .Include(p => p.Operations) + .Where(p => p.RiskLevel == riskLevel) + .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); + } + + public async Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(p => p.Operations) + .FirstOrDefaultAsync(p => p.SourceType == sourceType && p.SourceReferenceId == referenceId, cancellationToken); + } + + public async Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(p => p.Operations) + .FirstOrDefaultAsync(p => p.CorrelationId == correlationId, cancellationToken); + } + + public async Task> GetExpiredAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _dbSet + .Include(p => p.Operations) + .Where(p => p.Status == ProposalStatus.PendingReview && p.ExpiresAt < now) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs new file mode 100644 index 000000000..d8545318f --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class ChatMessageRepository : Repository, IChatMessageRepository +{ + public ChatMessageRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetBySessionIdAsync(Guid sessionId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(m => m.SessionId == sessionId) + .OrderBy(m => m.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByProposalIdAsync(Guid proposalId, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(m => m.ProposalId == proposalId) + .OrderBy(m => m.CreatedAt) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs new file mode 100644 index 000000000..daa586393 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class ChatSessionRepository : Repository, IChatSessionRepository +{ + public ChatSessionRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(s => s.UserId == userId) + .OrderByDescending(s => s.UpdatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(s => s.BoardId == boardId) + .OrderByDescending(s => s.UpdatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync(ChatSessionStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(s => s.Status == status) + .OrderByDescending(s => s.UpdatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(s => s.Messages) + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs new file mode 100644 index 000000000..2cf8c8b9d --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class CommandRunRepository : Repository, ICommandRunRepository +{ + public CommandRunRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(c => c.RequestedByUserId == userId) + .OrderByDescending(c => c.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync(CommandRunStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(c => c.Status == status) + .OrderByDescending(c => c.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByTemplateNameAsync(string templateName, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(c => c.TemplateName == templateName) + .OrderByDescending(c => c.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + return await _dbSet + .FirstOrDefaultAsync(c => c.CorrelationId == correlationId, cancellationToken); + } + + public async Task GetByIdWithLogsAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(c => c.Logs) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index dfd4cecf0..cc2cfdfdf 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -18,7 +18,12 @@ public UnitOfWork( IUserRepository users, IBoardAccessRepository boardAccesses, IAuditLogRepository auditLogs, - ILlmQueueRepository llmQueue) + ILlmQueueRepository llmQueue, + IAutomationProposalRepository automationProposals, + IArchiveItemRepository archiveItems, + IChatSessionRepository chatSessions, + IChatMessageRepository chatMessages, + ICommandRunRepository commandRuns) { _context = context; Boards = boards; @@ -29,6 +34,11 @@ public UnitOfWork( BoardAccesses = boardAccesses; AuditLogs = auditLogs; LlmQueue = llmQueue; + AutomationProposals = automationProposals; + ArchiveItems = archiveItems; + ChatSessions = chatSessions; + ChatMessages = chatMessages; + CommandRuns = commandRuns; } public IBoardRepository Boards { get; } @@ -39,6 +49,11 @@ public UnitOfWork( public IBoardAccessRepository BoardAccesses { get; } public IAuditLogRepository AuditLogs { get; } public ILlmQueueRepository LlmQueue { get; } + public IAutomationProposalRepository AutomationProposals { get; } + public IArchiveItemRepository ArchiveItems { get; } + public IChatSessionRepository ChatSessions { get; } + public IChatMessageRepository ChatMessages { get; } + public ICommandRunRepository CommandRuns { get; } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { diff --git a/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj b/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj index 34b5350b8..39f600f4c 100644 --- a/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj +++ b/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj @@ -12,6 +12,7 @@ + diff --git a/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs new file mode 100644 index 000000000..eb5326cc9 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class ArchiveApiTests : IClassFixture +{ + private readonly HttpClient _client; + + public ArchiveApiTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetArchiveItems_ShouldReturnList() + { + await AuthenticateAsync("archive-list"); + + var response = await _client.GetAsync("/api/archive/items"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var items = await response.Content.ReadFromJsonAsync>(); + items.Should().NotBeNull(); + } + + [Fact] + public async Task GetArchiveItem_ShouldReturnNotFound_WhenItemDoesNotExist() + { + await AuthenticateAsync("archive-item-notfound"); + + var response = await _client.GetAsync($"/api/archive/items/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task RestoreArchivedItem_WhenNotFound_ShouldReturnNotFound() + { + await AuthenticateAsync("archive-restore-notfound"); + + var restoreDto = new RestoreArchiveItemDto( + TargetBoardId: Guid.NewGuid(), + RestoreMode: RestoreMode.InPlace, + ConflictStrategy: ConflictStrategy.Fail + ); + + var response = await _client.PostAsJsonAsync( + $"/api/archive/Card/{Guid.NewGuid()}/restore", + restoreDto); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task RestoreArchivedItem_WithInvalidEntityType_ShouldReturnBadRequest() + { + await AuthenticateAsync("archive-restore-invalid-type"); + + var restoreDto = new RestoreArchiveItemDto( + TargetBoardId: Guid.NewGuid(), + RestoreMode: RestoreMode.InPlace, + ConflictStrategy: ConflictStrategy.Fail + ); + + var response = await _client.PostAsJsonAsync( + $"/api/archive/not-a-type/{Guid.NewGuid()}/restore", + restoreDto); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("ValidationError"); + } + + [Fact] + public async Task GetArchiveItems_WithLimit_ShouldRespectLimit() + { + await AuthenticateAsync("archive-limit"); + + var response = await _client.GetAsync("/api/archive/items?limit=5"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var items = await response.Content.ReadFromJsonAsync>(); + items.Should().NotBeNull(); + items!.Count.Should().BeLessOrEqualTo(5); + } + + private async Task AuthenticateAsync(string stem) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var username = $"{stem}_{suffix}"; + var email = $"{stem}_{suffix}@example.com"; + const string password = "password123"; + + var response = await _client.PostAsJsonAsync( + "/api/auth/register", + new CreateUserDto(username, email, password)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", payload!.Token); + return payload.User.Id; + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs new file mode 100644 index 000000000..eccc42bdb --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs @@ -0,0 +1,303 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class AutomationProposalsApiTests : IClassFixture +{ + private readonly HttpClient _client; + + public AutomationProposalsApiTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task CreateProposal_ThenGetProposal_ShouldReturnCreatedProposal() + { + var userId = await AuthenticateAsync("automation-create"); + var boardId = await CreateOwnedBoardAsync(userId); + var correlationId = Guid.NewGuid().ToString(); + + var createRequest = new CreateProposalDto( + SourceType: ProposalSourceType.Chat, + RequestedByUserId: userId, + Summary: "Test automation proposal", + RiskLevel: RiskLevel.Low, + CorrelationId: correlationId, + BoardId: boardId, + Operations: new List + { + new( + Sequence: 1, + ActionType: "CreateCard", + TargetType: "Card", + Parameters: "{\"title\":\"Test Card\"}", + IdempotencyKey: Guid.NewGuid().ToString() + ) + } + ); + + var createResponse = await _client.PostAsJsonAsync("/api/automation/proposals", createRequest); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var createdProposal = await createResponse.Content.ReadFromJsonAsync(); + createdProposal.Should().NotBeNull(); + createdProposal!.Summary.Should().Be(createRequest.Summary); + createdProposal.Status.Should().Be(ProposalStatus.PendingReview); + createdProposal.RiskLevel.Should().Be(RiskLevel.Low); + createdProposal.Operations.Should().HaveCount(1); + + var getResponse = await _client.GetAsync($"/api/automation/proposals/{createdProposal.Id}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var retrievedProposal = await getResponse.Content.ReadFromJsonAsync(); + retrievedProposal.Should().NotBeNull(); + retrievedProposal!.Id.Should().Be(createdProposal.Id); + retrievedProposal.Summary.Should().Be(createRequest.Summary); + } + + [Fact] + public async Task GetProposals_WithFilters_ShouldReturnFilteredResults() + { + var userId = await AuthenticateAsync("automation-filters"); + var boardId = await CreateOwnedBoardAsync(userId); + + var proposal1 = await CreateTestProposal(userId, boardId, RiskLevel.Low); + var proposal2 = await CreateTestProposal(userId, boardId, RiskLevel.High); + + var response = await _client.GetAsync($"/api/automation/proposals?boardId={boardId}&status={ProposalStatus.PendingReview}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var proposals = await response.Content.ReadFromJsonAsync>(); + proposals.Should().NotBeNull(); + proposals.Should().NotBeEmpty(); + proposals.Should().Contain(p => p.Id == proposal1.Id); + proposals.Should().Contain(p => p.Id == proposal2.Id); + } + + [Fact] + public async Task ApproveProposal_ShouldUpdateStatus() + { + var userId = await AuthenticateAsync("automation-approve"); + var boardId = await CreateOwnedBoardAsync(userId); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var approveResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve", null); + approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var approvedProposal = await approveResponse.Content.ReadFromJsonAsync(); + approvedProposal.Should().NotBeNull(); + approvedProposal!.Status.Should().Be(ProposalStatus.Approved); + approvedProposal.DecidedByUserId.Should().Be(userId); + approvedProposal.DecidedAt.Should().NotBeNull(); + } + + [Fact] + public async Task RejectProposal_ShouldUpdateStatus() + { + var userId = await AuthenticateAsync("automation-reject"); + var boardId = await CreateOwnedBoardAsync(userId); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var rejectDto = new UpdateProposalStatusDto(Reason: "Not needed"); + var rejectResponse = await _client.PostAsJsonAsync($"/api/automation/proposals/{proposal.Id}/reject", rejectDto); + rejectResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var rejectedProposal = await rejectResponse.Content.ReadFromJsonAsync(); + rejectedProposal.Should().NotBeNull(); + rejectedProposal!.Status.Should().Be(ProposalStatus.Rejected); + rejectedProposal.DecidedByUserId.Should().Be(userId); + } + + [Fact] + public async Task ExecuteProposal_WhenApproved_ShouldMarkAsApplied() + { + var userId = await AuthenticateAsync("automation-exec-applied"); + var boardId = await CreateOwnedBoardAsync(userId); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve", null); + + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposal.Id}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var executeResponse = await _client.SendAsync(executeRequest); + executeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var executedProposal = await executeResponse.Content.ReadFromJsonAsync(); + executedProposal.Should().NotBeNull(); + executedProposal!.Status.Should().Be(ProposalStatus.Applied); + executedProposal.AppliedAt.Should().NotBeNull(); + } + + [Fact] + public async Task ExecuteProposal_WhenNotApproved_ShouldReturnConflict() + { + var userId = await AuthenticateAsync("automation-exec-conflict"); + var boardId = await CreateOwnedBoardAsync(userId); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposal.Id}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var executeResponse = await _client.SendAsync(executeRequest); + executeResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var error = await executeResponse.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("InvalidOperation"); + } + + [Fact] + public async Task ExecuteProposal_WithoutIdempotencyKey_ShouldReturnBadRequest() + { + var userId = await AuthenticateAsync("automation-exec-no-idempotency"); + var boardId = await CreateOwnedBoardAsync(userId); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve", null); + + var executeResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/execute", null); + executeResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var error = await executeResponse.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("ValidationError"); + } + + [Fact] + public async Task ApproveProposal_ShouldReturnUnauthorized_WhenNotAuthenticated() + { + _client.DefaultRequestHeaders.Authorization = null; + + var response = await _client.PostAsync($"/api/automation/proposals/{Guid.NewGuid()}/approve", null); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetProposalDiff_ShouldReturnDiffPreview() + { + var userId = await AuthenticateAsync("automation-diff"); + var boardId = await CreateOwnedBoardAsync(userId); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var diffResponse = await _client.GetAsync($"/api/automation/proposals/{proposal.Id}/diff"); + diffResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var diffResult = await diffResponse.Content.ReadFromJsonAsync(); + diffResult.TryGetProperty("diff", out var diff).Should().BeTrue(); + } + + [Fact] + public async Task GetProposal_ShouldReturnNotFound_WhenProposalDoesNotExist() + { + await AuthenticateAsync("automation-get-notfound"); + + var response = await _client.GetAsync($"/api/automation/proposals/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task ApproveProposal_ShouldReturnNotFound_WhenProposalDoesNotExist() + { + await AuthenticateAsync("automation-approve-notfound"); + + var response = await _client.PostAsync($"/api/automation/proposals/{Guid.NewGuid()}/approve", null); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task CreateProposal_WithEmptySummary_ShouldReturnBadRequest() + { + var userId = await AuthenticateAsync("automation-create-invalid"); + + var createRequest = new CreateProposalDto( + SourceType: ProposalSourceType.Chat, + RequestedByUserId: userId, + Summary: string.Empty, + RiskLevel: RiskLevel.Low, + CorrelationId: Guid.NewGuid().ToString() + ); + + var response = await _client.PostAsJsonAsync("/api/automation/proposals", createRequest); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("ValidationError"); + } + + private async Task CreateTestProposal(Guid userId, Guid boardId, RiskLevel riskLevel) + { + var createRequest = new CreateProposalDto( + SourceType: ProposalSourceType.Chat, + RequestedByUserId: userId, + Summary: $"Test proposal {Guid.NewGuid()}", + RiskLevel: riskLevel, + CorrelationId: Guid.NewGuid().ToString(), + BoardId: boardId, + Operations: new List + { + new( + Sequence: 1, + ActionType: "update", + TargetType: "board", + Parameters: $"{{\"boardId\":\"{boardId}\",\"name\":\"Automated update {Guid.NewGuid():N}\"}}", + IdempotencyKey: Guid.NewGuid().ToString(), + TargetId: boardId.ToString() + ) + } + ); + + var response = await _client.PostAsJsonAsync("/api/automation/proposals", createRequest); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task AuthenticateAsync(string stem) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var username = $"{stem}_{suffix}"; + var email = $"{stem}_{suffix}@example.com"; + const string password = "password123"; + + var response = await _client.PostAsJsonAsync( + "/api/auth/register", + new CreateUserDto(username, email, password)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", payload!.Token); + return payload.User.Id; + } + + private async Task CreateOwnedBoardAsync(Guid ownerId) + { + var response = await _client.PostAsJsonAsync( + $"/api/import/boards?userId={ownerId}", + new ImportBoardDto( + $"automation-board-{Guid.NewGuid():N}", + null, + Array.Empty(), + Array.Empty(), + Array.Empty())); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Success.Should().BeTrue(); + result.BoardId.Should().NotBeNull(); + + return result.BoardId!.Value; + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs new file mode 100644 index 000000000..903a3e52f --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs @@ -0,0 +1,928 @@ +using System.Text.Json; +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class ArchiveRecoveryServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _archiveItemRepoMock; + private readonly Mock _auditLogRepoMock; + private readonly Mock _boardRepoMock; + private readonly Mock _columnRepoMock; + private readonly Mock _cardRepoMock; + private readonly Mock _authorizationServiceMock; + private readonly ArchiveRecoveryService _service; + + public ArchiveRecoveryServiceTests() + { + _unitOfWorkMock = new Mock(); + _archiveItemRepoMock = new Mock(); + _auditLogRepoMock = new Mock(); + _boardRepoMock = new Mock(); + _columnRepoMock = new Mock(); + _cardRepoMock = new Mock(); + _authorizationServiceMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.ArchiveItems).Returns(_archiveItemRepoMock.Object); + _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditLogRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + + _service = new ArchiveRecoveryService(_unitOfWorkMock.Object, _authorizationServiceMock.Object); + } + + #region CreateArchiveItemAsync Tests + + [Fact] + public async Task CreateArchiveItemAsync_ShouldReturnSuccess_WithValidData() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test Board", Description = "Test" }); + var dto = new CreateArchiveItemDto( + "board", + entityId, + boardId, + "Test Board", + userId, + snapshotJson, + "Archived by user"); + + _archiveItemRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((ArchiveItem a, CancellationToken ct) => a); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.CreateArchiveItemAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.EntityType.Should().Be("board"); + result.Value.EntityId.Should().Be(entityId); + result.Value.BoardId.Should().Be(boardId); + result.Value.Name.Should().Be("Test Board"); + result.Value.ArchivedByUserId.Should().Be(userId); + result.Value.RestoreStatus.Should().Be(RestoreStatus.Available); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task CreateArchiveItemAsync_ShouldReturnFailure_WithInvalidEntityType() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var dto = new CreateArchiveItemDto( + "invalid", + entityId, + boardId, + "Test", + userId, + "{}", + null); + + // Act + var result = await _service.CreateArchiveItemAsync(dto); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("EntityType"); + } + + [Fact] + public async Task CreateArchiveItemAsync_ShouldCreateAuditLog() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test Card" }); + var dto = new CreateArchiveItemDto( + "card", + entityId, + boardId, + "Test Card", + userId, + snapshotJson, + null); + + _archiveItemRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((ArchiveItem a, CancellationToken ct) => a); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.CreateArchiveItemAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _auditLogRepoMock.Verify(r => r.AddAsync( + It.Is(a => + a.EntityType == "ArchiveItem" + && a.Action == AuditAction.Created + && a.UserId == userId), + default), Times.Once); + } + + #endregion + + #region GetArchiveItemsAsync Tests + + [Fact] + public async Task GetArchiveItemsAsync_ShouldReturnAll_WhenNoFiltersProvided() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = new List + { + CreateArchiveItem("board", Guid.NewGuid(), boardId, "Board 1", userId), + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId), + CreateArchiveItem("column", Guid.NewGuid(), boardId, "Column 1", userId) + }; + + _archiveItemRepoMock.Setup(r => r.GetAllAsync(default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(3); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldFilterByEntityType() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardItems = new List + { + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId), + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 2", userId) + }; + + _archiveItemRepoMock.Setup(r => r.GetByEntityTypeAsync("card", 100, default)) + .ReturnsAsync(cardItems); + + // Act + var result = await _service.GetArchiveItemsAsync(entityType: "card"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().OnlyContain(i => i.EntityType == "card"); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldFilterByBoardId() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = new List + { + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId), + CreateArchiveItem("column", Guid.NewGuid(), boardId, "Column 1", userId) + }; + + _archiveItemRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, 100, default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(boardId: boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().OnlyContain(i => i.BoardId == boardId); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldFilterByStatus() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = new List + { + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId, RestoreStatus.Available) + }; + + _archiveItemRepoMock.Setup(r => r.GetByStatusAsync(RestoreStatus.Available, 100, default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(status: RestoreStatus.Available); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.Should().OnlyContain(i => i.RestoreStatus == RestoreStatus.Available); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldRespectLimit() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = Enumerable.Range(0, 150) + .Select(i => CreateArchiveItem("card", Guid.NewGuid(), boardId, $"Card {i}", userId)) + .ToList(); + + _archiveItemRepoMock.Setup(r => r.GetAllAsync(default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(limit: 50); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(50); + } + + #endregion + + #region GetArchiveItemByIdAsync Tests + + [Fact] + public async Task GetArchiveItemByIdAsync_ShouldReturnItem_WhenExists() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test Board", userId); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + // Act + var result = await _service.GetArchiveItemByIdAsync(item.Id); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(item.Id); + result.Value.Name.Should().Be("Test Board"); + } + + [Fact] + public async Task GetArchiveItemByIdAsync_ShouldReturnNotFound_WhenDoesNotExist() + { + // Arrange + var id = Guid.NewGuid(); + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(id, default)) + .ReturnsAsync((ArchiveItem?)null); + + // Act + var result = await _service.GetArchiveItemByIdAsync(id); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + #endregion + + #region RestoreArchiveItemAsync - General Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnNotFound_WhenArchiveItemDoesNotExist() + { + // Arrange + var id = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(id, default)) + .ReturnsAsync((ArchiveItem?)null); + + // Act + var result = await _service.RestoreArchiveItemAsync(id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnFailure_WhenAlreadyRestored() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test", userId); + item.MarkAsRestored(userId); + + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("Restored"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnForbidden_WhenUserLacksPermission() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test", Description = (string?)null }); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(false)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnFailure_WhenAuthorizationCheckFails() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test", Description = (string?)null }); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Failure(ErrorCodes.NotFound, "Board missing")); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + result.ErrorMessage.Should().Contain("missing"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnNotFound_WhenTargetBoardDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test" }); + var item = CreateArchiveItem("column", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardId, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync((Board?)null); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + result.ErrorMessage.Should().Contain("board"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnFailure_WhenTargetBoardIsArchived() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var archivedBoard = TestDataBuilder.CreateBoard(isArchived: true); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test" }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(archivedBoard.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, archivedBoard.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(archivedBoard.Id, default)) + .ReturnsAsync(archivedBoard); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("archived board"); + } + + #endregion + + #region RestoreArchiveItemAsync - Board Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreBoard_WithoutConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Unique Board", Description = "Test board" }); + var item = CreateArchiveItem("board", boardId, boardId, "Unique Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Unique Board", false, default)) + .ReturnsAsync(new List()); + _boardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Board b, CancellationToken ct) => b); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Success.Should().BeTrue(); + result.Value.RestoredEntityId.Should().NotBeEmpty(); + result.Value.ResolvedName.Should().Be("Unique Board"); + _boardRepoMock.Verify(r => r.AddAsync(It.Is(b => b.Name == "Unique Board"), default), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldFailOnConflict_WithFailStrategy() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var existingBoard = TestDataBuilder.CreateBoard("Existing Board"); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Board", Description = (string?)null }); + var item = CreateArchiveItem("board", boardId, boardId, "Existing Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Existing Board", false, default)) + .ReturnsAsync(new List { existingBoard }); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Conflict); + result.ErrorMessage.Should().Contain("already exists"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRenameOnConflict_WithRenameStrategy() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var existingBoard = TestDataBuilder.CreateBoard("Existing Board"); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Board", Description = (string?)null }); + var item = CreateArchiveItem("board", boardId, boardId, "Existing Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.Rename); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Existing Board", false, default)) + .ReturnsAsync(new List { existingBoard }); + _boardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Board b, CancellationToken ct) => b); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("Existing Board (Restored)"); + _boardRepoMock.Verify(r => r.AddAsync(It.Is(b => b.Name == "Existing Board (Restored)"), default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldAppendSuffixOnConflict_WithAppendSuffixStrategy() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var existingBoard = TestDataBuilder.CreateBoard("Existing Board"); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Board", Description = (string?)null }); + var item = CreateArchiveItem("board", boardId, boardId, "Existing Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.AppendSuffix); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Existing Board", false, default)) + .ReturnsAsync(new List { existingBoard }); + _boardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Board b, CancellationToken ct) => b); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.ResolvedName.Should().StartWith("Existing Board - "); + result.Value.ResolvedName.Should().MatchRegex(@"Existing Board - \d{8}-\d{6}"); + } + + #endregion + + #region RestoreArchiveItemAsync - Column Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreColumn_WithoutConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column1 = TestDataBuilder.CreateColumn(board.Id, "Existing Column", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column1 }); + + var snapshotJson = JsonSerializer.Serialize(new { Name = "New Column", Position = 0, WipLimit = (int?)5 }); + var item = CreateArchiveItem("column", Guid.NewGuid(), boardId, "New Column", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Column c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Success.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("New Column"); + _columnRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.Name == "New Column" && c.Position == 1 && c.WipLimit == 5), + default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRenameColumn_OnConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column1 = TestDataBuilder.CreateColumn(board.Id, "Existing Column", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column1 }); + + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Column", Position = 0, WipLimit = (int?)null }); + var item = CreateArchiveItem("column", Guid.NewGuid(), boardId, "Existing Column", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Rename); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Column c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("Existing Column (Restored)"); + _columnRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.Name == "Existing Column (Restored)"), + default), Times.Once); + } + + #endregion + + #region RestoreArchiveItemAsync - Card Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreCard_WithoutConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "New Card", + Description = "Test card", + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = column.Id + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "New Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Success.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("New Card"); + _cardRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.Title == "New Card" && c.ColumnId == column.Id), + default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreCardToFirstColumn_WhenOriginalColumnMissing() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "Card", + Description = (string?)null, + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = Guid.NewGuid() // Non-existent column + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _cardRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.ColumnId == column.Id), + default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldFail_WhenTargetBoardHasNoColumns() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "Card", + Description = (string?)null, + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = Guid.NewGuid() + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(board.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, board.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)) + .ReturnsAsync(board); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(board.Id, default)) + .ReturnsAsync(board); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("no columns"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldFail_WhenWipLimitExceeded() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var card1 = TestDataBuilder.CreateCard(board.Id, Guid.NewGuid(), "Card 1", position: 0); + var card2 = TestDataBuilder.CreateCard(board.Id, Guid.NewGuid(), "Card 2", position: 1); + var column = TestDataBuilder.CreateColumnWithCards(board.Id, "To Do", new[] { card1, card2 }, position: 0, wipLimit: 2); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "New Card", + Description = (string?)null, + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = column.Id + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "New Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.WipLimitExceeded); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreBlockedCard() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "Blocked Card", + Description = "Test", + DueDate = (DateTimeOffset?)null, + IsBlocked = true, + BlockReason = "Waiting on dependency", + ColumnId = column.Id + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Blocked Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _cardRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.IsBlocked && c.BlockReason == "Waiting on dependency"), + default), Times.Once); + } + + #endregion + + #region Helper Methods + + private static ArchiveItem CreateArchiveItem( + string entityType, + Guid entityId, + Guid boardId, + string name, + Guid userId, + RestoreStatus status = RestoreStatus.Available, + string? snapshotJson = null) + { + snapshotJson ??= JsonSerializer.Serialize(new { Name = name }); + var item = new ArchiveItem(entityType, entityId, boardId, name, userId, snapshotJson, null); + + if (status == RestoreStatus.Restored) + { + item.MarkAsRestored(userId); + } + else if (status == RestoreStatus.Expired) + { + item.MarkAsExpired(); + } + else if (status == RestoreStatus.Conflict) + { + item.MarkAsConflict(); + } + + return item; + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationExecutorServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationExecutorServiceTests.cs new file mode 100644 index 000000000..934b31cf9 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationExecutorServiceTests.cs @@ -0,0 +1,232 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationExecutorServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _proposalServiceMock; + private readonly Mock _policyEngineMock; + private readonly Mock _cardServiceMock; + private readonly Mock _boardServiceMock; + private readonly Mock _columnServiceMock; + private readonly Mock _proposalRepoMock; + private readonly Mock _auditLogRepoMock; + private readonly AutomationExecutorService _service; + + public AutomationExecutorServiceTests() + { + _unitOfWorkMock = new Mock(); + _proposalServiceMock = new Mock(); + _policyEngineMock = new Mock(); + _proposalRepoMock = new Mock(); + _auditLogRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.AutomationProposals).Returns(_proposalRepoMock.Object); + _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditLogRepoMock.Object); + + // Create mocks for services - they need IUnitOfWork in constructor + _cardServiceMock = new Mock(_unitOfWorkMock.Object); + _boardServiceMock = new Mock(_unitOfWorkMock.Object); + _columnServiceMock = new Mock(_unitOfWorkMock.Object); + + _service = new AutomationExecutorService( + _unitOfWorkMock.Object, + _proposalServiceMock.Object, + _policyEngineMock.Object, + _cardServiceMock.Object, + _boardServiceMock.Object, + _columnServiceMock.Object); + } + + #region ExecuteProposal Tests + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForEmptyProposalId() + { + // Act + var result = await _service.ExecuteProposalAsync(Guid.Empty, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForEmptyIdempotencyKey() + { + // Arrange + var proposalId = Guid.NewGuid(); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, ""); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("IdempotencyKey"); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForNonexistentProposal() + { + // Arrange + var proposalId = Guid.NewGuid(); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Failure(ErrorCodes.NotFound, "Not found")); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForNonApprovedProposal() + { + // Arrange + var proposalId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var proposal = new ProposalDto( + proposalId, + ProposalSourceType.Manual, + null, + null, + userId, + ProposalStatus.PendingReview, // Not approved + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Success(proposal)); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("Cannot execute proposal"); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_WhenPolicyValidationFails() + { + // Arrange + var proposalId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), proposalId, 0, "create", "card", null, "{\"title\":\"Test\"}", "key1", null) + }; + + var proposal = new ProposalDto( + proposalId, + ProposalSourceType.Manual, + null, + null, + userId, + ProposalStatus.Approved, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(-1), // Expired + null, + null, + null, + null, + "corr1", + operations + ); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Success(proposal)); + _policyEngineMock.Setup(e => e.ValidatePolicy(proposal)) + .Returns(Result.Failure(ErrorCodes.ValidationError, "Expired")); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_WhenPermissionValidationFails() + { + // Arrange + var proposalId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), proposalId, 0, "create", "card", null, "{\"title\":\"Test\"}", "key1", null) + }; + + var proposal = new ProposalDto( + proposalId, + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.Approved, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Success(proposal)); + _policyEngineMock.Setup(e => e.ValidatePolicy(proposal)) + .Returns(Result.Success()); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, operations, default)) + .ReturnsAsync(Result.Failure(ErrorCodes.Forbidden, "No access")); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs new file mode 100644 index 000000000..67e10db4d --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs @@ -0,0 +1,461 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationPlannerServiceTests +{ + private readonly Mock _proposalServiceMock; + private readonly Mock _policyEngineMock; + private readonly Mock _unitOfWorkMock; + private readonly Mock _columnRepoMock; + private readonly Mock _cardRepoMock; + private readonly AutomationPlannerService _service; + + public AutomationPlannerServiceTests() + { + _proposalServiceMock = new Mock(); + _policyEngineMock = new Mock(); + _unitOfWorkMock = new Mock(); + _columnRepoMock = new Mock(); + _cardRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + + _service = new AutomationPlannerService( + _proposalServiceMock.Object, + _policyEngineMock.Object, + _unitOfWorkMock.Object); + } + + #region ParseInstruction Tests + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForEmptyInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var result = await _service.ParseInstructionAsync("", userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("cannot be empty"); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForEmptyUserId() + { + // Act + var result = await _service.ParseInstructionAsync("create card 'test'", Guid.Empty); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForCreateCardInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var columnId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "To Do", 0); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + "create card 'Test Task'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Test Task'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.RequestedByUserId == userId && + dto.BoardId == boardId && + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "create" && + dto.Operations[0].TargetType == "card" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForCreateCardWithColumnName() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "In Progress", 1); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + "create card 'Task' in column 'In Progress'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Task' in column 'In Progress'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForCreateCardWithoutBoardId() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Test Task'", userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("Board ID is required"); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForMoveCardInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "Done", 2); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + $"move card {cardId} to column 'Done'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync($"move card {cardId} to column 'Done'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "move" && + dto.Operations[0].TargetType == "card" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForArchiveCardInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Medium, + $"archive card {cardId}", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Medium); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync($"archive card {cardId}", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "archive" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForArchiveCardsMatchingInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var card1 = TestDataBuilder.CreateCard(boardId, Guid.NewGuid(), "Old Task 1"); + var card2 = TestDataBuilder.CreateCard(boardId, Guid.NewGuid(), "Old Task 2"); + var card3 = TestDataBuilder.CreateCard(boardId, Guid.NewGuid(), "New Task"); + + _cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { card1, card2, card3 }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Medium, + "archive cards matching 'Old'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Medium); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync("archive cards matching 'Old'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 2 // Only card1 and card2 match + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForUpdateCardTitleInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + $"update card {cardId} title 'New Title'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync($"update card {cardId} title 'New Title'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "update" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForUnrecognizedPattern() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + + // Act + var result = await _service.ParseInstructionAsync("do something random", userId, boardId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("Could not parse instruction"); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_WhenPermissionValidationFails() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "To Do", 0); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + "create card 'Test'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Failure(ErrorCodes.Forbidden, "No access")); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Test'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationPolicyEngineTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPolicyEngineTests.cs new file mode 100644 index 000000000..dfaf81900 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPolicyEngineTests.cs @@ -0,0 +1,483 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationPolicyEngineTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _userRepoMock; + private readonly Mock _boardRepoMock; + private readonly Mock _boardAccessRepoMock; + private readonly Mock _cardRepoMock; + private readonly AutomationPolicyEngine _engine; + + public AutomationPolicyEngineTests() + { + _unitOfWorkMock = new Mock(); + _userRepoMock = new Mock(); + _boardRepoMock = new Mock(); + _boardAccessRepoMock = new Mock(); + _cardRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Users).Returns(_userRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.BoardAccesses).Returns(_boardAccessRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + + _engine = new AutomationPolicyEngine(_unitOfWorkMock.Object); + } + + #region ClassifyRisk Tests + + [Fact] + public void ClassifyRisk_ShouldReturnLow_ForEmptyOperations() + { + // Arrange + var operations = new List(); + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Low); + } + + [Fact] + public void ClassifyRisk_ShouldReturnLow_ForSimpleCardCreate() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Low); + } + + [Fact] + public void ClassifyRisk_ShouldReturnMedium_ForArchiveOperation() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "archive", "card", "card1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Medium); + } + + [Fact] + public void ClassifyRisk_ShouldReturnMedium_ForManyOperations() + { + // Arrange + var operations = Enumerable.Range(0, 7) + .Select(i => new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), i, "create", "card", null, "{}", $"key{i}", null)) + .ToList(); + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Medium); + } + + [Fact] + public void ClassifyRisk_ShouldReturnHigh_ForDeleteOperation() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "delete", "card", "card1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.High); + } + + [Fact] + public void ClassifyRisk_ShouldReturnHigh_ForBoardUpdate() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "update", "board", "board1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.High); + } + + [Fact] + public void ClassifyRisk_ShouldReturnCritical_ForBoardDelete() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "delete", "board", "board1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Critical); + } + + [Fact] + public void ClassifyRisk_ShouldReturnCritical_ForManyOperations() + { + // Arrange + var operations = Enumerable.Range(0, 25) + .Select(i => new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), i, "create", "card", null, "{}", $"key{i}", null)) + .ToList(); + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Critical); + } + + #endregion + + #region ValidatePermissions Tests + + [Fact] + public async Task ValidatePermissions_ShouldReturnSuccess_ForValidUser() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new User("testuser", "test@example.com", "hashedPassword"); + var operations = new List(); + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, null, operations); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForInvalidUserId() + { + // Arrange + var operations = new List(); + + // Act + var result = await _engine.ValidatePermissionsAsync(Guid.Empty, null, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForNonexistentUser() + { + // Arrange + var userId = Guid.NewGuid(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync((User?)null); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, null, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForNonexistentBoard() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var user = new User("testuser", "test@example.com", "hashedPassword"); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync((Board?)null); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, boardId, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForUnauthorizedBoardAccess() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var user = new User("testuser", "test@example.com", "hashedPassword"); + var board = TestDataBuilder.CreateBoard(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(board); + _boardAccessRepoMock.Setup(r => r.HasAccessAsync(boardId, userId, It.IsAny(), default)) + .ReturnsAsync(false); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, boardId, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion + + #region ValidatePolicy Tests + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForNullProposal() + { + // Act + var result = _engine.ValidatePolicy(null!); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForEmptyOperations() + { + // Arrange + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForTooManyOperations() + { + // Arrange + var operations = Enumerable.Range(0, 51) + .Select(i => new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), i, "create", "card", null, "{}", $"key{i}", null)) + .ToList(); + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("maximum operation count"); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForDuplicateSequences() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null), + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key2", null) + }; + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("sequences must be unique"); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForExpiredProposal() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(-1), // Expired + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("expired"); + } + + [Fact] + public void ValidatePolicy_ShouldReturnSuccess_ForValidProposal() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null), + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 1, "update", "card", "card1", "{}", "key2", null) + }; + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs new file mode 100644 index 000000000..03f3ad00a --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs @@ -0,0 +1,508 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationProposalServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _proposalRepoMock; + private readonly AutomationProposalService _service; + + public AutomationProposalServiceTests() + { + _unitOfWorkMock = new Mock(); + _proposalRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.AutomationProposals).Returns(_proposalRepoMock.Object); + + _service = new AutomationProposalService(_unitOfWorkMock.Object); + } + + #region CreateProposalAsync Tests + + [Fact] + public async Task CreateProposalAsync_ShouldReturnSuccess_WithValidData() + { + // Arrange + var dto = new CreateProposalDto( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Create new card", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AutomationProposal p, CancellationToken ct) => p); + + // Act + var result = await _service.CreateProposalAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Summary.Should().Be("Create new card"); + result.Value.Status.Should().Be(ProposalStatus.PendingReview); + result.Value.RiskLevel.Should().Be(RiskLevel.Low); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task CreateProposalAsync_ShouldAddOperations_WhenProvided() + { + // Arrange + var operations = new List + { + new(0, "card.create", "Card", "{\"name\":\"Test\"}", "key1"), + new(1, "card.move", "Card", "{\"position\":5}", "key2", "card-123") + }; + + var dto = new CreateProposalDto( + ProposalSourceType.Manual, + Guid.NewGuid(), + "Multi-step operation", + RiskLevel.Medium, + Guid.NewGuid().ToString(), + Operations: operations); + + _proposalRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AutomationProposal p, CancellationToken ct) => p); + + // Act + var result = await _service.CreateProposalAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Operations.Should().HaveCount(2); + result.Value.Operations[0].Sequence.Should().Be(0); + result.Value.Operations[1].Sequence.Should().Be(1); + } + + [Fact] + public async Task CreateProposalAsync_ShouldReturnValidationError_WhenSummaryIsEmpty() + { + // Arrange + var dto = new CreateProposalDto( + ProposalSourceType.Queue, + Guid.NewGuid(), + "", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + // Act + var result = await _service.CreateProposalAsync(dto); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region GetProposalByIdAsync Tests + + [Fact] + public async Task GetProposalByIdAsync_ShouldReturnProposal_WhenExists() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.GetProposalByIdAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(proposal.Id); + result.Value.Summary.Should().Be("Test proposal"); + } + + [Fact] + public async Task GetProposalByIdAsync_ShouldReturnNotFound_WhenDoesNotExist() + { + // Arrange + var proposalId = Guid.NewGuid(); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync((AutomationProposal?)null); + + // Act + var result = await _service.GetProposalByIdAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + #endregion + + #region ApproveProposalAsync Tests + + [Fact] + public async Task ApproveProposalAsync_ShouldReturnSuccess_WhenPending() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.ApproveProposalAsync(proposalId, deciderId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Approved); + result.Value.DecidedByUserId.Should().Be(deciderId); + result.Value.DecidedAt.Should().NotBeNull(); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task ApproveProposalAsync_ShouldReturnInvalidOperation_WhenAlreadyApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.Approve(deciderId); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.ApproveProposalAsync(proposalId, Guid.NewGuid()); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region RejectProposalAsync Tests + + [Fact] + public async Task RejectProposalAsync_ShouldReturnSuccess_WhenPending() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.RejectProposalAsync( + proposalId, + deciderId, + new UpdateProposalStatusDto("Not needed")); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Rejected); + result.Value.DecidedByUserId.Should().Be(deciderId); + result.Value.FailureReason.Should().Be("Not needed"); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task RejectProposalAsync_ShouldRequireReason_ForHighRisk() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.High, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.RejectProposalAsync( + proposalId, + deciderId, + new UpdateProposalStatusDto()); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region MarkAsAppliedAsync Tests + + [Fact] + public async Task MarkAsAppliedAsync_ShouldReturnSuccess_WhenApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.Approve(Guid.NewGuid()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.MarkAsAppliedAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Applied); + result.Value.AppliedAt.Should().NotBeNull(); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task MarkAsAppliedAsync_ShouldReturnInvalidOperation_WhenNotApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.MarkAsAppliedAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + } + + #endregion + + #region MarkAsFailedAsync Tests + + [Fact] + public async Task MarkAsFailedAsync_ShouldReturnSuccess_WhenApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.Approve(Guid.NewGuid()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.MarkAsFailedAsync(proposalId, "Database error"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Failed); + result.Value.FailureReason.Should().Be("Database error"); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + #endregion + + #region ExpireProposalsAsync Tests + + [Fact] + public async Task ExpireProposalsAsync_ShouldExpireAllStaleProposals() + { + // Arrange + var proposal1 = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test 1", + RiskLevel.Low, + Guid.NewGuid().ToString(), + expiryMinutes: 1); + + var proposal2 = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test 2", + RiskLevel.Low, + Guid.NewGuid().ToString(), + expiryMinutes: 1); + + // Simulate that these are expired (repository would return expired ones) + _proposalRepoMock.Setup(r => r.GetExpiredAsync(default)) + .ReturnsAsync(new[] { proposal1, proposal2 }); + + // Act + var result = await _service.ExpireProposalsAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(2); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task ExpireProposalsAsync_ShouldReturnZero_WhenNoExpiredProposals() + { + // Arrange + _proposalRepoMock.Setup(r => r.GetExpiredAsync(default)) + .ReturnsAsync(Array.Empty()); + + // Act + var result = await _service.ExpireProposalsAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(0); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region GetProposalDiffAsync Tests + + [Fact] + public async Task GetProposalDiffAsync_ShouldReturnDiff_WhenAvailable() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.SetDiffPreview("+ New card created\n- Old card removed"); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.GetProposalDiffAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain("New card created"); + } + + [Fact] + public async Task GetProposalDiffAsync_ShouldReturnNotFound_WhenDiffNotAvailable() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.GetProposalDiffAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + #endregion + + #region GetProposalsAsync Tests + + [Fact] + public async Task GetProposalsAsync_ShouldFilterByStatus_WhenStatusProvided() + { + // Arrange + var proposals = new[] + { + new AutomationProposal(ProposalSourceType.Chat, Guid.NewGuid(), "Test 1", RiskLevel.Low, Guid.NewGuid().ToString()), + new AutomationProposal(ProposalSourceType.Chat, Guid.NewGuid(), "Test 2", RiskLevel.Low, Guid.NewGuid().ToString()) + }; + + _proposalRepoMock.Setup(r => r.GetByStatusAsync(ProposalStatus.PendingReview, 100, default)) + .ReturnsAsync(proposals); + + // Act + var result = await _service.GetProposalsAsync(new ProposalFilterDto(Status: ProposalStatus.PendingReview)); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + } + + [Fact] + public async Task GetProposalsAsync_ShouldFilterByBoardId_WhenProvided() + { + // Arrange + var boardId = Guid.NewGuid(); + var proposals = new[] + { + new AutomationProposal(ProposalSourceType.Chat, Guid.NewGuid(), "Test", RiskLevel.Low, Guid.NewGuid().ToString(), boardId) + }; + + _proposalRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, 100, default)) + .ReturnsAsync(proposals); + + // Act + var result = await _service.GetProposalsAsync(new ProposalFilterDto(BoardId: boardId)); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + } + + #endregion +}