Skip to content

Commit 7085e7c

Browse files
authored
Merge pull request #1034 from Chris0Jeky/paper/1023-history
Add card history ledger for proposal review (#1023)
2 parents caa9f2b + ad5ea25 commit 7085e7c

7 files changed

Lines changed: 903 additions & 0 deletions

File tree

backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,23 @@ public class AutomationProposalsController : AuthenticatedControllerBase
2828
private readonly IAutomationExecutorService _executorService;
2929
private readonly ISimilarDecisionService _similarDecisionService;
3030
private readonly BoardAuthorizationService _authorizationService;
31+
private readonly ICardHistoryService _cardHistoryService;
3132
private readonly ISideEffectAnalyzer _sideEffectAnalyzer;
3233

3334
public AutomationProposalsController(
3435
IAutomationProposalService proposalService,
3536
IAutomationExecutorService executorService,
3637
ISimilarDecisionService similarDecisionService,
3738
BoardAuthorizationService authorizationService,
39+
ICardHistoryService cardHistoryService,
3840
ISideEffectAnalyzer sideEffectAnalyzer,
3941
IUserContext userContext) : base(userContext)
4042
{
4143
_proposalService = proposalService;
4244
_executorService = executorService;
4345
_similarDecisionService = similarDecisionService;
4446
_authorizationService = authorizationService;
47+
_cardHistoryService = cardHistoryService;
4548
_sideEffectAnalyzer = sideEffectAnalyzer;
4649
}
4750

@@ -267,6 +270,23 @@ public async Task<IActionResult> DismissProposals(
267270
: result.ToErrorActionResult();
268271
}
269272

273+
/// <summary>
274+
/// Gets the card history ledger for a proposal, showing all touches on affected cards.
275+
/// </summary>
276+
[HttpGet("{id}/history")]
277+
public async Task<IActionResult> GetProposalHistory(Guid id, CancellationToken cancellationToken = default)
278+
{
279+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
280+
return errorResult!;
281+
282+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
283+
if (auth.ErrorResult is not null)
284+
return auth.ErrorResult;
285+
286+
var result = await _cardHistoryService.GetCardHistoryForProposalAsync(id, cancellationToken);
287+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
288+
}
289+
270290
/// <summary>
271291
/// Gets the side-effect analysis for a proposal, including the 7-category breakdown
272292
/// and reversibility posture.

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
4747
services.AddScoped<HistoryService>();
4848
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
4949
services.AddScoped<IAutomationProposalService, AutomationProposalService>();
50+
services.AddScoped<ICardHistoryService, CardHistoryService>();
5051
services.AddScoped<ISideEffectAnalyzer, SideEffectAnalyzer>();
5152
services.AddScoped<IProposalRevisionService, ProposalRevisionService>();
5253
services.AddScoped<ISimilarDecisionService, SimilarDecisionService>();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Taskdeck.Domain.Enums;
2+
3+
namespace Taskdeck.Application.DTOs;
4+
5+
/// <summary>
6+
/// DTO representing a single row in the card history ledger for the proposal review History section.
7+
/// </summary>
8+
public record CardHistoryRowDto(
9+
string Serial,
10+
string Event,
11+
string Age,
12+
CardHistoryStatus Status);
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
using System.Globalization;
2+
using System.Text.Json;
3+
using Taskdeck.Application.DTOs;
4+
using Taskdeck.Application.Interfaces;
5+
using Taskdeck.Domain.Common;
6+
using Taskdeck.Domain.Entities;
7+
using Taskdeck.Domain.Enums;
8+
using Taskdeck.Domain.Exceptions;
9+
10+
namespace Taskdeck.Application.Services;
11+
12+
/// <summary>
13+
/// Builds a card history ledger for the proposal review History section.
14+
/// Collects audit log entries and proposal operations for all cards affected
15+
/// by a given proposal, then formats them as numbered history rows.
16+
/// </summary>
17+
public class CardHistoryService : ICardHistoryService
18+
{
19+
private const int MaxAuditEntriesPerCard = 200;
20+
private const int MaxTotalHistoryRows = 500;
21+
22+
private readonly IUnitOfWork _unitOfWork;
23+
24+
public CardHistoryService(IUnitOfWork unitOfWork)
25+
{
26+
_unitOfWork = unitOfWork;
27+
}
28+
29+
public async Task<Result<IReadOnlyList<CardHistoryRowDto>>> GetCardHistoryForProposalAsync(
30+
Guid proposalId,
31+
CancellationToken cancellationToken = default)
32+
{
33+
if (proposalId == Guid.Empty)
34+
return Result.Failure<IReadOnlyList<CardHistoryRowDto>>(
35+
ErrorCodes.ValidationError, "Proposal ID cannot be empty");
36+
37+
var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken);
38+
if (proposal == null)
39+
return Result.Failure<IReadOnlyList<CardHistoryRowDto>>(
40+
ErrorCodes.NotFound, $"Proposal with ID {proposalId} not found");
41+
42+
// Extract distinct card IDs from the proposal's operations.
43+
// Operations target cards when TargetType is "Card" and TargetId is a valid GUID.
44+
// Single-pass: TryParse both validates and produces the Guid, avoiding double parsing.
45+
var affectedCardIds = new List<Guid>();
46+
var seenCardIds = new HashSet<Guid>();
47+
foreach (var op in proposal.Operations)
48+
{
49+
if (string.Equals(op.TargetType, "Card", StringComparison.OrdinalIgnoreCase)
50+
&& !string.IsNullOrWhiteSpace(op.TargetId)
51+
&& Guid.TryParse(op.TargetId, out var cardGuid)
52+
&& seenCardIds.Add(cardGuid))
53+
{
54+
affectedCardIds.Add(cardGuid);
55+
}
56+
}
57+
58+
if (affectedCardIds.Count == 0)
59+
{
60+
// No card targets found -- return only the current proposal's operations as pending rows.
61+
var pendingOnlyRows = BuildPendingOperationRows(proposal, DateTimeOffset.UtcNow);
62+
return Result.Success<IReadOnlyList<CardHistoryRowDto>>(pendingOnlyRows);
63+
}
64+
65+
var now = DateTimeOffset.UtcNow;
66+
67+
// Collect audit log entries for all affected cards (bounded per card).
68+
// NOTE: This issues one query per card (N+1). Acceptable because proposals
69+
// typically affect 1-5 cards. If this becomes a hot path with larger proposals,
70+
// consider adding a batch GetByEntitiesAsync method to IAuditLogRepository.
71+
var allEntries = new List<HistoryEntry>();
72+
73+
foreach (var cardId in affectedCardIds)
74+
{
75+
var auditLogs = await _unitOfWork.AuditLogs.GetByEntityAsync(
76+
"Card", cardId, MaxAuditEntriesPerCard, cancellationToken);
77+
78+
foreach (var log in auditLogs)
79+
{
80+
allEntries.Add(new HistoryEntry(
81+
log.Timestamp,
82+
FormatAuditEvent(log),
83+
CardHistoryStatus.Past));
84+
}
85+
}
86+
87+
// Find proposals that targeted the same cards (applied proposals get 'applied' status).
88+
// Track seen proposal IDs to avoid duplicates when multiple cards share the same related proposal.
89+
var seenProposalIds = new HashSet<Guid> { proposalId };
90+
foreach (var cardId in affectedCardIds)
91+
{
92+
var relatedProposal = await _unitOfWork.AutomationProposals
93+
.GetLatestByOperationTargetAsync("card", cardId.ToString(), cancellationToken);
94+
95+
if (relatedProposal != null && seenProposalIds.Add(relatedProposal.Id))
96+
{
97+
var status = relatedProposal.Status == ProposalStatus.Applied
98+
? CardHistoryStatus.Applied
99+
: CardHistoryStatus.Past;
100+
101+
allEntries.Add(new HistoryEntry(
102+
relatedProposal.UpdatedAt,
103+
$"Proposal \"{relatedProposal.Summary}\" {FormatProposalStatus(relatedProposal.Status)}",
104+
status));
105+
}
106+
}
107+
108+
// Add current proposal's operations as 'pending' entries.
109+
foreach (var op in proposal.Operations)
110+
{
111+
allEntries.Add(new HistoryEntry(
112+
proposal.CreatedAt,
113+
FormatOperationEvent(op),
114+
CardHistoryStatus.Pending));
115+
}
116+
117+
// Sort by timestamp descending (newest first), cap total rows, then assign serial numbers.
118+
var sorted = allEntries
119+
.OrderByDescending(e => e.Timestamp)
120+
.Take(MaxTotalHistoryRows)
121+
.ToList();
122+
123+
var rows = new List<CardHistoryRowDto>(sorted.Count);
124+
for (var i = 0; i < sorted.Count; i++)
125+
{
126+
var entry = sorted[i];
127+
rows.Add(new CardHistoryRowDto(
128+
FormatSerial(i + 1),
129+
entry.Event,
130+
FormatAge(entry.Timestamp, now),
131+
entry.Status));
132+
}
133+
134+
return Result.Success<IReadOnlyList<CardHistoryRowDto>>(rows);
135+
}
136+
137+
private List<CardHistoryRowDto> BuildPendingOperationRows(
138+
AutomationProposal proposal, DateTimeOffset now)
139+
{
140+
var rows = new List<CardHistoryRowDto>(proposal.Operations.Count);
141+
for (var i = 0; i < proposal.Operations.Count; i++)
142+
{
143+
var op = proposal.Operations[i];
144+
rows.Add(new CardHistoryRowDto(
145+
FormatSerial(i + 1),
146+
FormatOperationEvent(op),
147+
FormatAge(proposal.CreatedAt, now),
148+
CardHistoryStatus.Pending));
149+
}
150+
return rows;
151+
}
152+
153+
/// <summary>
154+
/// Formats a 1-based index as '#001', '#002', etc.
155+
/// </summary>
156+
internal static string FormatSerial(int index)
157+
{
158+
return $"#{index:D3}";
159+
}
160+
161+
/// <summary>
162+
/// Formats a timestamp as a relative age string:
163+
/// - Same day (UTC): just time "11:42"
164+
/// - Yesterday (UTC): "yest 16:04"
165+
/// - Same week (within 6 days, UTC): "Mon 11:00"
166+
/// - Older: "Apr 15"
167+
/// All formatting uses UTC to avoid timezone ambiguity.
168+
/// </summary>
169+
internal static string FormatAge(DateTimeOffset timestamp, DateTimeOffset now)
170+
{
171+
var utcTimestamp = timestamp.UtcDateTime;
172+
var utcNow = now.UtcDateTime;
173+
174+
// Compare dates in UTC
175+
var timestampDate = utcTimestamp.Date;
176+
var nowDate = utcNow.Date;
177+
178+
if (timestampDate == nowDate)
179+
{
180+
// Same day: just time
181+
return utcTimestamp.ToString("H:mm", CultureInfo.InvariantCulture);
182+
}
183+
184+
if (timestampDate == nowDate.AddDays(-1))
185+
{
186+
// Yesterday
187+
return $"yest {utcTimestamp.ToString("H:mm", CultureInfo.InvariantCulture)}";
188+
}
189+
190+
var daysDiff = (nowDate - timestampDate).Days;
191+
if (daysDiff >= 2 && daysDiff <= 6)
192+
{
193+
// This week (2-6 days ago): abbreviated day name + time
194+
return $"{utcTimestamp.ToString("ddd", CultureInfo.InvariantCulture)} {utcTimestamp.ToString("H:mm", CultureInfo.InvariantCulture)}";
195+
}
196+
197+
// Older: abbreviated month + day
198+
return $"{utcTimestamp.ToString("MMM", CultureInfo.InvariantCulture)} {utcTimestamp.ToString("dd", CultureInfo.InvariantCulture)}";
199+
}
200+
201+
private static string FormatAuditEvent(AuditLog log)
202+
{
203+
var entityType = log.EntityType;
204+
return log.Action switch
205+
{
206+
AuditAction.Created => $"{entityType} created",
207+
AuditAction.Updated => FormatUpdateEvent(entityType, log.Changes),
208+
AuditAction.Deleted => $"{entityType} deleted",
209+
AuditAction.Archived => $"{entityType} archived",
210+
AuditAction.Unarchived => $"{entityType} restored from archive",
211+
AuditAction.Moved => $"{entityType} moved",
212+
AuditAction.PermissionGranted => $"{entityType} permission granted",
213+
AuditAction.PermissionRevoked => $"{entityType} permission revoked",
214+
AuditAction.OwnershipTransferred => $"{entityType} ownership transferred",
215+
_ => $"{entityType} {log.Action.ToString().ToLowerInvariant()}"
216+
};
217+
}
218+
219+
private static string FormatUpdateEvent(string entityType, string? changes)
220+
{
221+
if (string.IsNullOrWhiteSpace(changes))
222+
return $"{entityType} updated";
223+
224+
// Parse the changes JSON to check for property names properly,
225+
// avoiding false positives from string.Contains on raw JSON values.
226+
try
227+
{
228+
using var doc = JsonDocument.Parse(changes);
229+
var root = doc.RootElement;
230+
if (root.ValueKind != JsonValueKind.Object)
231+
return $"{entityType} updated";
232+
233+
if (root.TryGetProperty("title", out _))
234+
return $"{entityType} title updated";
235+
if (root.TryGetProperty("columnId", out _) || root.TryGetProperty("column", out _))
236+
return $"{entityType} moved to new column";
237+
if (root.TryGetProperty("description", out _))
238+
return $"{entityType} description updated";
239+
if (root.TryGetProperty("position", out _))
240+
return $"{entityType} position changed";
241+
if (root.TryGetProperty("label", out _) || root.TryGetProperty("labels", out _))
242+
return $"{entityType} labels updated";
243+
}
244+
catch (JsonException)
245+
{
246+
// If changes is not valid JSON, fall through to generic message.
247+
}
248+
249+
return $"{entityType} updated";
250+
}
251+
252+
private static string FormatOperationEvent(AutomationProposalOperation op)
253+
{
254+
var action = op.ActionType.ToLowerInvariant();
255+
var target = op.TargetType;
256+
257+
return action switch
258+
{
259+
"create" => $"Create {target}",
260+
"move" => $"Move {target}",
261+
"update" => $"Update {target}",
262+
"archive" => $"Archive {target}",
263+
"delete" => $"Delete {target}",
264+
"bulkmove" or "bulk_move" => $"Bulk move {target}",
265+
"createcolumn" or "create_column" => $"Create column",
266+
_ => $"{op.ActionType} {target}"
267+
};
268+
}
269+
270+
private static string FormatProposalStatus(ProposalStatus status)
271+
{
272+
return status switch
273+
{
274+
ProposalStatus.PendingReview => "pending review",
275+
ProposalStatus.Approved => "approved",
276+
ProposalStatus.Rejected => "rejected",
277+
ProposalStatus.Applied => "applied",
278+
ProposalStatus.Failed => "failed",
279+
ProposalStatus.Expired => "expired",
280+
ProposalStatus.Dismissed => "dismissed",
281+
_ => status.ToString().ToLowerInvariant()
282+
};
283+
}
284+
285+
private sealed record HistoryEntry(DateTimeOffset Timestamp, string Event, CardHistoryStatus Status);
286+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Services;
5+
6+
/// <summary>
7+
/// Service for retrieving the card history ledger for a proposal's affected cards.
8+
/// Used by the proposal review History section.
9+
/// </summary>
10+
public interface ICardHistoryService
11+
{
12+
/// <summary>
13+
/// Returns a history ledger of all touches on cards affected by the given proposal,
14+
/// ordered by timestamp descending (newest first).
15+
/// </summary>
16+
Task<Result<IReadOnlyList<CardHistoryRowDto>>> GetCardHistoryForProposalAsync(
17+
Guid proposalId,
18+
CancellationToken cancellationToken = default);
19+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace Taskdeck.Domain.Enums;
2+
3+
/// <summary>
4+
/// Status classification for a card history row in the proposal review ledger.
5+
/// </summary>
6+
public enum CardHistoryStatus
7+
{
8+
/// <summary>
9+
/// This row represents an operation from the current proposal being reviewed (not yet applied).
10+
/// </summary>
11+
Pending = 0,
12+
13+
/// <summary>
14+
/// This row represents a previously applied proposal operation.
15+
/// </summary>
16+
Applied = 1,
17+
18+
/// <summary>
19+
/// This row represents other historical activity (audit log entries, non-proposal changes).
20+
/// </summary>
21+
Past = 2
22+
}

0 commit comments

Comments
 (0)