-
Notifications
You must be signed in to change notification settings - Fork 114
feat: add analytics csv export endpoint for issue #27 #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
PiyushTheProgrammer
wants to merge
3
commits into
Dev-Card:main
Choose a base branch
from
PiyushTheProgrammer:feat/analytics-csv-export
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+143
−1
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
a2e9dd3
feat: add analytics csv export endpoint for issue #27
PiyushTheProgrammer 8465feb
refactor: optimize csv export with DB aggregation & Node streams, add…
PiyushTheProgrammer 69accc3
Merge remote-tracking branch 'origin/main' into feat/analytics-csv-ex…
PiyushTheProgrammer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import Fastify from 'fastify'; | ||
| import { analyticsRoutes } from '../routes/analytics'; | ||
|
|
||
| describe('GET /export - CSV Export', () => { | ||
| let app: ReturnType<typeof Fastify>; | ||
| let prismaMock: any; | ||
|
|
||
| beforeEach(async () => { | ||
| // 1. Setup a dummy Fastify instance for testing | ||
| app = Fastify(); | ||
|
|
||
| // 2. Mock Prisma behavior (Database) | ||
| prismaMock = { | ||
| $queryRaw: vi.fn(), | ||
| // Mocking other models just so the plugin registers without errors | ||
| cardView: { count: vi.fn(), findMany: vi.fn(), groupBy: vi.fn() }, | ||
| followLog: { count: vi.fn() } | ||
| }; | ||
| app.decorate('prisma', prismaMock); | ||
|
|
||
| // 3. Mock Authentication Middleware | ||
| app.decorate('authenticate', async (request: any, reply: any) => { | ||
| if (request.headers.authorization !== 'Bearer valid-token') { | ||
| reply.code(401).send({ error: 'Unauthorized' }); | ||
| return; | ||
| } | ||
| // Inject dummy user ID (simulating decoded JWT) | ||
| request.user = { id: 'user-123' }; | ||
| }); | ||
|
|
||
| // 4. Register our actual routes | ||
| await app.register(analyticsRoutes); | ||
| }); | ||
|
|
||
| it('should return 401 when unauthenticated', async () => { | ||
| // Expected: Request without valid Auth header returns 401 | ||
| const response = await app.inject({ | ||
| method: 'GET', | ||
| url: '/export', | ||
| }); | ||
|
|
||
| expect(response.statusCode).toBe(401); | ||
| expect(JSON.parse(response.body)).toEqual({ error: 'Unauthorized' }); | ||
| }); | ||
|
|
||
| it('should strictly return the users own data (No IDOR) and prevent 403 scenarios', async () => { | ||
| // Expected: The endpoint relies strictly on request.user.id | ||
| prismaMock.$queryRaw.mockResolvedValue([]); | ||
|
|
||
| const response = await app.inject({ | ||
| method: 'GET', | ||
| url: '/export', | ||
| headers: { authorization: 'Bearer valid-token' } | ||
| }); | ||
|
|
||
| // Validates that the request succeeded and Prisma was called | ||
| expect(response.statusCode).toBe(200); | ||
| expect(prismaMock.$queryRaw).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should return valid CSV structure with correct headers', async () => { | ||
| // Mock data mimicking Prisma's BigInt and Date return types | ||
| prismaMock.$queryRaw.mockResolvedValue([ | ||
| { date: new Date('2026-03-12T00:00:00.000Z'), count: 1n }, | ||
| { date: new Date('2026-03-13T00:00:00.000Z'), count: 5n } | ||
| ]); | ||
|
|
||
| const response = await app.inject({ | ||
| method: 'GET', | ||
| url: '/export', | ||
| headers: { authorization: 'Bearer valid-token' } | ||
| }); | ||
|
|
||
| // 1. Verify Status | ||
| expect(response.statusCode).toBe(200); | ||
|
|
||
| // 2. Verify Headers | ||
| expect(response.headers['content-type']).toBe('text/csv'); | ||
| expect(response.headers['content-disposition']).toBe('attachment; filename="devcard-analytics.csv"'); | ||
|
|
||
| // 3. Verify Body (CSV Format) | ||
| const body = response.body; | ||
| expect(body).toContain('date,platform,event_type,count\n'); | ||
| expect(body).toContain('2026-03-12,devcard,view,1\n'); | ||
| expect(body).toContain('2026-03-13,devcard,view,5\n'); | ||
| }); | ||
|
|
||
| }); | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; | ||
| import { Readable } from 'stream'; | ||
|
|
||
| export async function analyticsRoutes(app: FastifyInstance) { | ||
|
|
||
|
|
@@ -98,4 +99,56 @@ export async function analyticsRoutes(app: FastifyInstance) { | |
| }, | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| // ─── Export Analytics CSV (Optimized) ─── | ||
| app.get('/export', { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Request schema is missing? |
||
| preHandler: [app.authenticate], | ||
| }, async (request: FastifyRequest, reply: FastifyReply) => { | ||
| const userId = (request.user as any).id; | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Error handling can be more better |
||
| // 1. Set headers early for streaming | ||
| reply.header('Content-Type', 'text/csv'); | ||
| reply.header('Content-Disposition', 'attachment; filename="devcard-analytics.csv"'); | ||
|
|
||
| try { | ||
| // 2. DB Aggregation: Let the Database do the heavy lifting | ||
| // Note: Prisma doesn't natively group by Date strings easily, so we use $queryRaw | ||
| // This query groups views by day directly in the DB. | ||
| const aggregatedViews = await app.prisma.$queryRaw<Array<{ date: Date; count: bigint }>>` | ||
| SELECT | ||
| DATE("createdAt") as date, | ||
| COUNT(*) as count | ||
| FROM "CardView" | ||
| WHERE "ownerId" = ${userId} | ||
| GROUP BY DATE("createdAt") | ||
| ORDER BY date DESC | ||
| `; | ||
|
|
||
| // 3. Node.js Streams: Push data directly to the client without holding it in RAM | ||
| const csvStream = new Readable({ | ||
| read() {} // required implementation | ||
| }); | ||
|
|
||
| // Push Header | ||
| csvStream.push('date,platform,event_type,count\n'); | ||
|
|
||
| // Push Rows | ||
| for (const row of aggregatedViews) { | ||
| // Convert DB Date to YYYY-MM-DD | ||
| const dateStr = new Date(row.date).toISOString().split('T')[0]; | ||
| // Note: Prisma returns BigInt for COUNT(*), so we convert it to Number or String | ||
| csvStream.push(`${dateStr},devcard,view,${row.count.toString()}\n`); | ||
| } | ||
|
|
||
| // End the stream | ||
| csvStream.push(null); | ||
|
|
||
| // Fastify automatically handles piping the stream to the response | ||
| return reply.send(csvStream); | ||
|
|
||
| } catch (error) { | ||
| request.log.error(error); | ||
| return reply.status(500).send({ error: 'Failed to generate CSV export' }); | ||
| } | ||
| }); | ||
| } | ||
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These appear to be placeholder tests currently. Could we add real API test coverage with mocks and assertions?