-
Notifications
You must be signed in to change notification settings - Fork 0
Add exportable analytics reports (CSV) with reproducible filters #787
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f8d5a94
04ce52b
c715036
99fcc0a
decf648
c9b44f0
88cf0f8
1617980
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,11 +18,16 @@ namespace Taskdeck.Api.Controllers; | |
| public class MetricsController : AuthenticatedControllerBase | ||
| { | ||
| private readonly IBoardMetricsService _metricsService; | ||
| private readonly IMetricsExportService _exportService; | ||
|
|
||
| public MetricsController(IBoardMetricsService metricsService, IUserContext userContext) | ||
| public MetricsController( | ||
| IBoardMetricsService metricsService, | ||
| IMetricsExportService exportService, | ||
| IUserContext userContext) | ||
| : base(userContext) | ||
| { | ||
| _metricsService = metricsService; | ||
| _exportService = exportService; | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
@@ -61,4 +66,48 @@ public async Task<IActionResult> GetBoardMetrics( | |
| var result = await _metricsService.GetBoardMetricsAsync(query, userId); | ||
| return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Export board metrics as a downloadable CSV file. | ||
| /// </summary> | ||
| /// <param name="boardId">The board to export metrics for.</param> | ||
| /// <param name="from">Start of date range (ISO 8601).</param> | ||
| /// <param name="to">End of date range (ISO 8601).</param> | ||
| /// <param name="labelId">Optional label filter.</param> | ||
| /// <param name="cancellationToken">Cancellation token.</param> | ||
| /// <returns>CSV file download.</returns> | ||
| /// <response code="200">CSV file returned.</response> | ||
| /// <response code="400">Invalid query parameters.</response> | ||
| /// <response code="401">Authentication required.</response> | ||
| /// <response code="403">No read access to the board.</response> | ||
| /// <response code="404">Board not found.</response> | ||
| [HttpGet("boards/{boardId}/export")] | ||
| [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] | ||
| [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] | ||
| [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] | ||
| [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] | ||
| [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] | ||
| public async Task<IActionResult> ExportBoardMetrics( | ||
| Guid boardId, | ||
| [FromQuery] DateTimeOffset? from, | ||
| [FromQuery] DateTimeOffset? to, | ||
| [FromQuery] Guid? labelId, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| if (!TryGetCurrentUserId(out var userId, out var errorResult)) | ||
| return errorResult!; | ||
|
|
||
| // Default to last 30 days if not specified | ||
| var toDate = to ?? DateTimeOffset.UtcNow; | ||
| var fromDate = from ?? toDate.AddDays(-30); | ||
|
Comment on lines
+101
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| var query = new BoardMetricsQuery(boardId, fromDate, toDate, labelId); | ||
| var result = await _exportService.ExportCsvAsync(query, userId, cancellationToken); | ||
|
|
||
| if (!result.IsSuccess) | ||
| return result.ToErrorActionResult(); | ||
|
|
||
| var export = result.Value; | ||
| return File(export.Content, export.ContentType, export.FileName); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| using Taskdeck.Application.DTOs; | ||
| using Taskdeck.Domain.Common; | ||
|
|
||
| namespace Taskdeck.Application.Services; | ||
|
|
||
| /// <summary> | ||
| /// Exports board metrics data as CSV with schema-versioned columns. | ||
| /// </summary> | ||
| public interface IMetricsExportService | ||
| { | ||
| /// <summary> | ||
| /// Generate a CSV export of board metrics for the given query. | ||
| /// The acting user must have read access to the board. | ||
| /// </summary> | ||
| Task<Result<MetricsExportResult>> ExportCsvAsync( | ||
| BoardMetricsQuery query, | ||
| Guid actingUserId, | ||
| CancellationToken cancellationToken = default); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Result of a metrics export operation. | ||
| /// </summary> | ||
| /// <param name="Content">The raw CSV content bytes.</param> | ||
| /// <param name="FileName">Suggested file name for the download.</param> | ||
| /// <param name="ContentType">MIME type of the export.</param> | ||
| public sealed record MetricsExportResult( | ||
| byte[] Content, | ||
| string FileName, | ||
| string ContentType); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| using System.Globalization; | ||
| using System.Text; | ||
| using Taskdeck.Application.DTOs; | ||
| using Taskdeck.Domain.Common; | ||
|
|
||
| namespace Taskdeck.Application.Services; | ||
|
|
||
| /// <summary> | ||
| /// Produces CSV exports of board metrics with schema-versioned columns | ||
| /// and CSV-injection-safe cell values. | ||
| /// </summary> | ||
| public class MetricsExportService : IMetricsExportService | ||
| { | ||
| /// <summary> | ||
| /// Schema version embedded as the first comment line in every export. | ||
| /// Bump when column layout changes. | ||
| /// </summary> | ||
| internal const string SchemaVersion = "1.0"; | ||
|
|
||
| private readonly IBoardMetricsService _metricsService; | ||
|
|
||
| public MetricsExportService(IBoardMetricsService metricsService) | ||
| { | ||
| _metricsService = metricsService; | ||
| } | ||
|
|
||
| public async Task<Result<MetricsExportResult>> ExportCsvAsync( | ||
| BoardMetricsQuery query, | ||
| Guid actingUserId, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| var metricsResult = await _metricsService.GetBoardMetricsAsync(query, actingUserId, cancellationToken); | ||
| if (!metricsResult.IsSuccess) | ||
| return Result.Failure<MetricsExportResult>(metricsResult.ErrorCode, metricsResult.ErrorMessage); | ||
|
|
||
| var metrics = metricsResult.Value; | ||
| var csv = BuildCsv(metrics); | ||
|
|
||
| var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); | ||
|
Comment on lines
+37
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass the current timestamp to var now = DateTimeOffset.UtcNow;
var csv = BuildCsv(metrics, now);
var timestamp = now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); |
||
| var fileName = $"board-metrics-{metrics.BoardId:N}-{timestamp}.csv"; | ||
|
|
||
| var bom = Encoding.UTF8.GetPreamble(); | ||
| var csvBytes = Encoding.UTF8.GetBytes(csv); | ||
| var content = new byte[bom.Length + csvBytes.Length]; | ||
| bom.CopyTo(content, 0); | ||
| csvBytes.CopyTo(content, bom.Length); | ||
|
|
||
| return Result.Success(new MetricsExportResult(content, fileName, "text/csv")); | ||
| } | ||
|
|
||
| internal static string BuildCsv(BoardMetricsResponse metrics) | ||
| { | ||
| var sb = new StringBuilder(); | ||
|
|
||
| // Schema version header (comment line) | ||
| sb.AppendLine($"# schema_version={SchemaVersion}"); | ||
| sb.AppendLine($"# board_id={metrics.BoardId}"); | ||
| sb.AppendLine($"# from={metrics.From:o}"); | ||
| sb.AppendLine($"# to={metrics.To:o}"); | ||
| sb.AppendLine($"# exported_at={DateTimeOffset.UtcNow:o}"); | ||
|
Comment on lines
+51
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider passing the export timestamp as a parameter to internal static string BuildCsv(BoardMetricsResponse metrics, DateTimeOffset exportedAt)
{
var sb = new StringBuilder();
// Schema version header (comment line)
sb.AppendLine($"# schema_version={SchemaVersion}");
sb.AppendLine($"# board_id={metrics.BoardId}");
sb.AppendLine($"# from={metrics.From:o}");
sb.AppendLine($"# to={metrics.To:o}");
sb.AppendLine($"# exported_at={exportedAt:o}"); |
||
| sb.AppendLine(); | ||
|
Comment on lines
+55
to
+61
|
||
|
|
||
| // Section: Summary | ||
| sb.AppendLine("[Summary]"); | ||
| sb.AppendLine("Metric,Value"); | ||
| sb.AppendLine($"AverageCycleTimeDays,{metrics.AverageCycleTimeDays.ToString(CultureInfo.InvariantCulture)}"); | ||
| sb.AppendLine($"TotalWip,{metrics.TotalWip}"); | ||
| sb.AppendLine($"BlockedCount,{metrics.BlockedCount}"); | ||
| sb.AppendLine($"TotalThroughput,{metrics.Throughput.Sum(t => t.CompletedCount)}"); | ||
| sb.AppendLine(); | ||
|
|
||
| // Section: Throughput | ||
| sb.AppendLine("[Throughput]"); | ||
| sb.AppendLine("Date,CompletedCount"); | ||
| foreach (var dp in metrics.Throughput) | ||
| { | ||
| sb.AppendLine($"{dp.Date:yyyy-MM-dd},{dp.CompletedCount}"); | ||
| } | ||
| sb.AppendLine(); | ||
|
|
||
| // Section: CycleTime | ||
| sb.AppendLine("[CycleTime]"); | ||
| sb.AppendLine("CardId,CardTitle,CycleTimeDays"); | ||
| foreach (var entry in metrics.CycleTimeEntries) | ||
| { | ||
| sb.AppendLine($"{entry.CardId},{SanitizeCsvField(entry.CardTitle)},{entry.CycleTimeDays.ToString(CultureInfo.InvariantCulture)}"); | ||
| } | ||
| sb.AppendLine(); | ||
|
|
||
| // Section: WIP | ||
| sb.AppendLine("[WIP]"); | ||
| sb.AppendLine("ColumnId,ColumnName,CardCount,WipLimit"); | ||
| foreach (var wip in metrics.WipSnapshots) | ||
| { | ||
| sb.AppendLine($"{wip.ColumnId},{SanitizeCsvField(wip.ColumnName)},{wip.CardCount},{wip.WipLimit?.ToString(CultureInfo.InvariantCulture) ?? ""}"); | ||
| } | ||
| sb.AppendLine(); | ||
|
|
||
| // Section: Blocked | ||
| sb.AppendLine("[Blocked]"); | ||
| sb.AppendLine("CardId,CardTitle,BlockReason,BlockedDurationDays"); | ||
| foreach (var blocked in metrics.BlockedCards) | ||
| { | ||
| sb.AppendLine($"{blocked.CardId},{SanitizeCsvField(blocked.CardTitle)},{SanitizeCsvField(blocked.BlockReason ?? "")},{blocked.BlockedDurationDays.ToString(CultureInfo.InvariantCulture)}"); | ||
| } | ||
|
|
||
| return sb.ToString(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Sanitize a field for safe CSV inclusion. | ||
| /// - Strips CSV injection characters (=, +, -, @, tab, carriage return) from the start | ||
| /// of the value AND from the start of each embedded line (after \n or \r\n). | ||
| /// - Quotes the field if it contains commas, quotes, or newlines. | ||
| /// - Doubles internal quote characters. | ||
| /// </summary> | ||
| internal static string SanitizeCsvField(string value) | ||
| { | ||
| if (string.IsNullOrEmpty(value)) | ||
| return value; | ||
|
|
||
| // Strip leading characters that could trigger formula injection in spreadsheet apps. | ||
| // Apply to each line within the value to prevent injection via embedded newlines | ||
| // (e.g. "hello\n=CMD|'/C calc'!A0" must sanitize the second line too). | ||
| var sanitized = StripDangerousLeadingChars(value); | ||
| sanitized = SanitizeEmbeddedLines(sanitized); | ||
|
|
||
| // If the field contains special CSV characters, quote it | ||
| var needsQuoting = sanitized.Contains(',') || | ||
| sanitized.Contains('"') || | ||
| sanitized.Contains('\n') || | ||
| sanitized.Contains('\r'); | ||
|
|
||
| if (needsQuoting) | ||
| { | ||
| sanitized = "\"" + sanitized.Replace("\"", "\"\"") + "\""; | ||
| } | ||
|
|
||
| return sanitized; | ||
| } | ||
|
Comment on lines
+117
to
+140
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of CSV injection protection is destructive because it strips leading characters like To prevent formula injection without losing data, it is recommended to prefix the field with a single quote ( Note: The prefix should be added before the quoting logic to ensure it is correctly handled by CSV parsers. internal static string SanitizeCsvField(string value)
{
if (string.IsNullOrEmpty(value))
return value;
var sanitized = value;
// Prefix with a single quote if it starts with a dangerous character to prevent formula injection
if (IsDangerousLeadingChar(sanitized[0]))
{
sanitized = "'" + sanitized;
}
// If the field contains special CSV characters, quote it
var needsQuoting = sanitized.Contains(',') ||
sanitized.Contains('"') ||
sanitized.Contains('\n') ||
sanitized.Contains('\r');
if (needsQuoting)
{
sanitized = "\"" + sanitized.Replace("\"", "\"\"") + "\"";
}
return sanitized;
} |
||
|
|
||
| private static string StripDangerousLeadingChars(string s) | ||
| { | ||
| var i = 0; | ||
| while (i < s.Length && IsDangerousLeadingChar(s[i])) | ||
| i++; | ||
| return i == 0 ? s : s[i..]; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// For each line after the first within a multi-line value, strip leading | ||
| /// dangerous characters so that embedded newlines cannot smuggle formula prefixes. | ||
| /// </summary> | ||
| private static string SanitizeEmbeddedLines(string value) | ||
| { | ||
| if (!value.Contains('\n') && !value.Contains('\r')) | ||
| return value; | ||
|
|
||
| var lines = value.Split('\n'); | ||
| for (var i = 1; i < lines.Length; i++) | ||
| { | ||
| var line = lines[i]; | ||
| // Handle \r\n: the \r will be at the end of the previous line's split, | ||
| // but also strip dangerous chars that appear after a bare \n. | ||
| lines[i] = StripDangerousLeadingChars(line); | ||
| } | ||
|
|
||
| return string.Join('\n', lines); | ||
| } | ||
|
|
||
| private static bool IsDangerousLeadingChar(char c) | ||
| => c is '=' or '+' or '-' or '@' or '\t' or '\r'; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This controller has
[Produces("application/json")]at the class level, butExportBoardMetricsreturns a CSVFile(...)response. As-is, OpenAPI/Swagger may advertise the 200 response asapplication/jsoninstead oftext/csv.Consider overriding on this action (e.g., add
[Produces("text/csv")]/[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileContentResult), ContentTypes = new[] { "text/csv" })]) so clients and docs correctly reflect the content type.