Skip to content

Add exportable analytics reports (CSV) with reproducible filters#787

Merged
Chris0Jeky merged 8 commits intomainfrom
feature/analytics-export-reports-78
Apr 9, 2026
Merged

Add exportable analytics reports (CSV) with reproducible filters#787
Chris0Jeky merged 8 commits intomainfrom
feature/analytics-export-reports-78

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Add CSV export endpoint GET /api/metrics/boards/{boardId}/export that reuses the existing BoardMetricsService pipeline with validated filter parameters (boardId, from, to, labelId)
  • CSV output includes schema version header (1.0), documented column layout across 5 sections (Summary, Throughput, CycleTime, WIP, Blocked), reproducible filter parameters as comment headers, and CSV-injection-safe field sanitization
  • Add "Export CSV" button to the frontend metrics dashboard, wired to the backend endpoint with current filter state and browser download via blob URL
  • Create ADR-0022 recording the decision to ship CSV first and defer PDF export (dependency weight disproportionate for local-first tool)
  • 29 new tests: 21 unit tests (CSV building, injection protection) + 8 integration tests (auth, cross-user isolation, empty boards, date range filters, Content-Disposition)

Closes #78

Test plan

  • dotnet test passes for MetricsExportServiceTests (21 unit tests)
  • dotnet test passes for MetricsExportApiTests (8 integration tests)
  • Verify CSV includes # schema_version=1.0 header
  • Verify Content-Disposition attachment with .csv extension
  • Verify unauthorized requests return 401
  • Verify cross-user board access returns 403/404
  • Verify CSV injection chars (=, +, -, @) are stripped from field starts
  • Manual: verify Export CSV button appears in metrics dashboard and triggers download

…rotection

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
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
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
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
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
Copilot AI review requested due to automatic review settings April 8, 2026 01:20
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

CSV Injection

  • Mitigated: SanitizeCsvField strips leading =, +, -, @, \t, \r characters. Fields containing commas, quotes, or newlines are quoted with doubled internal quotes. 7 unit tests cover injection vectors including multi-char stripping (==+cmd).

Authorization

  • OK: Export endpoint reuses BoardMetricsService.GetBoardMetricsAsync which checks CanReadBoardAsync before returning data. Integration tests verify 401 (no token), 403/404 (cross-user), and 403/404 (nonexistent board).

Large Dataset Memory

  • Acceptable for current scope: CSV is built in-memory via StringBuilder. The metrics data is inherently bounded: throughput has at most ~90 daily data points, WIP is per-column (small), cycle time/blocked are per-card but already loaded by BoardMetricsService. No streaming needed at this data volume. If future analytics add unbounded row sources, streaming should be revisited.

Content-Disposition Header

  • OK: ASP.NET File(byte[], contentType, fileName) automatically sets Content-Disposition: attachment; filename=.... Integration test verifies presence, disposition type, and .csv extension.

Filter Validation

  • OK: Delegated to BoardMetricsService which validates From > To, empty board ID, and empty user ID. Date range defaults to last 30 days when not specified. Integration test covers custom date range.

Unused Import Fixed

  • Removed unused System.Text import from MetricsExportApiTests.cs.

No Issues Found Requiring Code Changes

All identified concerns are either already mitigated or acceptable for the current data scale.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a CSV export feature for board metrics, including a new API endpoint, a MetricsExportService with schema versioning and injection protection, and a frontend "Export CSV" button. ADR-0022 is included to document the prioritization of CSV over PDF. Feedback focuses on making the CSV sanitization non-destructive by using single-quote prefixing, refactoring duplicated date-defaulting logic in the controller, and passing an explicit timestamp to the CSV builder to improve testability and ensure consistency between the filename and file headers.

Comment on lines +113 to +137
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;
}
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;
    }

Comment on lines +99 to +100
var toDate = to ?? DateTimeOffset.UtcNow;
var fromDate = from ?? toDate.AddDays(-30);
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.

Comment on lines +37 to +39
var csv = BuildCsv(metrics);

var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
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);

Comment on lines +48 to +57
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}");
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}");

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an exportable, schema-versioned CSV report for board metrics, including a new backend API endpoint and a frontend “Export CSV” action, plus ADR documentation and test coverage.

