From a2e9dd302f1164cb668decb6270b689712197b4a Mon Sep 17 00:00:00 2001 From: PiyushTheProgrammer Date: Sun, 17 May 2026 12:28:04 +0530 Subject: [PATCH 1/2] feat: add analytics csv export endpoint for issue #27 --- apps/backend/src/__tests__/analytics.test.ts | 42 ++++++++++++++++++++ apps/backend/src/routes/analytics.ts | 38 ++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 apps/backend/src/__tests__/analytics.test.ts diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts new file mode 100644 index 00000000..113eb2cd --- /dev/null +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; + +// Mock test for Analytics CSV Export endpoint +// Note: This test verifies the expected behavior of the /api/analytics/export endpoint +// +// The implementation in analytics.ts: +// - Validates authentication via app.authenticate +// - Prevents IDOR by using request.user.id from the verified JWT (not URL params) +// - Aggregates data and serializes to CSV format +// - Sets correct Content-Type and Content-Disposition headers + +describe('GET /api/analytics/export - CSV Export', () => { + + it('should return 401 when unauthenticated', async () => { + // Expected behavior: + // Request without valid JWT token in cookies + // app.authenticate hook intercepts it + // Returns 401 Unauthorized + expect(true).toBe(true); + }); + + it('should strictly return the users own data (No IDOR) and prevent 403 scenarios', async () => { + // Expected behavior: + // Because the endpoint relies strictly on request.user.id from the auth context, + // users cannot pass another user's ID via URL. Attempting to access unauthorized + // routes naturally mitigates IDOR by strictly isolating data to the JWT owner. + expect(true).toBe(true); + }); + + it('should return valid CSV structure with correct headers', async () => { + // Expected behavior: + // Response Headers: + // - Content-Type: text/csv + // - Content-Disposition: attachment; filename="devcard-analytics.csv" + // + // Response Body matches format: + // date,platform,event_type,count + // 2026-03-12,devcard,view,1 + expect(true).toBe(true); + }); + +}); \ No newline at end of file diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index e9a75bb9..c97ee623 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -98,4 +98,42 @@ export async function analyticsRoutes(app: FastifyInstance) { }, }; }); + +// ─── Export Analytics CSV ─── + app.get('/export', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = (request.user as any).id; + + // Fetch raw views + const views = await app.prisma.cardView.findMany({ + where: { ownerId: userId }, + select: { createdAt: true, source: true }, + }); + + // Aggregation Object to group by date + const dailyStats: Record = {}; + + views.forEach((view) => { + const date = view.createdAt.toISOString().split('T')[0]; + if (!dailyStats[date]) { + dailyStats[date] = 0; + } + dailyStats[date]++; + }); + + // Create CSV Header strictly as per Acceptance Criteria + let csvContent = 'date,platform,event_type,count\n'; + + // Populate rows + for (const [date, count] of Object.entries(dailyStats)) { + csvContent += `${date},devcard,view,${count}\n`; + } + + // Set Headers + reply.header('Content-Type', 'text/csv'); + reply.header('Content-Disposition', 'attachment; filename="devcard-analytics.csv"'); + + return reply.send(csvContent); + }); } From 8465feb864c042b022def25740b427bf94439254 Mon Sep 17 00:00:00 2001 From: PiyushTheProgrammer Date: Fri, 5 Jun 2026 23:23:06 +0530 Subject: [PATCH 2/2] refactor: optimize csv export with DB aggregation & Node streams, add unit tests --- apps/backend/src/__tests__/analytics.test.ts | 105 ++++++++++++++----- apps/backend/src/routes/analytics.ts | 67 +++++++----- 2 files changed, 117 insertions(+), 55 deletions(-) diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 113eb2cd..be7cbc2e 100644 --- a/apps/backend/src/__tests__/analytics.test.ts +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -1,42 +1,89 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import { analyticsRoutes } from '../routes/analytics'; -// Mock test for Analytics CSV Export endpoint -// Note: This test verifies the expected behavior of the /api/analytics/export endpoint -// -// The implementation in analytics.ts: -// - Validates authentication via app.authenticate -// - Prevents IDOR by using request.user.id from the verified JWT (not URL params) -// - Aggregates data and serializes to CSV format -// - Sets correct Content-Type and Content-Disposition headers +describe('GET /export - CSV Export', () => { + let app: ReturnType; + let prismaMock: any; -describe('GET /api/analytics/export - CSV Export', () => { + 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 behavior: - // Request without valid JWT token in cookies - // app.authenticate hook intercepts it - // Returns 401 Unauthorized - expect(true).toBe(true); + // 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 behavior: - // Because the endpoint relies strictly on request.user.id from the auth context, - // users cannot pass another user's ID via URL. Attempting to access unauthorized - // routes naturally mitigates IDOR by strictly isolating data to the JWT owner. - expect(true).toBe(true); + // 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 () => { - // Expected behavior: - // Response Headers: - // - Content-Type: text/csv - // - Content-Disposition: attachment; filename="devcard-analytics.csv" - // - // Response Body matches format: - // date,platform,event_type,count - // 2026-03-12,devcard,view,1 - expect(true).toBe(true); + // 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'); }); }); \ No newline at end of file diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index c97ee623..8e9ac53f 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { Readable } from 'stream'; export async function analyticsRoutes(app: FastifyInstance) { @@ -99,41 +100,55 @@ export async function analyticsRoutes(app: FastifyInstance) { }; }); -// ─── Export Analytics CSV ─── +// ─── Export Analytics CSV (Optimized) ─── app.get('/export', { preHandler: [app.authenticate], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; - // Fetch raw views - const views = await app.prisma.cardView.findMany({ - where: { ownerId: userId }, - select: { createdAt: true, source: true }, - }); + // 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>` + 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 + }); - // Aggregation Object to group by date - const dailyStats: Record = {}; + // Push Header + csvStream.push('date,platform,event_type,count\n'); - views.forEach((view) => { - const date = view.createdAt.toISOString().split('T')[0]; - if (!dailyStats[date]) { - dailyStats[date] = 0; + // 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`); } - dailyStats[date]++; - }); - // Create CSV Header strictly as per Acceptance Criteria - let csvContent = 'date,platform,event_type,count\n'; + // End the stream + csvStream.push(null); - // Populate rows - for (const [date, count] of Object.entries(dailyStats)) { - csvContent += `${date},devcard,view,${count}\n`; - } + // Fastify automatically handles piping the stream to the response + return reply.send(csvStream); - // Set Headers - reply.header('Content-Type', 'text/csv'); - reply.header('Content-Disposition', 'attachment; filename="devcard-analytics.csv"'); - - return reply.send(csvContent); + } catch (error) { + request.log.error(error); + return reply.status(500).send({ error: 'Failed to generate CSV export' }); + } }); -} +} \ No newline at end of file