Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ public class AutomationProposalsController : AuthenticatedControllerBase
private readonly IAutomationProposalService _proposalService;
private readonly IAutomationExecutorService _executorService;
private readonly BoardAuthorizationService _authorizationService;
private readonly ISideEffectAnalyzer _sideEffectAnalyzer;

public AutomationProposalsController(
IAutomationProposalService proposalService,
IAutomationExecutorService executorService,
BoardAuthorizationService authorizationService,
ISideEffectAnalyzer sideEffectAnalyzer,
IUserContext userContext) : base(userContext)
{
_proposalService = proposalService;
_executorService = executorService;
_authorizationService = authorizationService;
_sideEffectAnalyzer = sideEffectAnalyzer;
}

/// <summary>
Expand Down Expand Up @@ -261,6 +264,24 @@ public async Task<IActionResult> DismissProposals(
: result.ToErrorActionResult();
}

/// <summary>
/// Gets the side-effect analysis for a proposal, including the 7-category breakdown
/// and reversibility posture.
/// </summary>
[HttpGet("{id}/side-effects")]
public async Task<IActionResult> GetProposalSideEffects(Guid id, CancellationToken cancellationToken = default)
{
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
return errorResult!;

var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
if (auth.ErrorResult is not null)
return auth.ErrorResult;

var result = await _sideEffectAnalyzer.AnalyzeAsync(id, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

/// <summary>
/// Gets a diff preview for a proposal showing what changes will be made.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<HistoryService>();
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
services.AddScoped<IAutomationProposalService, AutomationProposalService>();
services.AddScoped<ISideEffectAnalyzer, SideEffectAnalyzer>();
services.AddScoped<IProposalRevisionService, ProposalRevisionService>();
services.AddScoped<IAutomationPolicyEngine, AutomationPolicyEngine>();
services.AddScoped<IAutomationPlannerService, AutomationPlannerService>();
Expand Down
18 changes: 18 additions & 0 deletions backend/src/Taskdeck.Application/DTOs/SideEffectDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Taskdeck.Application.DTOs;

public record SideEffectRowDto(
string Key,
string Value,
string Tone // "active" | "passive"
);

public record ReversibilityDto(
string Summary,
string Description,
long WindowMs
);

public record ProposalSideEffectsDto(
IReadOnlyList<SideEffectRowDto> Rows,
ReversibilityDto Reversibility
);
16 changes: 16 additions & 0 deletions backend/src/Taskdeck.Application/Services/ISideEffectAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Taskdeck.Application.DTOs;
using Taskdeck.Domain.Common;

namespace Taskdeck.Application.Services;

/// <summary>
/// Analyzes a proposal's operations to produce a 7-category side-effect breakdown
/// and a reversibility posture.
/// </summary>
public interface ISideEffectAnalyzer
{
/// <summary>
/// Analyzes the side effects of the specified proposal.
/// </summary>
Task<Result<ProposalSideEffectsDto>> AnalyzeAsync(Guid proposalId, CancellationToken cancellationToken = default);
}
178 changes: 178 additions & 0 deletions backend/src/Taskdeck.Application/Services/SideEffectAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Exceptions;

namespace Taskdeck.Application.Services;

/// <summary>
/// Analyzes a proposal's operations to produce a 7-category side-effect breakdown
/// (Cards, Subtasks, Comments, Activity log, Notifications, Webhooks, Calendar)
/// and a reversibility posture.
/// </summary>
public sealed class SideEffectAnalyzer : ISideEffectAnalyzer
{
// Action types that actively mutate cards
private static readonly HashSet<string> CardMutatingActions = new(StringComparer.OrdinalIgnoreCase)
{
"create", "move", "archive", "update", "delete", "bulk_move"
};

private readonly IUnitOfWork _unitOfWork;

public SideEffectAnalyzer(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}

public async Task<Result<ProposalSideEffectsDto>> AnalyzeAsync(
Guid proposalId,
CancellationToken cancellationToken = default)
{
var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken);
if (proposal is null)
return Result.Failure<ProposalSideEffectsDto>(ErrorCodes.NotFound, "Proposal not found.");

var operations = proposal.Operations;

// Determine webhook status for the board
bool hasActiveWebhooks = false;
if (proposal.BoardId.HasValue)
{
var webhookSubs = await _unitOfWork.OutboundWebhookSubscriptions
.GetActiveByBoardAsync(proposal.BoardId.Value, cancellationToken);
hasActiveWebhooks = webhookSubs.Count > 0;
}

var rows = BuildSideEffectRows(operations, hasActiveWebhooks);
var reversibility = ComputeReversibility(operations, proposal.RiskLevel);

var dto = new ProposalSideEffectsDto(
Rows: rows.Select(r => new SideEffectRowDto(r.Key, r.Value, r.Tone.ToString().ToLowerInvariant())).ToList(),
Reversibility: new ReversibilityDto(reversibility.Summary, reversibility.Description, reversibility.WindowMs));

return Result.Success(dto);
}

internal static IReadOnlyList<SideEffectRow> BuildSideEffectRows(
IReadOnlyList<AutomationProposalOperation> operations,
bool hasActiveWebhooks)
{
bool hasCardMutation = operations.Any(op =>
CardMutatingActions.Contains(op.ActionType) &&
string.Equals(op.TargetType, "card", StringComparison.OrdinalIgnoreCase));
bool hasColumnMutation = operations.Any(op =>
string.Equals(op.TargetType, "column", StringComparison.OrdinalIgnoreCase));
bool hasBoardMutation = hasCardMutation || hasColumnMutation;
bool hasAnyOperation = operations.Count > 0;

return new List<SideEffectRow>
{
new(
"Cards",
hasBoardMutation
? hasCardMutation && hasColumnMutation
? "Creates, moves, or archives cards and adds columns on the board"
: hasCardMutation
? "Creates, moves, or archives cards on the board"
: "Adds columns to the board (no direct card mutations)"
: "No board mutations",
hasBoardMutation ? SideEffectTone.Active : SideEffectTone.Passive),

new(
"Subtasks",
"Subtask management not yet supported",
SideEffectTone.Passive),

new(
"Comments",
"Proposals do not create comments",
SideEffectTone.Passive),

new(
"Activity log",
hasAnyOperation
? "Audit entries will be recorded for all applied operations"
: "No operations to log",
hasAnyOperation ? SideEffectTone.Active : SideEffectTone.Passive),

new(
"Notifications",
hasAnyOperation
? "Approval or rejection generates notifications"
: "No notifications generated",
hasAnyOperation ? SideEffectTone.Active : SideEffectTone.Passive),

new(
"Webhooks",
hasActiveWebhooks && hasAnyOperation
? "Outbound webhooks configured for this board will fire"
: hasActiveWebhooks
? "Outbound webhooks configured but no operations to trigger them"
: "No outbound webhooks configured",
hasActiveWebhooks && hasAnyOperation ? SideEffectTone.Active : SideEffectTone.Passive),

new(
"Calendar",
"Calendar integration not yet available",
SideEffectTone.Passive)
};
}

internal static Reversibility ComputeReversibility(
IReadOnlyList<AutomationProposalOperation> operations,
RiskLevel riskLevel)
{
// Base window is 6 hours
long windowMs = Reversibility.DefaultWindowMs;

// Adjust window based on risk level
string summary;
string description;

switch (riskLevel)
{
case RiskLevel.Critical:
windowMs = Reversibility.DefaultWindowMs / 2; // 3 hours -- tighter for critical
summary = "3 hours · manual intervention required";
description = "Critical-risk operations may require manual intervention to reverse. " +
"Archive and delete operations can be recovered within the window, " +
"but downstream effects (webhooks, notifications) cannot be recalled.";
break;

case RiskLevel.High:
windowMs = Reversibility.DefaultWindowMs; // 6 hours
summary = "6 hours · single keystroke";
description = "High-risk operations can be reversed within the window. " +
"Card moves and updates are fully reversible; " +
"archived cards can be restored from the archive.";
break;

case RiskLevel.Medium:
windowMs = Reversibility.DefaultWindowMs; // 6 hours
summary = "6 hours · single keystroke";
description = "Medium-risk operations are fully reversible within the window. " +
"All board mutations can be undone from the activity log.";
break;

case RiskLevel.Low:
default:
windowMs = Reversibility.DefaultWindowMs; // 6 hours
summary = "6 hours · single keystroke";
description = "Low-risk operations are fully reversible within the window. " +
"All board mutations can be undone from the activity log.";
break;
}

// If there are no operations, the proposal is a no-op
if (operations.Count == 0)
{
summary = "6 hours · no operations";
description = "This proposal contains no operations and will have no effect.";
windowMs = Reversibility.DefaultWindowMs;
}

return new Reversibility(summary, description, windowMs);
}
}
46 changes: 46 additions & 0 deletions backend/src/Taskdeck.Domain/Entities/ProposalSideEffects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace Taskdeck.Domain.Entities;

/// <summary>
/// Value object aggregating the full side-effect analysis for a proposal:
/// the 7-row breakdown and the reversibility posture.
/// </summary>
public sealed class ProposalSideEffects : IEquatable<ProposalSideEffects>
{
/// <summary>The 7-category side-effect breakdown.</summary>
public IReadOnlyList<SideEffectRow> Rows { get; }

/// <summary>The reversibility posture for this proposal.</summary>
public Reversibility Reversibility { get; }

public ProposalSideEffects(IReadOnlyList<SideEffectRow> rows, Reversibility reversibility)
{
if (rows is null || rows.Count == 0)
throw new ArgumentException("Side-effect rows cannot be null or empty.", nameof(rows));
Reversibility = reversibility ?? throw new ArgumentNullException(nameof(reversibility));

Rows = rows;
}

public bool Equals(ProposalSideEffects? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
if (Rows.Count != other.Rows.Count) return false;
for (int i = 0; i < Rows.Count; i++)
{
if (!Rows[i].Equals(other.Rows[i])) return false;
}
return Reversibility.Equals(other.Reversibility);
}

public override bool Equals(object? obj) => Equals(obj as ProposalSideEffects);

public override int GetHashCode()
{
var hash = new HashCode();
foreach (var row in Rows)
hash.Add(row);
hash.Add(Reversibility);
return hash.ToHashCode();
}
}
47 changes: 47 additions & 0 deletions backend/src/Taskdeck.Domain/Entities/Reversibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Taskdeck.Domain.Entities;

/// <summary>
/// Value object representing the reversibility window for a proposal.
/// Describes how long a user has to undo the effects and the effort required.
/// </summary>
public sealed class Reversibility : IEquatable<Reversibility>
{
/// <summary>Default reversibility window: 6 hours in milliseconds.</summary>
public const long DefaultWindowMs = 6L * 60 * 60 * 1000; // 21_600_000

/// <summary>Short summary (e.g. "6 hours - single keystroke").</summary>
public string Summary { get; }

/// <summary>Detailed description of the reversibility posture.</summary>
public string Description { get; }

/// <summary>Reversibility window in milliseconds.</summary>
public long WindowMs { get; }

public Reversibility(string summary, string description, long windowMs)
{
if (string.IsNullOrWhiteSpace(summary))
throw new ArgumentException("Reversibility summary cannot be empty.", nameof(summary));
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Reversibility description cannot be empty.", nameof(description));
if (windowMs <= 0)
throw new ArgumentOutOfRangeException(nameof(windowMs), "Reversibility window must be positive.");

Summary = summary;
Description = description;
WindowMs = windowMs;
}

public bool Equals(Reversibility? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return Summary == other.Summary && Description == other.Description && WindowMs == other.WindowMs;
}

public override bool Equals(object? obj) => Equals(obj as Reversibility);

public override int GetHashCode() => HashCode.Combine(Summary, Description, WindowMs);

public override string ToString() => $"{Summary} ({WindowMs}ms)";
}
Loading
Loading