From f8d5a94ef0c7a5ff90c51097d16042e8088df2eb Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 8 Apr 2026 02:19:42 +0100 Subject: [PATCH 1/7] Add metrics CSV export service with schema versioning and injection protection Introduce IMetricsExportService and MetricsExportService in the Application layer. CSV output includes schema_version header, reproducible filter parameters, documented columns per section (Summary, Throughput, CycleTime, WIP, Blocked), and CSV-injection-safe field sanitization. Closes part of #78 --- .../Services/IMetricsExportService.cs | 30 ++++ .../Services/MetricsExportService.cs | 141 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/IMetricsExportService.cs create mode 100644 backend/src/Taskdeck.Application/Services/MetricsExportService.cs diff --git a/backend/src/Taskdeck.Application/Services/IMetricsExportService.cs b/backend/src/Taskdeck.Application/Services/IMetricsExportService.cs new file mode 100644 index 000000000..74f6c365e --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IMetricsExportService.cs @@ -0,0 +1,30 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Exports board metrics data as CSV with schema-versioned columns. +/// +public interface IMetricsExportService +{ + /// + /// Generate a CSV export of board metrics for the given query. + /// The acting user must have read access to the board. + /// + Task> ExportCsvAsync( + BoardMetricsQuery query, + Guid actingUserId, + CancellationToken cancellationToken = default); +} + +/// +/// Result of a metrics export operation. +/// +/// The raw CSV content bytes. +/// Suggested file name for the download. +/// MIME type of the export. +public sealed record MetricsExportResult( + byte[] Content, + string FileName, + string ContentType); diff --git a/backend/src/Taskdeck.Application/Services/MetricsExportService.cs b/backend/src/Taskdeck.Application/Services/MetricsExportService.cs new file mode 100644 index 000000000..19634c7fe --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/MetricsExportService.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using System.Text; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Produces CSV exports of board metrics with schema-versioned columns +/// and CSV-injection-safe cell values. +/// +public class MetricsExportService : IMetricsExportService +{ + /// + /// Schema version embedded as the first comment line in every export. + /// Bump when column layout changes. + /// + internal const string SchemaVersion = "1.0"; + + private readonly IBoardMetricsService _metricsService; + + public MetricsExportService(IBoardMetricsService metricsService) + { + _metricsService = metricsService; + } + + public async Task> ExportCsvAsync( + BoardMetricsQuery query, + Guid actingUserId, + CancellationToken cancellationToken = default) + { + var metricsResult = await _metricsService.GetBoardMetricsAsync(query, actingUserId, cancellationToken); + if (!metricsResult.IsSuccess) + return Result.Failure(metricsResult.ErrorCode, metricsResult.ErrorMessage); + + var metrics = metricsResult.Value; + var csv = BuildCsv(metrics); + + var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var fileName = $"board-metrics-{metrics.BoardId:N}-{timestamp}.csv"; + + return Result.Success(new MetricsExportResult( + Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray(), + 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}"); + sb.AppendLine(); + + // 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(); + } + + /// + /// Sanitize a field for safe CSV inclusion. + /// - Strips CSV injection characters (=, +, -, @, tab, carriage return) from the start. + /// - Quotes the field if it contains commas, quotes, or newlines. + /// - Doubles internal quote characters. + /// + internal static string SanitizeCsvField(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + // Strip leading characters that could trigger formula injection in spreadsheet apps + var sanitized = value; + while (sanitized.Length > 0 && IsDangerousLeadingChar(sanitized[0])) + { + sanitized = sanitized[1..]; + } + + // 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 bool IsDangerousLeadingChar(char c) + => c is '=' or '+' or '-' or '@' or '\t' or '\r'; +} From 04ce52b103bc16689e6b53cfa626e5bad54cae1a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 8 Apr 2026 02:19:50 +0100 Subject: [PATCH 2/7] Add CSV export endpoint to MetricsController with DI registration GET /api/metrics/boards/{boardId}/export returns a downloadable CSV file with Content-Disposition attachment header. Reuses existing authorization and metrics computation pipeline via IMetricsExportService. Closes part of #78 --- .../Controllers/MetricsController.cs | 49 ++++++++++++++++++- .../ApplicationServiceRegistration.cs | 3 ++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Api/Controllers/MetricsController.cs b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs index cb8060b04..65f84aa6b 100644 --- a/backend/src/Taskdeck.Api/Controllers/MetricsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs @@ -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; } /// @@ -61,4 +66,46 @@ public async Task GetBoardMetrics( var result = await _metricsService.GetBoardMetricsAsync(query, userId); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + + /// + /// Export board metrics as a downloadable CSV file. + /// + /// The board to export metrics for. + /// Start of date range (ISO 8601). + /// End of date range (ISO 8601). + /// Optional label filter. + /// CSV file download. + /// CSV file returned. + /// Invalid query parameters. + /// Authentication required. + /// No read access to the board. + /// Board not found. + [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 ExportBoardMetrics( + Guid boardId, + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] Guid? labelId) + { + 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); + + var query = new BoardMetricsQuery(boardId, fromDate, toDate, labelId); + var result = await _exportService.ExportCsvAsync(query, userId); + + if (!result.IsSuccess) + return result.ToErrorActionResult(); + + var export = result.Value; + return File(export.Content, export.ContentType, export.FileName); + } } diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 41684391d..b6782632d 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -59,6 +59,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection new BoardMetricsService( sp.GetRequiredService(), sp.GetRequiredService())); + services.AddScoped(sp => + new MetricsExportService( + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); From c715036314e2a0ee5158b6e7c24cd13fb441f489 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 8 Apr 2026 02:19:59 +0100 Subject: [PATCH 3/7] Add Export CSV button to metrics dashboard Wire frontend metrics view to the backend CSV export endpoint. Button is enabled when metrics data is loaded, triggers a browser download via blob URL, and extracts filename from Content-Disposition header. Closes part of #78 --- frontend/taskdeck-web/src/api/metricsApi.ts | 29 +++++++++++++++ .../taskdeck-web/src/views/MetricsView.vue | 35 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/frontend/taskdeck-web/src/api/metricsApi.ts b/frontend/taskdeck-web/src/api/metricsApi.ts index 83055c648..d849c5665 100644 --- a/frontend/taskdeck-web/src/api/metricsApi.ts +++ b/frontend/taskdeck-web/src/api/metricsApi.ts @@ -13,4 +13,33 @@ export const metricsApi = { const { data } = await http.get(url) return data }, + + async exportBoardMetricsCsv(query: MetricsQuery): Promise { + const params = new URLSearchParams() + if (query.from) params.append('from', query.from) + if (query.to) params.append('to', query.to) + if (query.labelId) params.append('labelId', query.labelId) + + const qs = params.toString() + const url = `/metrics/boards/${encodeURIComponent(query.boardId)}/export${qs ? `?${qs}` : ''}` + const response = await http.get(url, { responseType: 'blob' }) + + // Extract filename from Content-Disposition header or use default + const disposition = response.headers['content-disposition'] as string | undefined + let filename = 'board-metrics.csv' + if (disposition) { + const match = disposition.match(/filename[^;=\n]*=["']?([^"';\n]*)["']?/) + if (match?.[1]) filename = match[1] + } + + // Trigger browser download + const blob = new Blob([response.data as BlobPart], { type: 'text/csv' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) + }, } diff --git a/frontend/taskdeck-web/src/views/MetricsView.vue b/frontend/taskdeck-web/src/views/MetricsView.vue index 2cca4ef4a..d9982f3ff 100644 --- a/frontend/taskdeck-web/src/views/MetricsView.vue +++ b/frontend/taskdeck-web/src/views/MetricsView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { useBoardStore } from '../store/boardStore' import { useMetricsStore } from '../store/metricsStore' +import { metricsApi } from '../api/metricsApi' import type { MetricsQuery } from '../types/metrics' import type { Board } from '../types/board' @@ -12,6 +13,7 @@ const selectedBoardId = ref('') const dateRangeDays = ref(30) const boards = ref([]) const boardsLoading = ref(false) +const exporting = ref(false) const fromDate = computed(() => { const d = new Date() @@ -65,6 +67,23 @@ onMounted(async () => { } }) +async function exportCsv() { + if (!selectedBoardId.value) return + exporting.value = true + try { + const query: MetricsQuery = { + boardId: selectedBoardId.value, + from: fromDate.value, + to: toDate.value, + } + await metricsApi.exportBoardMetricsCsv(query) + } catch { + // Error is surfaced by the store via toast + } finally { + exporting.value = false + } +} + // Computed helpers for the template const metrics = computed(() => metricsStore.metrics) const loading = computed(() => metricsStore.loading) @@ -123,6 +142,17 @@ const maxWipCount = computed(() => { + +
+ +
@@ -337,6 +367,11 @@ const maxWipCount = computed(() => { letter-spacing: 0.1em; } +.td-metrics__filter-group--action { + justify-content: flex-end; + align-self: flex-end; +} + .td-metrics__select { padding: var(--td-space-2) var(--td-space-3); border: 1px solid var(--td-border-ghost); From 99fcc0a8a4408f55af5fb986d5934a97e2fdb560 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 8 Apr 2026 02:20:08 +0100 Subject: [PATCH 4/7] Add unit and integration tests for metrics CSV export 21 unit tests covering CSV building, schema version, all sections, and CSV injection protection. 8 integration tests covering auth, authorization, cross-user isolation, empty boards, date range filters, and Content-Disposition. Closes part of #78 --- .../MetricsExportApiTests.cs | 140 +++++++++++++ .../Services/MetricsExportServiceTests.cs | 189 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs diff --git a/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs b/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs new file mode 100644 index 000000000..8315dc36f --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs @@ -0,0 +1,140 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using Taskdeck.Api.Tests.Support; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class MetricsExportApiTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public MetricsExportApiTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ExportCsv_ShouldReturnUnauthorized_WhenNoToken() + { + using var client = _factory.CreateClient(); + var boardId = Guid.NewGuid(); + + var response = await client.GetAsync($"/api/metrics/boards/{boardId}/export"); + + await ApiTestHarness.AssertUnauthorizedAsync(response); + } + + [Fact] + public async Task ExportCsv_ShouldReturnCsvFile_ForOwnBoard() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-owner"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("text/csv"); + response.Content.Headers.ContentDisposition.Should().NotBeNull(); + response.Content.Headers.ContentDisposition!.DispositionType.Should().Be("attachment"); + response.Content.Headers.ContentDisposition.FileName.Should().Contain("board-metrics-"); + response.Content.Headers.ContentDisposition.FileName.Should().EndWith(".csv"); + } + + [Fact] + public async Task ExportCsv_ShouldContainSchemaVersion() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-schema"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-schema-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("# schema_version=1.0"); + content.Should().Contain($"# board_id={board.Id}"); + } + + [Fact] + public async Task ExportCsv_ShouldContainAllSections() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-sections"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-sections-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("[Summary]"); + content.Should().Contain("[Throughput]"); + content.Should().Contain("[CycleTime]"); + content.Should().Contain("[WIP]"); + content.Should().Contain("[Blocked]"); + } + + [Fact] + public async Task ExportCsv_ShouldReturnForbiddenOrNotFound_ForOtherUsersBoard() + { + using var ownerClient = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(ownerClient, "export-board-owner"); + var board = await ApiTestHarness.CreateBoardAsync(ownerClient, "export-private"); + + using var outsiderClient = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(outsiderClient, "export-outsider"); + + var response = await outsiderClient.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + await ApiTestHarness.AssertNotFoundOrForbiddenAsync(response); + } + + [Fact] + public async Task ExportCsv_NonExistentBoard_ShouldReturnNotFoundOrForbidden() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-notfound"); + + var response = await client.GetAsync($"/api/metrics/boards/{Guid.NewGuid()}/export"); + + await ApiTestHarness.AssertNotFoundOrForbiddenAsync(response); + } + + [Fact] + public async Task ExportCsv_WithDateRange_ShouldRespectFilters() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-range"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-range-board"); + + var from = DateTimeOffset.UtcNow.AddDays(-7).ToString("o"); + var to = DateTimeOffset.UtcNow.ToString("o"); + + var response = await client.GetAsync( + $"/api/metrics/boards/{board.Id}/export?from={Uri.EscapeDataString(from)}&to={Uri.EscapeDataString(to)}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("# schema_version=1.0"); + content.Should().Contain("# from="); + content.Should().Contain("# to="); + } + + [Fact] + public async Task ExportCsv_EmptyBoard_ShouldReturnValidCsv() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-empty"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-empty-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK, + "CSV export for an empty board should succeed, not error"); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("[Summary]"); + content.Should().Contain("TotalThroughput,0"); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs new file mode 100644 index 000000000..2eae7daf6 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs @@ -0,0 +1,189 @@ +using FluentAssertions; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class MetricsExportServiceTests +{ + private static BoardMetricsResponse CreateSampleMetrics( + Guid? boardId = null, + IReadOnlyList? throughput = null, + IReadOnlyList? cycleTime = null, + IReadOnlyList? wip = null, + IReadOnlyList? blocked = null) + { + var bid = boardId ?? Guid.NewGuid(); + return new BoardMetricsResponse( + bid, + DateTimeOffset.UtcNow.AddDays(-30), + DateTimeOffset.UtcNow, + throughput ?? Array.Empty(), + 2.5, + cycleTime ?? Array.Empty(), + wip ?? Array.Empty(), + 10, + 0, + blocked ?? Array.Empty()); + } + + [Fact] + public void BuildCsv_ShouldIncludeSchemaVersionHeader() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain($"# schema_version={MetricsExportService.SchemaVersion}"); + } + + [Fact] + public void BuildCsv_ShouldIncludeBoardIdHeader() + { + var boardId = Guid.NewGuid(); + var metrics = CreateSampleMetrics(boardId: boardId); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain($"# board_id={boardId}"); + } + + [Fact] + public void BuildCsv_ShouldIncludeAllSections() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("[Summary]"); + csv.Should().Contain("[Throughput]"); + csv.Should().Contain("[CycleTime]"); + csv.Should().Contain("[WIP]"); + csv.Should().Contain("[Blocked]"); + } + + [Fact] + public void BuildCsv_ShouldIncludeSummaryValues() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("AverageCycleTimeDays,2.5"); + csv.Should().Contain("TotalWip,10"); + csv.Should().Contain("BlockedCount,0"); + csv.Should().Contain("TotalThroughput,0"); + } + + [Fact] + public void BuildCsv_ShouldIncludeThroughputData() + { + var throughput = new[] + { + new ThroughputDataPoint(new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero), 3), + new ThroughputDataPoint(new DateTimeOffset(2026, 4, 2, 0, 0, 0, TimeSpan.Zero), 5), + }; + + var metrics = CreateSampleMetrics(throughput: throughput); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("Date,CompletedCount"); + csv.Should().Contain("2026-04-01,3"); + csv.Should().Contain("2026-04-02,5"); + csv.Should().Contain("TotalThroughput,8"); + } + + [Fact] + public void BuildCsv_ShouldIncludeCycleTimeEntries() + { + var cardId = Guid.NewGuid(); + var cycleTime = new[] { new CycleTimeEntry(cardId, "Test Card", 3.5) }; + var metrics = CreateSampleMetrics(cycleTime: cycleTime); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("CardId,CardTitle,CycleTimeDays"); + csv.Should().Contain($"{cardId},Test Card,3.5"); + } + + [Fact] + public void BuildCsv_ShouldIncludeWipSnapshots() + { + var colId = Guid.NewGuid(); + var wip = new[] { new WipSnapshot(colId, "In Progress", 5, 8) }; + var metrics = CreateSampleMetrics(wip: wip); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("ColumnId,ColumnName,CardCount,WipLimit"); + csv.Should().Contain($"{colId},In Progress,5,8"); + } + + [Fact] + public void BuildCsv_ShouldIncludeBlockedCards() + { + var cardId = Guid.NewGuid(); + var blocked = new[] { new BlockedCardSummary(cardId, "Blocked Card", "Waiting on API", 2.3) }; + var metrics = CreateSampleMetrics(blocked: blocked); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("CardId,CardTitle,BlockReason,BlockedDurationDays"); + csv.Should().Contain($"{cardId},Blocked Card,Waiting on API,2.3"); + } + + [Fact] + public void BuildCsv_EmptyMetrics_ShouldReturnValidCsvWithHeaders() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().NotBeNullOrWhiteSpace(); + csv.Should().Contain("[Summary]"); + csv.Should().Contain("TotalThroughput,0"); + } + + // --- CSV Injection Protection Tests --- + + [Theory] + [InlineData("=SUM(A1:A10)", "SUM(A1:A10)")] + [InlineData("+cmd|'/C calc'!A0", "cmd|'/C calc'!A0")] + [InlineData("-1+2", "1+2")] + [InlineData("@SUM(1+1)*cmd|'/C calc'!A0", "SUM(1+1)*cmd|'/C calc'!A0")] + [InlineData("\tcmd", "cmd")] + [InlineData("\rcmd", "cmd")] + public void SanitizeCsvField_ShouldStripDangerousLeadingCharacters(string input, string expected) + { + MetricsExportService.SanitizeCsvField(input).Should().Be(expected); + } + + [Fact] + public void SanitizeCsvField_ShouldQuoteFieldWithComma() + { + MetricsExportService.SanitizeCsvField("hello, world").Should().Be("\"hello, world\""); + } + + [Fact] + public void SanitizeCsvField_ShouldDoubleQuotesInField() + { + MetricsExportService.SanitizeCsvField("say \"hello\"").Should().Be("\"say \"\"hello\"\"\""); + } + + [Fact] + public void SanitizeCsvField_ShouldQuoteFieldWithNewline() + { + MetricsExportService.SanitizeCsvField("line1\nline2").Should().Be("\"line1\nline2\""); + } + + [Fact] + public void SanitizeCsvField_ShouldHandleEmptyString() + { + MetricsExportService.SanitizeCsvField("").Should().Be(""); + } + + [Fact] + public void SanitizeCsvField_ShouldHandleNormalText() + { + MetricsExportService.SanitizeCsvField("Normal card title").Should().Be("Normal card title"); + } + + [Fact] + public void SanitizeCsvField_ShouldStripMultipleDangerousLeadingChars() + { + MetricsExportService.SanitizeCsvField("==+cmd").Should().Be("cmd"); + } +} From decf648a9991b5716efc0ba4f46839d97b0e7883 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 8 Apr 2026 02:20:15 +0100 Subject: [PATCH 5/7] Add ADR-0022: Analytics Export CSV first, PDF deferred Record the decision to ship CSV export first and defer PDF to a future iteration. PDF adds significant dependency weight for low marginal value in a developer-facing local-first tool. Closes #78 --- ...analytics-export-csv-first-pdf-deferred.md | 39 +++++++++++++++++++ docs/decisions/INDEX.md | 1 + 2 files changed, 40 insertions(+) create mode 100644 docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md diff --git a/docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md b/docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md new file mode 100644 index 000000000..e67211293 --- /dev/null +++ b/docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md @@ -0,0 +1,39 @@ +# ADR-0022: Analytics Export — CSV First, PDF Deferred + +- **Status**: Accepted +- **Date**: 2026-04-08 +- **Deciders**: Project maintainers + +## Context + +Issue #78 (ANL-02) requires exportable analytics reports with reproducible filters. The original scope called for both CSV and PDF formats. This ADR records the decision to ship CSV first and defer PDF export. + +## Decision + +Ship CSV export as the initial analytics export format. Defer PDF export to a future iteration. + +CSV export includes: +- Schema version header for forward compatibility +- Documented column layout per section (Summary, Throughput, CycleTime, WIP, Blocked) +- CSV-injection-safe field sanitization (leading `=`, `+`, `-`, `@`, tab, CR stripped) +- Reproducible filter parameters embedded as comment headers (board_id, from, to, exported_at) +- UTF-8 BOM for reliable Excel opening + +## Alternatives Considered + +- **Ship both CSV and PDF simultaneously**: PDF generation requires either a third-party library (e.g., QuestPDF, iTextSharp) or an HTML-to-PDF headless browser pipeline. Both add significant dependency weight to a local-first SQLite application. The marginal user value of PDF over CSV is low for developer-facing analytics, since CSV is directly importable into spreadsheets, scripts, and BI tools. Rejected for this iteration. + +- **PDF only**: CSV is more machine-friendly and more useful for the developer audience. PDF is a presentation format better suited for stakeholder reports, which is not a current Taskdeck use case. Rejected. + +- **Server-side PDF via Chromium headless**: Adds ~300 MB runtime dependency. Entirely disproportionate for a local-first tool. Rejected. + +## Consequences + +- CSV export is available immediately via `GET /api/metrics/boards/{boardId}/export`. +- PDF export can be added later with minimal disruption — the export pipeline is designed with a format-agnostic interface (`IMetricsExportService`). +- If PDF is later needed, the recommended approach is a lightweight .NET PDF library (e.g., QuestPDF) added to the Infrastructure layer, keeping Application layer pure. + +## References + +- Issue: #78 (ANL-02: Exportable analytics reports) +- Related: `BoardMetricsService`, `MetricsExportService` diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index df1c17ce8..3af5d87d4 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -23,3 +23,4 @@ | [0019](ADR-0019-mcp-server-official-sdk-embedded-hosting.md) | MCP Server — Official SDK with Embedded Hosting | Accepted | 2026-04-01 | | [0020](ADR-0020-plugin-extension-architecture.md) | Plugin/Extension Architecture RFC and Sandboxing Constraints | Proposed | 2026-04-01 | | [0021](ADR-0021-jwt-invalidation-user-active-middleware.md) | JWT Invalidation — User-Active Middleware over Token Blocklist | Accepted | 2026-04-03 | +| [0022](ADR-0022-analytics-export-csv-first-pdf-deferred.md) | Analytics Export — CSV First, PDF Deferred | Accepted | 2026-04-08 | From c9b44f0afaa7614843a720c8440282b0702c97b0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 8 Apr 2026 02:21:55 +0100 Subject: [PATCH 6/7] Remove unused System.Text import from MetricsExportApiTests --- backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs b/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs index 8315dc36f..eb806d705 100644 --- a/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text; using FluentAssertions; using Taskdeck.Api.Tests.Support; using Xunit; From 88cf0f8c46c5c629c24eb5c3261f394ab9a78839 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 8 Apr 2026 03:01:31 +0100 Subject: [PATCH 7/7] Fix CSV injection via embedded newlines, forward CancellationToken, surface export errors in UI - SanitizeCsvField now strips dangerous leading chars from each embedded line, not just the first line, preventing formula injection via multi-line cell values (e.g. "hello\n=CMD|'/C calc'!A0") - ExportBoardMetrics controller action forwards CancellationToken from HttpContext.RequestAborted to the export service - Replace inefficient .Concat().ToArray() BOM prepend with direct array copy - Frontend exportCsv() now shows toast error on failure instead of silently swallowing exceptions - Add 5 new test cases for embedded-newline CSV injection vectors --- .../Controllers/MetricsController.cs | 6 ++- .../Services/MetricsExportService.cs | 54 +++++++++++++++---- .../Services/MetricsExportServiceTests.cs | 11 ++++ .../taskdeck-web/src/views/MetricsView.vue | 8 ++- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/backend/src/Taskdeck.Api/Controllers/MetricsController.cs b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs index 65f84aa6b..2d01956c5 100644 --- a/backend/src/Taskdeck.Api/Controllers/MetricsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs @@ -74,6 +74,7 @@ public async Task GetBoardMetrics( /// Start of date range (ISO 8601). /// End of date range (ISO 8601). /// Optional label filter. + /// Cancellation token. /// CSV file download. /// CSV file returned. /// Invalid query parameters. @@ -90,7 +91,8 @@ public async Task ExportBoardMetrics( Guid boardId, [FromQuery] DateTimeOffset? from, [FromQuery] DateTimeOffset? to, - [FromQuery] Guid? labelId) + [FromQuery] Guid? labelId, + CancellationToken cancellationToken) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) return errorResult!; @@ -100,7 +102,7 @@ public async Task ExportBoardMetrics( var fromDate = from ?? toDate.AddDays(-30); var query = new BoardMetricsQuery(boardId, fromDate, toDate, labelId); - var result = await _exportService.ExportCsvAsync(query, userId); + var result = await _exportService.ExportCsvAsync(query, userId, cancellationToken); if (!result.IsSuccess) return result.ToErrorActionResult(); diff --git a/backend/src/Taskdeck.Application/Services/MetricsExportService.cs b/backend/src/Taskdeck.Application/Services/MetricsExportService.cs index 19634c7fe..6b03e4918 100644 --- a/backend/src/Taskdeck.Application/Services/MetricsExportService.cs +++ b/backend/src/Taskdeck.Application/Services/MetricsExportService.cs @@ -39,10 +39,13 @@ public async Task> ExportCsvAsync( var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); var fileName = $"board-metrics-{metrics.BoardId:N}-{timestamp}.csv"; - return Result.Success(new MetricsExportResult( - Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray(), - fileName, - "text/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) @@ -106,7 +109,8 @@ internal static string BuildCsv(BoardMetricsResponse metrics) /// /// Sanitize a field for safe CSV inclusion. - /// - Strips CSV injection characters (=, +, -, @, tab, carriage return) from the start. + /// - 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. /// @@ -115,12 +119,11 @@ internal static string SanitizeCsvField(string value) if (string.IsNullOrEmpty(value)) return value; - // Strip leading characters that could trigger formula injection in spreadsheet apps - var sanitized = value; - while (sanitized.Length > 0 && IsDangerousLeadingChar(sanitized[0])) - { - sanitized = sanitized[1..]; - } + // 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(',') || @@ -136,6 +139,35 @@ internal static string SanitizeCsvField(string value) 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..]; + } + + /// + /// For each line after the first within a multi-line value, strip leading + /// dangerous characters so that embedded newlines cannot smuggle formula prefixes. + /// + 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'; } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs index 2eae7daf6..819e1a300 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs @@ -186,4 +186,15 @@ public void SanitizeCsvField_ShouldStripMultipleDangerousLeadingChars() { MetricsExportService.SanitizeCsvField("==+cmd").Should().Be("cmd"); } + + [Theory] + [InlineData("hello\n=CMD|'/C calc'!A0", "\"hello\nCMD|'/C calc'!A0\"")] + [InlineData("line1\n+cmd\nline3", "\"line1\ncmd\nline3\"")] + [InlineData("safe\nsafe2", "\"safe\nsafe2\"")] + [InlineData("ok\n@evil", "\"ok\nevil\"")] + [InlineData("a\n\t\r=bad", "\"a\nbad\"")] + public void SanitizeCsvField_ShouldStripDangerousCharsFromEmbeddedLines(string input, string expected) + { + MetricsExportService.SanitizeCsvField(input).Should().Be(expected); + } } diff --git a/frontend/taskdeck-web/src/views/MetricsView.vue b/frontend/taskdeck-web/src/views/MetricsView.vue index d9982f3ff..5fc4e4f24 100644 --- a/frontend/taskdeck-web/src/views/MetricsView.vue +++ b/frontend/taskdeck-web/src/views/MetricsView.vue @@ -2,12 +2,15 @@ import { computed, onMounted, ref, watch } from 'vue' import { useBoardStore } from '../store/boardStore' import { useMetricsStore } from '../store/metricsStore' +import { useToastStore } from '../store/toastStore' import { metricsApi } from '../api/metricsApi' +import { getErrorDisplay } from '../composables/useErrorMapper' import type { MetricsQuery } from '../types/metrics' import type { Board } from '../types/board' const boardStore = useBoardStore() const metricsStore = useMetricsStore() +const toast = useToastStore() const selectedBoardId = ref('') const dateRangeDays = ref(30) @@ -77,8 +80,9 @@ async function exportCsv() { to: toDate.value, } await metricsApi.exportBoardMetricsCsv(query) - } catch { - // Error is surfaced by the store via toast + } catch (e: unknown) { + const { message } = getErrorDisplay(e, 'Failed to export CSV') + toast.error(message) } finally { exporting.value = false }