Skip to content
51 changes: 50 additions & 1 deletion backend/src/Taskdeck.Api/Controllers/MetricsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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(
Comment on lines +84 to +90
Copy link

Copilot AI Apr 8, 2026

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, but ExportBoardMetrics returns a CSV File(...) response. As-is, OpenAPI/Swagger may advertise the 200 response as application/json instead of text/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.

Copilot uses AI. Check for mistakes.
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This date range defaulting logic is duplicated from the GetBoardMetrics method (lines 62-63). Consider refactoring this into a shared helper method or moving the default logic into the BoardMetricsQuery constructor to improve maintainability and ensure consistency across the API.


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
Expand Up @@ -59,6 +59,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
new BoardMetricsService(
sp.GetRequiredService<IUnitOfWork>(),
sp.GetRequiredService<IAuthorizationService>()));
services.AddScoped<IMetricsExportService>(sp =>
new MetricsExportService(
sp.GetRequiredService<IBoardMetricsService>()));
services.AddScoped<AgentProfileService>();
services.AddScoped<AgentRunService>();
services.AddScoped<SignalRBoardRealtimeNotifier>();
Expand Down
30 changes: 30 additions & 0 deletions backend/src/Taskdeck.Application/Services/IMetricsExportService.cs
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);
173 changes: 173 additions & 0 deletions backend/src/Taskdeck.Application/Services/MetricsExportService.cs
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the current timestamp to BuildCsv to ensure consistency between the filename and the header, and to improve testability by making the CSV generation deterministic.

        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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The BuildCsv method is currently non-deterministic because it uses DateTimeOffset.UtcNow internally for the exported_at header. This makes it difficult to write precise unit tests for the CSV output.

Consider passing the export timestamp as a parameter to BuildCsv. This also ensures that the timestamp in the CSV header matches the one used in the filename.

    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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSV header includes board_id, from, to, and exported_at, but it does not include the optional labelId filter from the request. When a label filter is used, the export is no longer fully reproducible/auditable from the embedded headers.

Suggested fix: pass the original BoardMetricsQuery (or at least LabelId) into BuildCsv(...) and emit a # label_id=... (or # label_id= empty) header line when applicable.

Copilot uses AI. Check for mistakes.

// 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The current implementation of CSV injection protection is destructive because it strips leading characters like =, +, -, and @. This alters legitimate data (e.g., a card title starting with a hyphen like "- Fix bug" becomes "Fix bug").

To prevent formula injection without losing data, it is recommended to prefix the field with a single quote (') if it starts with a dangerous character. Spreadsheet applications like Excel treat a leading single quote as an instruction to render the cell as a literal string.

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';
}
Loading
Loading