From c6760d7170117dbdfa89d4e22412d3f9d14398d2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:42:39 +0000
Subject: [PATCH 01/30] Initial plan
From 9b2a7efd1ce613baec73c34b3e6818b8bfb981ea Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:47:43 +0000
Subject: [PATCH 02/30] Add domain entities for archive, chat, and ops CLI
features
Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com>
---
.../Interfaces/IUserContext.cs | 25 +++
.../Taskdeck.Domain/Entities/ArchiveItem.cs | 106 +++++++++++
.../Entities/AutomationProposal.cs | 176 ++++++++++++++++++
.../Entities/AutomationProposalOperation.cs | 54 ++++++
.../Taskdeck.Domain/Entities/ChatMessage.cs | 71 +++++++
.../Taskdeck.Domain/Entities/ChatSession.cs | 79 ++++++++
.../Taskdeck.Domain/Entities/CommandRun.cs | 127 +++++++++++++
.../Taskdeck.Domain/Entities/CommandRunLog.cs | 45 +++++
.../Exceptions/DomainException.cs | 1 +
.../Identity/UserContext.cs | 36 ++++
10 files changed, 720 insertions(+)
create mode 100644 backend/src/Taskdeck.Application/Interfaces/IUserContext.cs
create mode 100644 backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs
create mode 100644 backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs
create mode 100644 backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs
create mode 100644 backend/src/Taskdeck.Domain/Entities/ChatMessage.cs
create mode 100644 backend/src/Taskdeck.Domain/Entities/ChatSession.cs
create mode 100644 backend/src/Taskdeck.Domain/Entities/CommandRun.cs
create mode 100644 backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs
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.Domain/Entities/ArchiveItem.cs b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs
new file mode 100644
index 000000000..2b729010c
--- /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 string EntityId { get; private set; }
+ public string BoardId { get; private set; }
+ public string Name { get; private set; }
+ public string 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 string? RestoredByUserId { get; private set; }
+
+ private ArchiveItem() { } // EF Core
+
+ public ArchiveItem(
+ string entityType,
+ string entityId,
+ string boardId,
+ string name,
+ string 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 (string.IsNullOrWhiteSpace(entityId))
+ throw new DomainException(ErrorCodes.ValidationError, "EntityId cannot be empty");
+ if (string.IsNullOrWhiteSpace(boardId))
+ 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 (string.IsNullOrWhiteSpace(archivedByUserId))
+ 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(string restoredByUserId)
+ {
+ if (string.IsNullOrWhiteSpace(restoredByUserId))
+ 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..dff1527c5
--- /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 string? BoardId { get; private set; }
+ public string 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 string? 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,
+ string requestedByUserId,
+ string summary,
+ RiskLevel riskLevel,
+ string correlationId,
+ string? boardId = null,
+ string? sourceReferenceId = null,
+ int expiryMinutes = 1440)
+ {
+ if (string.IsNullOrWhiteSpace(requestedByUserId))
+ 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(string decidedByUserId)
+ {
+ if (string.IsNullOrWhiteSpace(decidedByUserId))
+ 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(string decidedByUserId, string? reason = null)
+ {
+ if (string.IsNullOrWhiteSpace(decidedByUserId))
+ 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..7c93e00cd
--- /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 string 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(
+ string proposalId,
+ int sequence,
+ string actionType,
+ string targetType,
+ string parameters,
+ string idempotencyKey,
+ string? targetId = null,
+ string? expectedVersion = null)
+ {
+ if (string.IsNullOrWhiteSpace(proposalId))
+ 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..1eff2b92f
--- /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 string SessionId { get; private set; }
+ public ChatMessageRole Role { get; private set; }
+ public string Content { get; private set; }
+ public string MessageType { get; private set; }
+ public string? ProposalId { get; private set; }
+ public int? TokenUsage { get; private set; }
+
+ // Navigation
+ public ChatSession Session { get; private set; } = null!;
+
+ private ChatMessage() { } // EF Core
+
+ public ChatMessage(
+ string sessionId,
+ ChatMessageRole role,
+ string content,
+ string messageType = "text",
+ string? proposalId = null,
+ int? tokenUsage = null)
+ {
+ if (string.IsNullOrWhiteSpace(sessionId))
+ 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(string proposalId)
+ {
+ if (string.IsNullOrWhiteSpace(proposalId))
+ 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..d5ef5b1eb
--- /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 string UserId { get; private set; }
+ public string? 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(
+ string userId,
+ string title,
+ string? boardId = null)
+ {
+ if (string.IsNullOrWhiteSpace(userId))
+ 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..17d4d1940
--- /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 string 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,
+ string requestedByUserId,
+ string correlationId)
+ {
+ if (string.IsNullOrWhiteSpace(templateName))
+ throw new DomainException(ErrorCodes.ValidationError, "TemplateName cannot be empty");
+ if (string.IsNullOrWhiteSpace(requestedByUserId))
+ 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..33263d2bd
--- /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 string 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(
+ string commandRunId,
+ string level,
+ string source,
+ string message,
+ string? metadata = null)
+ {
+ if (string.IsNullOrWhiteSpace(commandRunId))
+ 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/Identity/UserContext.cs b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs
new file mode 100644
index 000000000..3d9843e1e
--- /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;
+}
From aabd795dde6b84d2c81c356232f47e7799067cca Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:52:31 +0000
Subject: [PATCH 03/30] Add repository interfaces for new domain entities
Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com>
---
.../Interfaces/IArchiveItemRepository.cs | 12 ++++++++++++
.../Interfaces/IAutomationProposalRepository.cs | 14 ++++++++++++++
.../Interfaces/IChatMessageRepository.cs | 9 +++++++++
.../Interfaces/IChatSessionRepository.cs | 11 +++++++++++
.../Interfaces/ICommandRunRepository.cs | 12 ++++++++++++
.../Interfaces/IUnitOfWork.cs | 5 +++++
.../Repositories/UnitOfWork.cs | 17 ++++++++++++++++-
7 files changed, 79 insertions(+), 1 deletion(-)
create mode 100644 backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs
create mode 100644 backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs
create mode 100644 backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs
create mode 100644 backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs
create mode 100644 backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs
diff --git a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs
new file mode 100644
index 000000000..6aad8930f
--- /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(string 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, string entityId, CancellationToken cancellationToken = default);
+ Task> GetByUserIdAsync(string 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..ff5f12809
--- /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(string boardId, CancellationToken cancellationToken = default);
+ Task> GetByUserIdAsync(string 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..fda001431
--- /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(string sessionId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByProposalIdAsync(string 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..4202dd237
--- /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(string userId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByBoardIdAsync(string 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..4f0f8213e
--- /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(string 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.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)
{
From db4358765c5e553498966d480b2daf7e15f5cec1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:56:48 +0000
Subject: [PATCH 04/30] Implement repository classes for new entities
Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com>
---
.../DependencyInjection.cs | 5 ++
.../Repositories/ArchiveItemRepository.cs | 55 +++++++++++++++
.../AutomationProposalRepository.cs | 68 +++++++++++++++++++
.../Repositories/ChatMessageRepository.cs | 30 ++++++++
.../Repositories/ChatSessionRepository.cs | 47 +++++++++++++
.../Repositories/CommandRunRepository.cs | 53 +++++++++++++++
6 files changed, 258 insertions(+)
create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs
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/Repositories/ArchiveItemRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs
new file mode 100644
index 000000000..edb11cba2
--- /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(string 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.Status == status)
+ .OrderByDescending(a => a.ArchivedAt)
+ .Take(limit)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task GetByEntityAsync(string entityType, string entityId, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .FirstOrDefaultAsync(a => a.EntityType == entityType && a.EntityId == entityId, cancellationToken);
+ }
+
+ public async Task> GetByUserIdAsync(string 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..f7f3d5e6c
--- /dev/null
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs
@@ -0,0 +1,68 @@
+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 async Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .Where(p => p.Status == status)
+ .OrderByDescending(p => p.CreatedAt)
+ .Take(limit)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetByBoardIdAsync(string boardId, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .Where(p => p.BoardId == boardId)
+ .OrderByDescending(p => p.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .Where(p => p.RequestedByUserId == userId)
+ .OrderByDescending(p => p.CreatedAt)
+ .Take(limit)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .Where(p => p.RiskLevel == riskLevel)
+ .OrderByDescending(p => p.CreatedAt)
+ .Take(limit)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .FirstOrDefaultAsync(p => p.SourceType == sourceType && p.SourceReferenceId == referenceId, cancellationToken);
+ }
+
+ public async Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .FirstOrDefaultAsync(p => p.CorrelationId == correlationId, cancellationToken);
+ }
+
+ public async Task> GetExpiredAsync(CancellationToken cancellationToken = default)
+ {
+ var now = DateTime.UtcNow;
+ return await _dbSet
+ .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..35c18d829
--- /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(string 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(string 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..9b5ed2fd5
--- /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(string 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(string 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..a201b4139
--- /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(string userId, int limit = 100, CancellationToken cancellationToken = default)
+ {
+ return await _dbSet
+ .Where(c => c.UserId == 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);
+ }
+}
From 5514aa1ce98982a04301ebdf957e3906439256a4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:59:58 +0000
Subject: [PATCH 05/30] Add EF Core entity configurations for new entities and
fix repository bugs
Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com>
---
.../ArchiveItemConfiguration.cs | 68 +++++++++++++++
.../AutomationProposalConfiguration.cs | 82 +++++++++++++++++++
...utomationProposalOperationConfiguration.cs | 57 +++++++++++++
.../ChatMessageConfiguration.cs | 48 +++++++++++
.../ChatSessionConfiguration.cs | 49 +++++++++++
.../Configurations/CommandRunConfiguration.cs | 65 +++++++++++++++
.../CommandRunLogConfiguration.cs | 48 +++++++++++
.../Persistence/TaskdeckDbContext.cs | 7 ++
.../Repositories/ArchiveItemRepository.cs | 2 +-
.../Repositories/CommandRunRepository.cs | 2 +-
.../Taskdeck.Infrastructure.csproj | 1 +
11 files changed, 427 insertions(+), 2 deletions(-)
create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs
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
index edb11cba2..3d564b772 100644
--- a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs
@@ -32,7 +32,7 @@ public async Task> GetByEntityTypeAsync(string entityTy
public async Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
- .Where(a => a.Status == status)
+ .Where(a => a.RestoreStatus == status)
.OrderByDescending(a => a.ArchivedAt)
.Take(limit)
.ToListAsync(cancellationToken);
diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs
index a201b4139..2c9a17d12 100644
--- a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs
@@ -14,7 +14,7 @@ public CommandRunRepository(TaskdeckDbContext context) : base(context)
public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
- .Where(c => c.UserId == userId)
+ .Where(c => c.RequestedByUserId == userId)
.OrderByDescending(c => c.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken);
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 @@
+
From 73c12024995ca9e862d8cdc7c62d7a5fbbd186f1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 20:00:56 +0000
Subject: [PATCH 06/30] Fix operator precedence bug in UserContext
authentication check
Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com>
---
backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs
index 3d9843e1e..d3b5760ac 100644
--- a/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs
+++ b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs
@@ -21,7 +21,7 @@ public string? UserId
get
{
var user = _httpContextAccessor.HttpContext?.User;
- if (user == null || !user.Identity?.IsAuthenticated == true)
+ if (user == null || user.Identity?.IsAuthenticated != true)
return null;
// Try both 'sub' (standard JWT claim) and 'nameid' (alternative)
From 6db8a8803e02effc38cf904551cd5071349c63c2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 20:06:27 +0000
Subject: [PATCH 07/30] Change ID fields from string to Guid in new domain
entities
Updated all new domain entities to use Guid for ID fields to match Entity base class:
- AutomationProposal: BoardId, RequestedByUserId, DecidedByUserId
- AutomationProposalOperation: ProposalId
- ArchiveItem: EntityId, BoardId, ArchivedByUserId, RestoredByUserId
- ChatSession: UserId, BoardId
- ChatMessage: SessionId, ProposalId
- CommandRun: RequestedByUserId
- CommandRunLog: CommandRunId
Also updated repository interfaces and implementations to use Guid parameters.
CorrelationId remains string as it's not an entity reference.
Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com>
---
.../Interfaces/IArchiveItemRepository.cs | 6 ++---
.../IAutomationProposalRepository.cs | 4 ++--
.../Interfaces/IChatMessageRepository.cs | 4 ++--
.../Interfaces/IChatSessionRepository.cs | 4 ++--
.../Interfaces/ICommandRunRepository.cs | 2 +-
.../Taskdeck.Domain/Entities/ArchiveItem.cs | 24 +++++++++----------
.../Entities/AutomationProposal.cs | 20 ++++++++--------
.../Entities/AutomationProposalOperation.cs | 6 ++---
.../Taskdeck.Domain/Entities/ChatMessage.cs | 14 +++++------
.../Taskdeck.Domain/Entities/ChatSession.cs | 10 ++++----
.../Taskdeck.Domain/Entities/CommandRun.cs | 6 ++---
.../Taskdeck.Domain/Entities/CommandRunLog.cs | 6 ++---
.../Repositories/ArchiveItemRepository.cs | 6 ++---
.../AutomationProposalRepository.cs | 4 ++--
.../Repositories/ChatMessageRepository.cs | 4 ++--
.../Repositories/ChatSessionRepository.cs | 4 ++--
.../Repositories/CommandRunRepository.cs | 2 +-
17 files changed, 63 insertions(+), 63 deletions(-)
diff --git a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs
index 6aad8930f..83109cac8 100644
--- a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs
+++ b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs
@@ -4,9 +4,9 @@ namespace Taskdeck.Application.Interfaces;
public interface IArchiveItemRepository : IRepository
{
- Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default);
+ 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, string entityId, CancellationToken cancellationToken = default);
- Task> GetByUserIdAsync(string userId, 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
index ff5f12809..2acc3b480 100644
--- a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs
+++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs
@@ -5,8 +5,8 @@ namespace Taskdeck.Application.Interfaces;
public interface IAutomationProposalRepository : IRepository
{
Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default);
- Task> GetByBoardIdAsync(string boardId, CancellationToken cancellationToken = default);
- Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByBoardIdAsync(Guid boardId, 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);
diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs
index fda001431..66154c103 100644
--- a/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs
+++ b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs
@@ -4,6 +4,6 @@ namespace Taskdeck.Application.Interfaces;
public interface IChatMessageRepository : IRepository
{
- Task> GetBySessionIdAsync(string sessionId, int limit = 100, CancellationToken cancellationToken = default);
- Task> GetByProposalIdAsync(string proposalId, CancellationToken cancellationToken = default);
+ 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
index 4202dd237..e490cb499 100644
--- a/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs
+++ b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs
@@ -4,8 +4,8 @@ namespace Taskdeck.Application.Interfaces;
public interface IChatSessionRepository : IRepository
{
- Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default);
- Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default);
+ 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
index 4f0f8213e..a6785ced4 100644
--- a/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs
+++ b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs
@@ -4,7 +4,7 @@ namespace Taskdeck.Application.Interfaces;
public interface ICommandRunRepository : IRepository
{
- Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default);
+ 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);
diff --git a/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs
index 2b729010c..85dfa947c 100644
--- a/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs
+++ b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs
@@ -6,25 +6,25 @@ namespace Taskdeck.Domain.Entities;
public class ArchiveItem : Entity
{
public string EntityType { get; private set; }
- public string EntityId { get; private set; }
- public string BoardId { get; private set; }
+ public Guid EntityId { get; private set; }
+ public Guid BoardId { get; private set; }
public string Name { get; private set; }
- public string ArchivedByUserId { 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 string? RestoredByUserId { get; private set; }
+ public Guid? RestoredByUserId { get; private set; }
private ArchiveItem() { } // EF Core
public ArchiveItem(
string entityType,
- string entityId,
- string boardId,
+ Guid entityId,
+ Guid boardId,
string name,
- string archivedByUserId,
+ Guid archivedByUserId,
string snapshotJson,
string? reason = null)
{
@@ -32,15 +32,15 @@ public ArchiveItem(
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 (string.IsNullOrWhiteSpace(entityId))
+ if (entityId == Guid.Empty)
throw new DomainException(ErrorCodes.ValidationError, "EntityId cannot be empty");
- if (string.IsNullOrWhiteSpace(boardId))
+ 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 (string.IsNullOrWhiteSpace(archivedByUserId))
+ 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");
@@ -56,9 +56,9 @@ public ArchiveItem(
RestoreStatus = RestoreStatus.Available;
}
- public void MarkAsRestored(string restoredByUserId)
+ public void MarkAsRestored(Guid restoredByUserId)
{
- if (string.IsNullOrWhiteSpace(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}");
diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs
index dff1527c5..301a6e58d 100644
--- a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs
+++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs
@@ -7,8 +7,8 @@ public class AutomationProposal : Entity
{
public ProposalSourceType SourceType { get; private set; }
public string? SourceReferenceId { get; private set; }
- public string? BoardId { get; private set; }
- public string RequestedByUserId { 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; }
@@ -16,7 +16,7 @@ public class AutomationProposal : Entity
public string? ValidationIssues { get; private set; }
public DateTime ExpiresAt { get; private set; }
public DateTime? DecidedAt { get; private set; }
- public string? DecidedByUserId { 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; }
@@ -28,15 +28,15 @@ private AutomationProposal() { } // EF Core
public AutomationProposal(
ProposalSourceType sourceType,
- string requestedByUserId,
+ Guid requestedByUserId,
string summary,
RiskLevel riskLevel,
string correlationId,
- string? boardId = null,
+ Guid? boardId = null,
string? sourceReferenceId = null,
int expiryMinutes = 1440)
{
- if (string.IsNullOrWhiteSpace(requestedByUserId))
+ 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");
@@ -67,9 +67,9 @@ public void AddOperation(AutomationProposalOperation operation)
Touch();
}
- public void Approve(string decidedByUserId)
+ public void Approve(Guid decidedByUserId)
{
- if (string.IsNullOrWhiteSpace(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}");
@@ -82,9 +82,9 @@ public void Approve(string decidedByUserId)
Touch();
}
- public void Reject(string decidedByUserId, string? reason = null)
+ public void Reject(Guid decidedByUserId, string? reason = null)
{
- if (string.IsNullOrWhiteSpace(decidedByUserId))
+ 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}");
diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs
index 7c93e00cd..871b01f66 100644
--- a/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs
+++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs
@@ -5,7 +5,7 @@ namespace Taskdeck.Domain.Entities;
public class AutomationProposalOperation : Entity
{
- public string ProposalId { get; private set; }
+ public Guid ProposalId { get; private set; }
public int Sequence { get; private set; }
public string ActionType { get; private set; }
public string TargetType { get; private set; }
@@ -20,7 +20,7 @@ public class AutomationProposalOperation : Entity
private AutomationProposalOperation() { } // EF Core
public AutomationProposalOperation(
- string proposalId,
+ Guid proposalId,
int sequence,
string actionType,
string targetType,
@@ -29,7 +29,7 @@ public AutomationProposalOperation(
string? targetId = null,
string? expectedVersion = null)
{
- if (string.IsNullOrWhiteSpace(proposalId))
+ 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");
diff --git a/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs
index 1eff2b92f..bac4f0079 100644
--- a/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs
+++ b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs
@@ -5,11 +5,11 @@ namespace Taskdeck.Domain.Entities;
public class ChatMessage : Entity
{
- public string SessionId { get; private set; }
+ 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 string? ProposalId { get; private set; }
+ public Guid? ProposalId { get; private set; }
public int? TokenUsage { get; private set; }
// Navigation
@@ -18,14 +18,14 @@ public class ChatMessage : Entity
private ChatMessage() { } // EF Core
public ChatMessage(
- string sessionId,
+ Guid sessionId,
ChatMessageRole role,
string content,
string messageType = "text",
- string? proposalId = null,
+ Guid? proposalId = null,
int? tokenUsage = null)
{
- if (string.IsNullOrWhiteSpace(sessionId))
+ 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");
@@ -53,9 +53,9 @@ public void SetTokenUsage(int tokenUsage)
Touch();
}
- public void SetProposalId(string proposalId)
+ public void SetProposalId(Guid proposalId)
{
- if (string.IsNullOrWhiteSpace(proposalId))
+ if (proposalId == Guid.Empty)
throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty");
ProposalId = proposalId;
diff --git a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs
index d5ef5b1eb..b3737343f 100644
--- a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs
+++ b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs
@@ -5,8 +5,8 @@ namespace Taskdeck.Domain.Entities;
public class ChatSession : Entity
{
- public string UserId { get; private set; }
- public string? BoardId { get; private set; }
+ public Guid UserId { get; private set; }
+ public Guid? BoardId { get; private set; }
public string Title { get; private set; }
public ChatSessionStatus Status { get; private set; }
@@ -16,11 +16,11 @@ public class ChatSession : Entity
private ChatSession() { } // EF Core
public ChatSession(
- string userId,
+ Guid userId,
string title,
- string? boardId = null)
+ Guid? boardId = null)
{
- if (string.IsNullOrWhiteSpace(userId))
+ 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");
diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRun.cs b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs
index 17d4d1940..a0420da74 100644
--- a/backend/src/Taskdeck.Domain/Entities/CommandRun.cs
+++ b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs
@@ -6,7 +6,7 @@ namespace Taskdeck.Domain.Entities;
public class CommandRun : Entity
{
public string TemplateName { get; private set; }
- public string RequestedByUserId { 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; }
@@ -23,12 +23,12 @@ private CommandRun() { } // EF Core
public CommandRun(
string templateName,
- string requestedByUserId,
+ Guid requestedByUserId,
string correlationId)
{
if (string.IsNullOrWhiteSpace(templateName))
throw new DomainException(ErrorCodes.ValidationError, "TemplateName cannot be empty");
- if (string.IsNullOrWhiteSpace(requestedByUserId))
+ 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");
diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs
index 33263d2bd..664cbe2f2 100644
--- a/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs
+++ b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs
@@ -5,7 +5,7 @@ namespace Taskdeck.Domain.Entities;
public class CommandRunLog : Entity
{
- public string CommandRunId { get; private set; }
+ public Guid CommandRunId { get; private set; }
public DateTime Timestamp { get; private set; }
public string Level { get; private set; }
public string Source { get; private set; }
@@ -18,13 +18,13 @@ public class CommandRunLog : Entity
private CommandRunLog() { } // EF Core
public CommandRunLog(
- string commandRunId,
+ Guid commandRunId,
string level,
string source,
string message,
string? metadata = null)
{
- if (string.IsNullOrWhiteSpace(commandRunId))
+ 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");
diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs
index 3d564b772..49d5f5545 100644
--- a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs
@@ -11,7 +11,7 @@ public ArchiveItemRepository(TaskdeckDbContext context) : base(context)
{
}
- public async Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default)
+ public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(a => a.BoardId == boardId)
@@ -38,13 +38,13 @@ public async Task> GetByStatusAsync(RestoreStatus statu
.ToListAsync(cancellationToken);
}
- public async Task GetByEntityAsync(string entityType, string entityId, CancellationToken cancellationToken = default)
+ 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(string userId, int limit = 100, CancellationToken cancellationToken = default)
+ public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(a => a.ArchivedByUserId == userId)
diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs
index f7f3d5e6c..86a7af600 100644
--- a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs
@@ -20,7 +20,7 @@ public async Task> GetByStatusAsync(ProposalStat
.ToListAsync(cancellationToken);
}
- public async Task> GetByBoardIdAsync(string boardId, CancellationToken cancellationToken = default)
+ public async Task> GetByBoardIdAsync(Guid boardId, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(p => p.BoardId == boardId)
@@ -28,7 +28,7 @@ public async Task> GetByBoardIdAsync(string boar
.ToListAsync(cancellationToken);
}
- public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default)
+ public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(p => p.RequestedByUserId == userId)
diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs
index 35c18d829..d8545318f 100644
--- a/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs
@@ -11,7 +11,7 @@ public ChatMessageRepository(TaskdeckDbContext context) : base(context)
{
}
- public async Task> GetBySessionIdAsync(string sessionId, int limit = 100, CancellationToken cancellationToken = default)
+ public async Task> GetBySessionIdAsync(Guid sessionId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(m => m.SessionId == sessionId)
@@ -20,7 +20,7 @@ public async Task> GetBySessionIdAsync(string sessionId
.ToListAsync(cancellationToken);
}
- public async Task> GetByProposalIdAsync(string proposalId, CancellationToken cancellationToken = default)
+ public async Task> GetByProposalIdAsync(Guid proposalId, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(m => m.ProposalId == proposalId)
diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs
index 9b5ed2fd5..daa586393 100644
--- a/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs
@@ -11,7 +11,7 @@ public ChatSessionRepository(TaskdeckDbContext context) : base(context)
{
}
- public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default)
+ public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(s => s.UserId == userId)
@@ -20,7 +20,7 @@ public async Task> GetByUserIdAsync(string userId, int
.ToListAsync(cancellationToken);
}
- public async Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default)
+ public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(s => s.BoardId == boardId)
diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs
index 2c9a17d12..2cf8c8b9d 100644
--- a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs
+++ b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs
@@ -11,7 +11,7 @@ public CommandRunRepository(TaskdeckDbContext context) : base(context)
{
}
- public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default)
+ public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(c => c.RequestedByUserId == userId)
From afdcd3e346adfefeaba270e44afd8cbc8425ce8f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 20:07:17 +0000
Subject: [PATCH 08/30] Add database migration for automation, archive, chat,
and ops entities
Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com>
---
...tomationArchiveChatOpsEntities.Designer.cs | 951 ++++++++++++++++++
...649_AddAutomationArchiveChatOpsEntities.cs | 345 +++++++
.../TaskdeckDbContextModelSnapshot.cs | 446 ++++++++
3 files changed, 1742 insertions(+)
create mode 100644 backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs
create mode 100644 backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs
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