feat: add GET /api/analytics/export endpoint for CSV download#62
Open
SdSarthak wants to merge 2 commits into
Open
feat: add GET /api/analytics/export endpoint for CSV download#62SdSarthak wants to merge 2 commits into
SdSarthak wants to merge 2 commits into
Conversation
- 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
There was a problem hiding this comment.
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/exportthat aggregatesCardViewandFollowLogevents and returns a CSV attachment. - Implemented an IDOR guard that rejects mismatched
userIdquery 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 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
+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'; | |||
…CSV escaping, fix test mocks
Author
|
Good catches — addressed in the follow-up commit:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GET /api/analytics/exporttoapps/backend/src/routes/analytics.ts, protected by the existingapp.authenticatepre-handler (401 for unauthenticated requests)CardViewrows grouped by date + source andFollowLogrows grouped by date + platform, then merges both into a single aggregated CSVdate,platform,event_type,count— sorted ascending by dateContent-Type: text/csvandContent-Disposition: attachment; filename=devcard-analytics.csvheadersuserIdquery param that doesn't match their own JWT-derived ID, the request is rejected with 403src/__tests__/analytics-export.test.tswith 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 passGET /api/analytics/exportreturns a downloadable CSV file with the correct columns?userId=<someone-else>returns 403curl -Osaves a valid file