From 6930c2b7e8cadfd12dae001a492bbcbc5e6571d9 Mon Sep 17 00:00:00 2001 From: Sarthak Doshi Date: Sun, 10 May 2026 23:31:29 +0530 Subject: [PATCH 1/2] feat: add GET /api/analytics/export endpoint for CSV download - 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 #27 --- .../src/__tests__/analytics-export.test.ts | 88 +++++++++++++++++++ apps/backend/src/routes/analytics.ts | 61 +++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 apps/backend/src/__tests__/analytics-export.test.ts diff --git a/apps/backend/src/__tests__/analytics-export.test.ts b/apps/backend/src/__tests__/analytics-export.test.ts new file mode 100644 index 0000000..cf937af --- /dev/null +++ b/apps/backend/src/__tests__/analytics-export.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import { analyticsRoutes } from '../routes/analytics.js'; + +// Minimal mock of app.prisma and app.authenticate +function buildApp(prismaOverrides: Record = {}, authenticateReject = false) { + const app = Fastify(); + + const defaultCardViewGroupBy = vi.fn(async () => []); + const defaultFollowGroupBy = vi.fn(async () => []); + + (app as any).prisma = { + cardView: { + count: vi.fn(async () => 0), + findMany: vi.fn(async () => []), + groupBy: prismaOverrides.cardViewGroupBy ?? defaultCardViewGroupBy, + }, + followLog: { + count: vi.fn(async () => 0), + groupBy: prismaOverrides.followGroupBy ?? defaultFollowGroupBy, + }, + }; + + (app as any).authenticate = async (request: any, reply: any) => { + if (authenticateReject) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + request.user = { id: 'user-1' }; + }; + + app.register(analyticsRoutes, { prefix: '/api/analytics' }); + return app; +} + +describe('GET /api/analytics/export', () => { + it('returns 401 when unauthenticated', async () => { + const app = buildApp({}, true); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.statusCode).toBe(401); + }); + + it('returns 403 when querying another user data via userId param', async () => { + const app = buildApp(); + await app.ready(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/export?userId=other-user', + }); + expect(res.statusCode).toBe(403); + }); + + it('returns CSV with correct headers and structure', async () => { + const date = new Date('2025-01-15T10:00:00Z'); + const app = buildApp({ + cardViewGroupBy: vi.fn(async () => [ + { createdAt: date, source: 'qr', _count: { id: 3 } }, + { createdAt: date, source: 'web', _count: { id: 2 } }, + ]), + followGroupBy: vi.fn(async () => [ + { createdAt: date, platform: 'github', _count: { id: 1 } }, + ]), + }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('text/csv'); + expect(res.headers['content-disposition']).toContain('attachment'); + expect(res.headers['content-disposition']).toContain('devcard-analytics.csv'); + + const lines = res.body.split('\n'); + expect(lines[0]).toBe('date,platform,event_type,count'); + // Check all data rows exist + const dataLines = lines.slice(1); + expect(dataLines).toContain('2025-01-15,qr,card_view,3'); + expect(dataLines).toContain('2025-01-15,web,card_view,2'); + expect(dataLines).toContain('2025-01-15,github,follow,1'); + }); + + it('returns only header row when user has no data', async () => { + const app = buildApp(); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.statusCode).toBe(200); + expect(res.body.trim()).toBe('date,platform,event_type,count'); + }); +}); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index e9a75bb..a416484 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -57,6 +57,67 @@ export async function analyticsRoutes(app: FastifyInstance) { }; }); + app.get('/export', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest<{ Querystring: { userId?: string } }>, reply: FastifyReply) => { + const requestingUserId = (request.user as any).id; + + // IDOR guard: if a userId query param is supplied and differs from the caller, reject. + if (request.query.userId && request.query.userId !== requestingUserId) { + return reply.status(403).send({ error: 'Forbidden' }); + } + + const userId = requestingUserId; + + // 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 = {}; + + 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; + } + + const rows = Object.values(aggregated).sort((a, b) => a.date.localeCompare(b.date)); + + const csvLines = ['date,platform,event_type,count']; + for (const r of rows) { + csvLines.push(`${r.date},${r.platform},${r.event_type},${r.count}`); + } + const csv = csvLines.join('\n'); + + reply + .header('Content-Type', 'text/csv') + .header('Content-Disposition', 'attachment; filename=devcard-analytics.csv') + .send(csv); + }); + app.get('/views', { preHandler: [app.authenticate], }, async (request: FastifyRequest<{ Querystring: { page?: string, cardId?: string } }>, reply: FastifyReply) => { From ebdf614918b5705c7f0a23e30270d7b3e25057a3 Mon Sep 17 00:00:00 2001 From: Sarthak Doshi Date: Mon, 11 May 2026 00:11:37 +0530 Subject: [PATCH 2/2] fix: replace groupBy-on-timestamp with findMany+day-aggregation, add CSV escaping, fix test mocks --- .../src/__tests__/analytics-export.test.ts | 39 ++++++++----- apps/backend/src/routes/analytics.ts | 55 +++++++++++-------- 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/apps/backend/src/__tests__/analytics-export.test.ts b/apps/backend/src/__tests__/analytics-export.test.ts index cf937af..a442167 100644 --- a/apps/backend/src/__tests__/analytics-export.test.ts +++ b/apps/backend/src/__tests__/analytics-export.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import Fastify from 'fastify'; import { analyticsRoutes } from '../routes/analytics.js'; @@ -6,18 +6,16 @@ import { analyticsRoutes } from '../routes/analytics.js'; function buildApp(prismaOverrides: Record = {}, authenticateReject = false) { const app = Fastify(); - const defaultCardViewGroupBy = vi.fn(async () => []); - const defaultFollowGroupBy = vi.fn(async () => []); - (app as any).prisma = { cardView: { count: vi.fn(async () => 0), - findMany: vi.fn(async () => []), - groupBy: prismaOverrides.cardViewGroupBy ?? defaultCardViewGroupBy, + findMany: prismaOverrides.cardViewFindMany ?? vi.fn(async () => []), + groupBy: vi.fn(async () => []), }, followLog: { count: vi.fn(async () => 0), - groupBy: prismaOverrides.followGroupBy ?? defaultFollowGroupBy, + findMany: prismaOverrides.followLogFindMany ?? vi.fn(async () => []), + groupBy: vi.fn(async () => []), }, }; @@ -53,12 +51,15 @@ describe('GET /api/analytics/export', () => { it('returns CSV with correct headers and structure', async () => { const date = new Date('2025-01-15T10:00:00Z'); const app = buildApp({ - cardViewGroupBy: vi.fn(async () => [ - { createdAt: date, source: 'qr', _count: { id: 3 } }, - { createdAt: date, source: 'web', _count: { id: 2 } }, + cardViewFindMany: vi.fn(async () => [ + { createdAt: date, source: 'qr' }, + { createdAt: date, source: 'qr' }, + { createdAt: date, source: 'qr' }, + { createdAt: date, source: 'web' }, + { createdAt: date, source: 'web' }, ]), - followGroupBy: vi.fn(async () => [ - { createdAt: date, platform: 'github', _count: { id: 1 } }, + followLogFindMany: vi.fn(async () => [ + { createdAt: date, platform: 'github' }, ]), }); await app.ready(); @@ -71,7 +72,6 @@ describe('GET /api/analytics/export', () => { const lines = res.body.split('\n'); expect(lines[0]).toBe('date,platform,event_type,count'); - // Check all data rows exist const dataLines = lines.slice(1); expect(dataLines).toContain('2025-01-15,qr,card_view,3'); expect(dataLines).toContain('2025-01-15,web,card_view,2'); @@ -85,4 +85,17 @@ describe('GET /api/analytics/export', () => { expect(res.statusCode).toBe(200); expect(res.body.trim()).toBe('date,platform,event_type,count'); }); + + it('applies RFC 4180 CSV escaping for values containing commas', async () => { + const date = new Date('2025-03-01T00:00:00Z'); + const app = buildApp({ + cardViewFindMany: vi.fn(async () => [ + { createdAt: date, source: 'web,mobile' }, + ]), + followLogFindMany: vi.fn(async () => []), + }); + await app.ready(); + const res = await app.inject({ method: 'GET', url: '/api/analytics/export' }); + expect(res.body).toContain('"web,mobile"'); + }); }); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index a416484..e22701a 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -69,46 +69,55 @@ export async function analyticsRoutes(app: FastifyInstance) { const userId = requestingUserId; - // 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 }, - }); + // Use findMany + in-code day-level aggregation instead of groupBy on + // createdAt (a full timestamp), which would produce one group per row. + const [cardViews, followLogs] = await Promise.all([ + app.prisma.cardView.findMany({ + where: { ownerId: userId }, + select: { createdAt: true, source: true }, + }), + app.prisma.followLog.findMany({ + where: { followerId: userId, status: 'success' }, + select: { createdAt: true, platform: true }, + }), + ]); - // Aggregate rows by date string + platform + event_type + // Aggregate by date (day) + platform + event_type const aggregated: Record = {}; - for (const row of cardViewGroups) { + for (const row of cardViews) { const date = row.createdAt.toISOString().slice(0, 10); - const key = `${date}|${row.source}|card_view`; + const platform = row.source ?? 'unknown'; + const key = `${date}|${platform}|card_view`; if (!aggregated[key]) { - aggregated[key] = { date, platform: row.source, event_type: 'card_view', count: 0 }; + aggregated[key] = { date, platform, event_type: 'card_view', count: 0 }; } - aggregated[key].count += row._count.id; + aggregated[key].count += 1; } - for (const row of followGroups) { + for (const row of followLogs) { const date = row.createdAt.toISOString().slice(0, 10); - const key = `${date}|${row.platform}|follow`; + const platform = row.platform ?? 'unknown'; + const key = `${date}|${platform}|follow`; if (!aggregated[key]) { - aggregated[key] = { date, platform: row.platform, event_type: 'follow', count: 0 }; + aggregated[key] = { date, platform, event_type: 'follow', count: 0 }; } - aggregated[key].count += row._count.id; + aggregated[key].count += 1; } const rows = Object.values(aggregated).sort((a, b) => a.date.localeCompare(b.date)); + // RFC 4180-compliant escaping: wrap cell in quotes if it contains comma, + // newline, or quote; double any embedded quotes. + function csvCell(value: string | number): string { + const s = String(value); + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; + } + const csvLines = ['date,platform,event_type,count']; for (const r of rows) { - csvLines.push(`${r.date},${r.platform},${r.event_type},${r.count}`); + csvLines.push([csvCell(r.date), csvCell(r.platform), csvCell(r.event_type), csvCell(r.count)].join(',')); } const csv = csvLines.join('\n');