|
| 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 | +} |
0 commit comments