Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
597 changes: 597 additions & 0 deletions ANALYTICS_API_DOCUMENTATION.md

Large diffs are not rendered by default.

472 changes: 472 additions & 0 deletions ANALYTICS_INTEGRATION_GUIDE.md

Large diffs are not rendered by default.

447 changes: 447 additions & 0 deletions SNIPPET_ANALYTICS_IMPLEMENTATION.md

Large diffs are not rendered by default.

240 changes: 240 additions & 0 deletions app/api/analytics/analytics.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { describe, it, expect, beforeAll } from 'vitest';

/**
* Integration Tests for Analytics API Endpoints
* These tests assume the server is running
*/

describe('Analytics API Endpoints', () => {
const baseUrl = process.env.API_URL || 'http://localhost:3000';
const testSnippetId = 'test-snippet-' + Date.now();
const testUserWallet = 'GADDEBF2HLF7SA6YDVM6NSX3XARCEZXOT7Z4RY4YQJWQA37VBN2KA74';

describe('POST /api/snippets/[id]/analytics', () => {
it('should log a view action', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actionType: 'view',
userWallet: testUserWallet,
metadata: { referrer: 'search' },
}),
}
);

expect(response.status).toBe(201);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.event).toBeDefined();
expect(data.event.action_type).toBe('view');
});

it('should log a copy action', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actionType: 'copy',
metadata: { format: 'text' },
}),
}
);

expect(response.status).toBe(201);
const data = await response.json();
expect(data.event.action_type).toBe('copy');
});

it('should log a share action', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actionType: 'share',
userWallet: testUserWallet,
metadata: { method: 'link' },
}),
}
);

expect(response.status).toBe(201);
const data = await response.json();
expect(data.event.action_type).toBe('share');
});

it('should reject invalid action type', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actionType: 'invalid-action',
}),
}
);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBeDefined();
});

it('should reject missing action type', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}
);

expect(response.status).toBe(400);
});
});

describe('GET /api/snippets/[id]/analytics', () => {
beforeAll(async () => {
// Log some test events
await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType: 'view' }),
}
);
await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType: 'view' }),
}
);
await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType: 'copy' }),
}
);
});

it('should fetch analytics for a snippet', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`
);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.snippetId).toBe(testSnippetId);
expect(data.summary).toBeDefined();
expect(data.summary.views).toBeGreaterThanOrEqual(0);
expect(data.summary.copies).toBeGreaterThanOrEqual(0);
expect(data.summary.shares).toBeGreaterThanOrEqual(0);
});

it('should return recent events', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics`
);

expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.recentEvents)).toBe(true);
});

it('should support limit parameter', async () => {
const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics?limit=5`
);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.recentEvents.length).toBeLessThanOrEqual(5);
});

it('should support date range filtering', async () => {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);

const response = await fetch(
`${baseUrl}/api/snippets/${testSnippetId}/analytics?startDate=${yesterday.toISOString()}&endDate=${tomorrow.toISOString()}`
);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.recentEvents).toBeDefined();
});
});

describe('GET /api/analytics', () => {
it('should fetch global analytics summary', async () => {
const response = await fetch(`${baseUrl}/api/analytics?type=summary`);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.summary).toBeDefined();
expect(data.summary.totalViews).toBeGreaterThanOrEqual(0);
expect(data.summary.totalCopies).toBeGreaterThanOrEqual(0);
expect(data.summary.totalShares).toBeGreaterThanOrEqual(0);
});

it('should fetch top viewed snippets', async () => {
const response = await fetch(
`${baseUrl}/api/analytics?type=top-viewed&limit=5`
);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.type).toBe('top-viewed');
expect(Array.isArray(data.snippets)).toBe(true);
});

it('should fetch top copied snippets', async () => {
const response = await fetch(`${baseUrl}/api/analytics?type=top-copied`);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.type).toBe('top-copied');
expect(Array.isArray(data.snippets)).toBe(true);
});

it('should fetch top shared snippets', async () => {
const response = await fetch(`${baseUrl}/api/analytics?type=top-shared`);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.type).toBe('top-shared');
expect(Array.isArray(data.snippets)).toBe(true);
});

it('should reject invalid query type', async () => {
const response = await fetch(
`${baseUrl}/api/analytics?type=invalid-type`
);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBeDefined();
});

it('should support limit parameter', async () => {
const response = await fetch(`${baseUrl}/api/analytics?type=top-viewed&limit=3`);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.snippets.length).toBeLessThanOrEqual(3);
});
});
});
106 changes: 106 additions & 0 deletions app/api/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { analyticsRepository } from '@/lib/analytics.repository';

/**
* GET /api/analytics
* Fetch global analytics summary for dashboard
*
* Query parameters:
* - type: "summary" | "top-viewed" | "top-copied" | "top-shared" (default: "summary")
* - limit: number of results for top snippets (default: 10)
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const type = searchParams.get('type') || 'summary';
const limit = Math.min(
Math.max(parseInt(searchParams.get('limit') || '10', 10), 1),
100
);

switch (type) {
case 'summary': {
// Get overall summary
const globalCounts = await analyticsRepository.getGlobalActionCounts();

return NextResponse.json(
{
summary: {
totalViews: globalCounts.view,
totalCopies: globalCounts.copy,
totalShares: globalCounts.share,
totalActions:
globalCounts.view + globalCounts.copy + globalCounts.share,
},
},
{ status: 200 }
);
}

case 'top-viewed': {
const topSnippets = await analyticsRepository.getTopSnippets(
'view',
limit
);
return NextResponse.json(
{
type: 'top-viewed',
limit,
snippets: topSnippets,
},
{ status: 200 }
);
}

case 'top-copied': {
const topSnippets = await analyticsRepository.getTopSnippets(
'copy',
limit
);
return NextResponse.json(
{
type: 'top-copied',
limit,
snippets: topSnippets,
},
{ status: 200 }
);
}

case 'top-shared': {
const topSnippets = await analyticsRepository.getTopSnippets(
'share',
limit
);
return NextResponse.json(
{
type: 'top-shared',
limit,
snippets: topSnippets,
},
{ status: 200 }
);
}

default: {
return NextResponse.json(
{
error: 'Invalid query type',
message:
'type must be one of: summary, top-viewed, top-copied, top-shared',
},
{ status: 400 }
);
}
}
} catch (error) {
console.error('[Global Analytics API] Error fetching analytics:', error);
return NextResponse.json(
{
error: 'Failed to fetch analytics',
message: error instanceof Error ? error.message : 'Internal Server Error',
},
{ status: 500 }
);
}
}
Loading