Changes:

  • Backend: introduce IMetricsExportService/MetricsExportService and GET /api/metrics/boards/{boardId}/export returning a downloadable CSV.
  • Frontend: add an “Export CSV” button on the metrics dashboard and a metricsApi.exportBoardMetricsCsv() blob download helper.
  • Docs/Tests: add ADR-0022 and new unit + integration tests covering CSV output and API behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
frontend/taskdeck-web/src/views/MetricsView.vue Adds export button + wiring for CSV export from the metrics dashboard
frontend/taskdeck-web/src/api/metricsApi.ts Adds client call to export endpoint and triggers browser download
backend/src/Taskdeck.Application/Services/MetricsExportService.cs Implements CSV generation + injection sanitization + download metadata
backend/src/Taskdeck.Application/Services/IMetricsExportService.cs Defines export service contract and result shape
backend/src/Taskdeck.Api/Controllers/MetricsController.cs Adds /export endpoint to serve CSV downloads
backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs Registers export service in DI
backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs Unit tests for CSV structure and sanitization
backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs Integration tests for auth, access isolation, headers, and basic content
docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md ADR documenting CSV-first decision and PDF deferral
docs/decisions/INDEX.md Adds ADR-0022 to the ADR index

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +80 to +81
} catch {
// Error is surfaced by the store via toast
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.

exportCsv() swallows errors but the comment says the store will surface a toast. Since this call goes directly to metricsApi.exportBoardMetricsCsv() (not through metricsStore), failures currently won’t show any user-facing error.

Consider mapping the error here (e.g., via useToastStore() + getErrorDisplay) or adding an exportBoardMetricsCsv() action to metricsStore to keep error/toast behavior consistent with fetchBoardMetrics().

Suggested change
} catch {
// Error is surfaced by the store via toast
} catch (err: unknown) {
metricsStore.error =
err instanceof Error && err.message ? err.message : 'Failed to export metrics CSV.'

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +58
// 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();
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.
Comment on lines +83 to +89
[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(
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.
Comment on lines +74 to +78
const query: MetricsQuery = {
boardId: selectedBoardId.value,
from: fromDate.value,
to: toDate.value,
}
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.

toDate is defined as computed(() => new Date().toISOString()) with no reactive dependencies, so Vue will cache it after first evaluation. That means subsequent exports (and metric fetches) can end up using a stale to timestamp, and if the page stays open long enough the recomputed from (based on "now") can even become later than the cached to, triggering backend validation errors.

Consider generating to (and from) at the moment you build the query (e.g., inside fetchMetrics() / exportCsv()), or making toDate depend on a reactive "now" source (timer) so it updates over time.

Copilot uses AI. Check for mistakes.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review -- Round 2

Findings

1. [HIGH] CSV Injection: Embedded newlines can smuggle formula prefixes

SanitizeCsvField only strips dangerous leading characters (=, +, -, @, \t, \r) from the start of the value. A card title like "hello\n=CMD|'/C calc'!A0" passes sanitization -- the value gets quoted because of the newline, but the =CMD payload appears at the start of a new line inside the quoted cell. Some spreadsheet applications (older Excel, LibreOffice in certain modes) will evaluate formulas embedded in multi-line quoted cells.

Fix: After the leading-char strip loop, also strip dangerous leading chars from each line within the value. Alternatively, prefix every dangerous leading char with a single-quote (') instead of stripping, which is the OWASP-recommended approach.

File: backend/src/Taskdeck.Application/Services/MetricsExportService.cs, SanitizeCsvField method.


2. [MEDIUM] CancellationToken not forwarded from controller to export service

ExportBoardMetrics action (line 103 of MetricsController.cs) calls _exportService.ExportCsvAsync(query, userId) without passing the CancellationToken from HttpContext.RequestAborted. If a client disconnects mid-export, the server continues generating the CSV unnecessarily. For large boards this wastes resources.

File: backend/src/Taskdeck.Api/Controllers/MetricsController.cs, line 103.


3. [MEDIUM] Entire CSV buffered in memory as byte[]

ExportCsvAsync builds the full CSV as a StringBuilder, converts to byte[] via Encoding.UTF8.GetBytes, then concatenates with the BOM via .Concat(...).ToArray(). For a board with 100K cards, each CycleTime/Blocked row could be ~100-200 bytes, yielding 10-20 MB of string + byte array allocations, doubled by the .Concat().ToArray() copy.

The Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray() pattern on line 43 is particularly wasteful -- it creates an intermediate LINQ enumerable and a third array copy. At minimum, pre-allocate and use MemoryStream or write BOM + content directly.

Not critical for typical board sizes but worth noting for scalability.

File: backend/src/Taskdeck.Application/Services/MetricsExportService.cs, lines 42-43.


4. [MEDIUM] Frontend error silently swallowed -- comment is inaccurate

MetricsView.vue exportCsv() catches errors with the comment "Error is surfaced by the store via toast", but the export call goes through metricsApi.exportBoardMetricsCsv() which calls http.get() directly -- NOT through the metrics store. The HTTP interceptor only console.errors and rejects; there is no toast notification wired up for this path. The user gets no visible feedback when the export fails (403, 404, 500, network error).

File: frontend/taskdeck-web/src/views/MetricsView.vue, lines 80-81.


5. [LOW] No from > to validation at controller level

The controller defaults to before from and relies on BoardMetricsService to reject from > to. This works because the service validates it, but if a caller provides from=2026-12-01&to=2026-01-01, the error message comes from the metrics service layer rather than being caught early with a 400. Acceptable but not ideal.


6. [LOW] Content-Disposition filename lacks sanitization against special chars

The filename is constructed as board-metrics-{boardId:N}-{timestamp}.csv. Since boardId is formatted with :N (no hyphens, just hex digits) and timestamp uses digits/hyphens, there is no path traversal risk. This is fine as implemented.


7. [LOW] UTF-8 BOM is present -- good

Line 43 correctly prepends Encoding.UTF8.GetPreamble() (the BOM bytes EF BB BF) for Excel compatibility. This is a positive finding.


8. [LOW] Schema version header present and documented -- good

# schema_version=1.0 is emitted as the first line. ADR-0022 documents the format. The SchemaVersion constant makes it easy to bump.


Test Quality Assessment

  • Auth test (ExportCsv_ShouldReturnUnauthorized_WhenNoToken) -- real integration test, good.
  • Board ownership test (ExportCsv_ShouldReturnForbiddenOrNotFound_ForOtherUsersBoard) -- tests actual cross-user access, good.
  • CSV content structure tests verify section headers and schema version -- adequate.
  • CSV injection tests cover the main dangerous chars -- but missing the embedded-newline vector described in Finding Claude/create feature f 01 qr r as j4 s14advm nn qhp2 la #1.
  • No test for the from > to date range edge case on the export endpoint specifically (the metrics service may test this, but the export path should also verify).
  • No test for extremely long card titles (memory/truncation behavior).

Verdict

PASS WITH FIXES -- Findings #1 (CSV injection via embedded newlines) and #4 (silent error swallowing in frontend) should be fixed before merge. Finding #2 (CancellationToken) is a good practice fix. The rest are acceptable as-is or for follow-up.

…urface 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
…und2

Fix adversarial review findings for CSV export
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review -- Round 2 Fixes Applied

All HIGH and MEDIUM findings from the review above have been fixed via PR #794 (merged into this branch):

  1. [HIGH] CSV injection via embedded newlines -- Fixed. SanitizeCsvField now strips dangerous leading chars from every line within multi-line values, not just the first line. Added 5 new test cases covering this vector.

  2. [MEDIUM] CancellationToken not forwarded -- Fixed. ExportBoardMetrics now accepts and forwards CancellationToken from HttpContext.RequestAborted.

  3. [MEDIUM] Inefficient BOM + CSV byte array concatenation -- Fixed. Replaced Encoding.UTF8.GetPreamble().Concat(...).ToArray() with direct array allocation and copy.

  4. [MEDIUM] Frontend error silently swallowed -- Fixed. exportCsv() now uses useToastStore and getErrorDisplay to show a user-visible toast on failure.

All backend tests pass (full suite, 0 failures).

@Chris0Jeky Chris0Jeky merged commit 2214271 into main Apr 9, 2026
23 checks passed
@Chris0Jeky Chris0Jeky deleted the feature/analytics-export-reports-78 branch April 9, 2026 01:33
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

ANL-02: Exportable analytics reports (CSV/PDF) with reproducible filters

2 participants