Skip to content

feat: add GET /api/analytics/export endpoint for CSV download#62

Open
SdSarthak wants to merge 2 commits into
Dev-Card:mainfrom
SdSarthak:feat/analytics-csv-export
Open

feat: add GET /api/analytics/export endpoint for CSV download#62
SdSarthak wants to merge 2 commits into
Dev-Card:mainfrom
SdSarthak:feat/analytics-csv-export

Conversation

@SdSarthak
Copy link
Copy Markdown

Summary

  • Adds GET /api/analytics/export to apps/backend/src/routes/analytics.ts, protected by the existing app.authenticate pre-handler (401 for unauthenticated requests)
  • Queries CardView rows grouped by date + source and FollowLog rows grouped by date + platform, then merges both into a single aggregated CSV
  • CSV columns: date, platform, event_type, count — sorted ascending by date
  • Sets Content-Type: text/csv and Content-Disposition: attachment; filename=devcard-analytics.csv headers
  • IDOR guard: if a caller passes a userId query param that doesn't match their own JWT-derived ID, the request is rejected with 403
  • Adds src/__tests__/analytics-export.test.ts with four vitest unit tests covering: unauthenticated (401), IDOR attempt (403), correct CSV structure, and empty-state (header only)

Closes #27

Test plan

  • cd apps/backend && npm test — analytics-export tests pass
  • Authenticated GET /api/analytics/export returns a downloadable CSV file with the correct columns
  • Unauthenticated request returns 401
  • Request with ?userId=<someone-else> returns 403
  • Downloading the CSV in a browser or via curl -O saves a valid file

- Adds authenticated /api/analytics/export route that returns card view
  and follow event data as a downloadable CSV file
- Groups CardView rows by date + source (used as platform) and FollowLog
  rows by date + platform, then merges into a single sorted CSV
- CSV columns: date, platform, event_type, count
- Sets Content-Type: text/csv and Content-Disposition: attachment header
- IDOR guard: 403 if a userId query param doesn't match the authenticated user
- Adds unit tests covering 401, 403, correct CSV structure, and empty state

Closes Dev-Card#27
Copilot AI review requested due to automatic review settings May 10, 2026 18:01
Copy link
Copy Markdown

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 a new authenticated CSV export endpoint to the backend analytics routes so users can download aggregated analytics data (card views + follow logs) for portability and dashboard export use cases.

Changes:

  • Added GET /api/analytics/export that aggregates CardView and FollowLog events and returns a CSV attachment.
  • Implemented an IDOR guard that rejects mismatched userId query params with 403.
  • Added vitest coverage for auth guard behavior and CSV output shape/empty state.

Reviewed changes

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

File Description
apps/backend/src/routes/analytics.ts Adds /export route that queries analytics data and serializes it to CSV for download.
apps/backend/src/tests/analytics-export.test.ts Adds unit tests validating auth behavior and CSV structure for the export endpoint.

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

Comment thread apps/backend/src/routes/analytics.ts Outdated
Comment on lines +72 to +104
// Fetch card views grouped by date and source (used as "platform" for card views)
const cardViewGroups = await app.prisma.cardView.groupBy({
by: ['createdAt', 'source'],
where: { ownerId: userId },
_count: { id: true },
});

// Fetch follow logs grouped by date and platform
const followGroups = await app.prisma.followLog.groupBy({
by: ['createdAt', 'platform'],
where: { followerId: userId, status: 'success' },
_count: { id: true },
});

// Aggregate rows by date string + platform + event_type
const aggregated: Record<string, { date: string; platform: string; event_type: string; count: number }> = {};

for (const row of cardViewGroups) {
const date = row.createdAt.toISOString().slice(0, 10);
const key = `${date}|${row.source}|card_view`;
if (!aggregated[key]) {
aggregated[key] = { date, platform: row.source, event_type: 'card_view', count: 0 };
}
aggregated[key].count += row._count.id;
}

for (const row of followGroups) {
const date = row.createdAt.toISOString().slice(0, 10);
const key = `${date}|${row.platform}|follow`;
if (!aggregated[key]) {
aggregated[key] = { date, platform: row.platform, event_type: 'follow', count: 0 };
}
aggregated[key].count += row._count.id;
Comment thread apps/backend/src/routes/analytics.ts Outdated
Comment on lines +72 to +104
// Fetch card views grouped by date and source (used as "platform" for card views)
const cardViewGroups = await app.prisma.cardView.groupBy({
by: ['createdAt', 'source'],
where: { ownerId: userId },
_count: { id: true },
});

// Fetch follow logs grouped by date and platform
const followGroups = await app.prisma.followLog.groupBy({
by: ['createdAt', 'platform'],
where: { followerId: userId, status: 'success' },
_count: { id: true },
});

// Aggregate rows by date string + platform + event_type
const aggregated: Record<string, { date: string; platform: string; event_type: string; count: number }> = {};

for (const row of cardViewGroups) {
const date = row.createdAt.toISOString().slice(0, 10);
const key = `${date}|${row.source}|card_view`;
if (!aggregated[key]) {
aggregated[key] = { date, platform: row.source, event_type: 'card_view', count: 0 };
}
aggregated[key].count += row._count.id;
}

for (const row of followGroups) {
const date = row.createdAt.toISOString().slice(0, 10);
const key = `${date}|${row.platform}|follow`;
if (!aggregated[key]) {
aggregated[key] = { date, platform: row.platform, event_type: 'follow', count: 0 };
}
aggregated[key].count += row._count.id;
Comment on lines +109 to +112
const csvLines = ['date,platform,event_type,count'];
for (const r of rows) {
csvLines.push(`${r.date},${r.platform},${r.event_type},${r.count}`);
}
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
@SdSarthak
Copy link
Copy Markdown
Author

Good catches — addressed in the follow-up commit:

  • Replaced groupBy on createdAt (full timestamp) with findMany + in-code day-level aggregation to avoid the one-group-per-row issue.
  • Added RFC 4180-compliant CSV escaping (quotes values containing commas, newlines, or quotes).
  • Removed unused beforeEach import and updated test mocks to match the new findMany shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

backend: implement /api/analytics/export endpoint for CSV download

2 participants