Skip to content

Commit 2214271

Browse files
authored
Merge pull request #787 from Chris0Jeky/feature/analytics-export-reports-78
Add exportable analytics reports (CSV) with reproducible filters
2 parents d8c09df + 1617980 commit 2214271

9 files changed

Lines changed: 702 additions & 1 deletion

File tree

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@ namespace Taskdeck.Api.Controllers;
1818
public class MetricsController : AuthenticatedControllerBase
1919
{
2020
private readonly IBoardMetricsService _metricsService;
21+
private readonly IMetricsExportService _exportService;
2122

22-
public MetricsController(IBoardMetricsService metricsService, IUserContext userContext)
23+
public MetricsController(
24+
IBoardMetricsService metricsService,
25+
IMetricsExportService exportService,
26+
IUserContext userContext)
2327
: base(userContext)
2428
{
2529
_metricsService = metricsService;
30+
_exportService = exportService;
2631
}
2732

2833
/// <summary>
@@ -61,4 +66,48 @@ public async Task<IActionResult> GetBoardMetrics(
6166
var result = await _metricsService.GetBoardMetricsAsync(query, userId);
6267
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
6368
}
69+
70+
/// <summary>
71+
/// Export board metrics as a downloadable CSV file.
72+
/// </summary>
73+
/// <param name="boardId">The board to export metrics for.</param>
74+
/// <param name="from">Start of date range (ISO 8601).</param>
75+
/// <param name="to">End of date range (ISO 8601).</param>
76+
/// <param name="labelId">Optional label filter.</param>
77+
/// <param name="cancellationToken">Cancellation token.</param>
78+
/// <returns>CSV file download.</returns>
79+
/// <response code="200">CSV file returned.</response>
80+
/// <response code="400">Invalid query parameters.</response>
81+
/// <response code="401">Authentication required.</response>
82+
/// <response code="403">No read access to the board.</response>
83+
/// <response code="404">Board not found.</response>
84+
[HttpGet("boards/{boardId}/export")]
85+
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
86+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
87+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
88+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
89+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
90+
public async Task<IActionResult> ExportBoardMetrics(
91+
Guid boardId,
92+
[FromQuery] DateTimeOffset? from,
93+
[FromQuery] DateTimeOffset? to,
94+
[FromQuery] Guid? labelId,
95+
CancellationToken cancellationToken)
96+
{
97+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
98+
return errorResult!;
99+
100+
// Default to last 30 days if not specified
101+
var toDate = to ?? DateTimeOffset.UtcNow;
102+
var fromDate = from ?? toDate.AddDays(-30);
103+
104+
var query = new BoardMetricsQuery(boardId, fromDate, toDate, labelId);
105+
var result = await _exportService.ExportCsvAsync(query, userId, cancellationToken);
106+
107+
if (!result.IsSuccess)
108+
return result.ToErrorActionResult();
109+
110+
var export = result.Value;
111+
return File(export.Content, export.ContentType, export.FileName);
112+
}
64113
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
5959
new BoardMetricsService(
6060
sp.GetRequiredService<IUnitOfWork>(),
6161
sp.GetRequiredService<IAuthorizationService>()));
62+
services.AddScoped<IMetricsExportService>(sp =>
63+
new MetricsExportService(
64+
sp.GetRequiredService<IBoardMetricsService>()));
6265
services.AddScoped<AgentProfileService>();
6366
services.AddScoped<AgentRunService>();
6467
services.AddScoped<SignalRBoardRealtimeNotifier>();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Services;
5+
6+
/// <summary>
7+
/// Exports board metrics data as CSV with schema-versioned columns.
8+
/// </summary>
9+
public interface IMetricsExportService
10+
{
11+
/// <summary>
12+
/// Generate a CSV export of board metrics for the given query.
13+
/// The acting user must have read access to the board.
14+
/// </summary>
15+
Task<Result<MetricsExportResult>> ExportCsvAsync(
16+
BoardMetricsQuery query,
17+
Guid actingUserId,
18+
CancellationToken cancellationToken = default);
19+
}
20+
21+
/// <summary>
22+
/// Result of a metrics export operation.
23+
/// </summary>
24+
/// <param name="Content">The raw CSV content bytes.</param>
25+
/// <param name="FileName">Suggested file name for the download.</param>
26+
/// <param name="ContentType">MIME type of the export.</param>
27+
public sealed record MetricsExportResult(
28+
byte[] Content,
29+
string FileName,
30+
string ContentType);
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using Taskdeck.Application.DTOs;
4+
using Taskdeck.Domain.Common;
5+
6+
namespace Taskdeck.Application.Services;
7+
8+
/// <summary>
9+
/// Produces CSV exports of board metrics with schema-versioned columns
10+
/// and CSV-injection-safe cell values.
11+
/// </summary>
12+
public class MetricsExportService : IMetricsExportService
13+
{
14+
/// <summary>
15+
/// Schema version embedded as the first comment line in every export.
16+
/// Bump when column layout changes.
17+
/// </summary>
18+
internal const string SchemaVersion = "1.0";
19+
20+
private readonly IBoardMetricsService _metricsService;
21+
22+
public MetricsExportService(IBoardMetricsService metricsService)
23+
{
24+
_metricsService = metricsService;
25+
}
26+
27+
public async Task<Result<MetricsExportResult>> ExportCsvAsync(
28+
BoardMetricsQuery query,
29+
Guid actingUserId,
30+
CancellationToken cancellationToken = default)
31+
{
32+
var metricsResult = await _metricsService.GetBoardMetricsAsync(query, actingUserId, cancellationToken);
33+
if (!metricsResult.IsSuccess)
34+
return Result.Failure<MetricsExportResult>(metricsResult.ErrorCode, metricsResult.ErrorMessage);
35+
36+
var metrics = metricsResult.Value;
37+
var csv = BuildCsv(metrics);
38+
39+
var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
40+
var fileName = $"board-metrics-{metrics.BoardId:N}-{timestamp}.csv";
41+
42+
var bom = Encoding.UTF8.GetPreamble();
43+
var csvBytes = Encoding.UTF8.GetBytes(csv);
44+
var content = new byte[bom.Length + csvBytes.Length];
45+
bom.CopyTo(content, 0);
46+
csvBytes.CopyTo(content, bom.Length);
47+
48+
return Result.Success(new MetricsExportResult(content, fileName, "text/csv"));
49+
}
50+
51+
internal static string BuildCsv(BoardMetricsResponse metrics)
52+
{
53+
var sb = new StringBuilder();
54+
55+
// Schema version header (comment line)
56+
sb.AppendLine($"# schema_version={SchemaVersion}");
57+
sb.AppendLine($"# board_id={metrics.BoardId}");
58+
sb.AppendLine($"# from={metrics.From:o}");
59+
sb.AppendLine($"# to={metrics.To:o}");
60+
sb.AppendLine($"# exported_at={DateTimeOffset.UtcNow:o}");
61+
sb.AppendLine();
62+
63+
// Section: Summary
64+
sb.AppendLine("[Summary]");
65+
sb.AppendLine("Metric,Value");
66+
sb.AppendLine($"AverageCycleTimeDays,{metrics.AverageCycleTimeDays.ToString(CultureInfo.InvariantCulture)}");
67+
sb.AppendLine($"TotalWip,{metrics.TotalWip}");
68+
sb.AppendLine($"BlockedCount,{metrics.BlockedCount}");
69+
sb.AppendLine($"TotalThroughput,{metrics.Throughput.Sum(t => t.CompletedCount)}");
70+
sb.AppendLine();
71+
72+
// Section: Throughput
73+
sb.AppendLine("[Throughput]");
74+
sb.AppendLine("Date,CompletedCount");
75+
foreach (var dp in metrics.Throughput)
76+
{
77+
sb.AppendLine($"{dp.Date:yyyy-MM-dd},{dp.CompletedCount}");
78+
}
79+
sb.AppendLine();
80+
81+
// Section: CycleTime
82+
sb.AppendLine("[CycleTime]");
83+
sb.AppendLine("CardId,CardTitle,CycleTimeDays");
84+
foreach (var entry in metrics.CycleTimeEntries)
85+
{
86+
sb.AppendLine($"{entry.CardId},{SanitizeCsvField(entry.CardTitle)},{entry.CycleTimeDays.ToString(CultureInfo.InvariantCulture)}");
87+
}
88+
sb.AppendLine();
89+
90+
// Section: WIP
91+
sb.AppendLine("[WIP]");
92+
sb.AppendLine("ColumnId,ColumnName,CardCount,WipLimit");
93+
foreach (var wip in metrics.WipSnapshots)
94+
{
95+
sb.AppendLine($"{wip.ColumnId},{SanitizeCsvField(wip.ColumnName)},{wip.CardCount},{wip.WipLimit?.ToString(CultureInfo.InvariantCulture) ?? ""}");
96+
}
97+
sb.AppendLine();
98+
99+
// Section: Blocked
100+
sb.AppendLine("[Blocked]");
101+
sb.AppendLine("CardId,CardTitle,BlockReason,BlockedDurationDays");
102+
foreach (var blocked in metrics.BlockedCards)
103+
{
104+
sb.AppendLine($"{blocked.CardId},{SanitizeCsvField(blocked.CardTitle)},{SanitizeCsvField(blocked.BlockReason ?? "")},{blocked.BlockedDurationDays.ToString(CultureInfo.InvariantCulture)}");
105+
}
106+
107+
return sb.ToString();
108+
}
109+
110+
/// <summary>
111+
/// Sanitize a field for safe CSV inclusion.
112+
/// - Strips CSV injection characters (=, +, -, @, tab, carriage return) from the start
113+
/// of the value AND from the start of each embedded line (after \n or \r\n).
114+
/// - Quotes the field if it contains commas, quotes, or newlines.
115+
/// - Doubles internal quote characters.
116+
/// </summary>
117+
internal static string SanitizeCsvField(string value)
118+
{
119+
if (string.IsNullOrEmpty(value))
120+
return value;
121+
122+
// Strip leading characters that could trigger formula injection in spreadsheet apps.
123+
// Apply to each line within the value to prevent injection via embedded newlines
124+
// (e.g. "hello\n=CMD|'/C calc'!A0" must sanitize the second line too).
125+
var sanitized = StripDangerousLeadingChars(value);
126+
sanitized = SanitizeEmbeddedLines(sanitized);
127+
128+
// If the field contains special CSV characters, quote it
129+
var needsQuoting = sanitized.Contains(',') ||
130+
sanitized.Contains('"') ||
131+
sanitized.Contains('\n') ||
132+
sanitized.Contains('\r');
133+
134+
if (needsQuoting)
135+
{
136+
sanitized = "\"" + sanitized.Replace("\"", "\"\"") + "\"";
137+
}
138+
139+
return sanitized;
140+
}
141+
142+
private static string StripDangerousLeadingChars(string s)
143+
{
144+
var i = 0;
145+
while (i < s.Length && IsDangerousLeadingChar(s[i]))
146+
i++;
147+
return i == 0 ? s : s[i..];
148+
}
149+
150+
/// <summary>
151+
/// For each line after the first within a multi-line value, strip leading
152+
/// dangerous characters so that embedded newlines cannot smuggle formula prefixes.
153+
/// </summary>
154+
private static string SanitizeEmbeddedLines(string value)
155+
{
156+
if (!value.Contains('\n') && !value.Contains('\r'))
157+
return value;
158+
159+
var lines = value.Split('\n');
160+
for (var i = 1; i < lines.Length; i++)
161+
{
162+
var line = lines[i];
163+
// Handle \r\n: the \r will be at the end of the previous line's split,
164+
// but also strip dangerous chars that appear after a bare \n.
165+
lines[i] = StripDangerousLeadingChars(line);
166+
}
167+
168+
return string.Join('\n', lines);
169+
}
170+
171+
private static bool IsDangerousLeadingChar(char c)
172+
=> c is '=' or '+' or '-' or '@' or '\t' or '\r';
173+
}

0 commit comments

Comments
 (0)