feat: implement analytics endpoints (task 28)

- Created analytics service with summary, trends, comparison, and aggregated data methods
- Implemented 5 analytics endpoints: summary, trends, comparison, aggregated, and CSV export
- Added time series data with day/week/month interval support
- Implemented period-over-period comparison metrics with percentage changes
- Added CSV export functionality using json2csv
- Updated frontend analytics service to unwrap API responses
- Fixed chart components to work with new data structure (reportCount, eventCount, litterCollected)
- Updated TypeScript types to match backend API structure
- All endpoints tested and verified working

Requirements satisfied: 5.1, 5.2, 5.3, 5.4, 5.5
This commit is contained in:
2025-11-17 00:13:07 +00:00
parent ea08bf4289
commit d91a13da60
16 changed files with 1051 additions and 78 deletions

View File

@@ -0,0 +1,201 @@
import { Request, Response } from 'express';
import { analyticsService } from '../services/analyticsService';
export class AnalyticsController {
/**
* GET /api/analytics/summary
* Get overall statistics summary
*/
async getSummary(req: Request, res: Response) {
try {
const { startDate, endDate } = req.query;
let dateRange;
if (startDate && endDate) {
dateRange = {
startDate: new Date(startDate as string),
endDate: new Date(endDate as string),
};
}
const summary = await analyticsService.getSummary(dateRange);
res.json({
success: true,
data: summary,
});
} catch (error: any) {
console.error('Error fetching analytics summary:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: error.message || 'Failed to fetch analytics summary',
timestamp: new Date().toISOString(),
},
});
}
}
/**
* GET /api/analytics/trends
* Get time series data for trends
*/
async getTrends(req: Request, res: Response) {
try {
const { startDate, endDate, interval = 'day' } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'startDate and endDate are required',
timestamp: new Date().toISOString(),
},
});
}
const validIntervals = ['day', 'week', 'month'];
if (!validIntervals.includes(interval as string)) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'interval must be one of: day, week, month',
timestamp: new Date().toISOString(),
},
});
}
const timeSeries = await analyticsService.getTimeSeries(
new Date(startDate as string),
new Date(endDate as string),
interval as 'day' | 'week' | 'month'
);
res.json({
success: true,
data: timeSeries,
});
} catch (error: any) {
console.error('Error fetching trends:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: error.message || 'Failed to fetch trends data',
timestamp: new Date().toISOString(),
},
});
}
}
/**
* GET /api/analytics/comparison
* Get comparison metrics for period-over-period analysis
*/
async getComparison(req: Request, res: Response) {
try {
const { currentStart, currentEnd, previousStart, previousEnd } = req.query;
if (!currentStart || !currentEnd || !previousStart || !previousEnd) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'currentStart, currentEnd, previousStart, and previousEnd are required',
timestamp: new Date().toISOString(),
},
});
}
const comparison = await analyticsService.getComparisonMetrics(
new Date(currentStart as string),
new Date(currentEnd as string),
new Date(previousStart as string),
new Date(previousEnd as string)
);
res.json({
success: true,
data: comparison,
});
} catch (error: any) {
console.error('Error fetching comparison metrics:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: error.message || 'Failed to fetch comparison metrics',
timestamp: new Date().toISOString(),
},
});
}
}
/**
* GET /api/analytics/export
* Export analytics data to CSV
*/
async exportCSV(req: Request, res: Response) {
try {
const { startDate, endDate } = req.query;
let dateRange;
if (startDate && endDate) {
dateRange = {
startDate: new Date(startDate as string),
endDate: new Date(endDate as string),
};
}
const csv = await analyticsService.exportToCSV(dateRange);
// Set headers for CSV download
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="beach-litter-analytics-${new Date().toISOString().split('T')[0]}.csv"`);
res.send(csv);
} catch (error: any) {
console.error('Error exporting CSV:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: error.message || 'Failed to export CSV',
timestamp: new Date().toISOString(),
},
});
}
}
/**
* GET /api/analytics/aggregated
* Get aggregated litter type and quantity data
*/
async getAggregatedData(req: Request, res: Response) {
try {
const { startDate, endDate } = req.query;
let dateRange;
if (startDate && endDate) {
dateRange = {
startDate: new Date(startDate as string),
endDate: new Date(endDate as string),
};
}
const aggregatedData = await analyticsService.getAggregatedData(dateRange);
res.json({
success: true,
data: aggregatedData,
});
} catch (error: any) {
console.error('Error fetching aggregated data:', error);
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: error.message || 'Failed to fetch aggregated data',
timestamp: new Date().toISOString(),
},
});
}
}
}
export const analyticsController = new AnalyticsController();

View File

@@ -7,6 +7,7 @@ import reportRoutes from './routes/reportRoutes';
import eventRoutes from './routes/eventRoutes';
import userRoutes from './routes/userRoutes';
import externalDataRoutes from './routes/externalDataRoutes';
import analyticsRoutes from './routes/analyticsRoutes';
// Load environment variables
dotenv.config();
@@ -44,6 +45,7 @@ app.use('/api/reports', reportRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/users', userRoutes);
app.use('/api/external', externalDataRoutes);
app.use('/api/analytics', analyticsRoutes);
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {

View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import { analyticsController } from '../controllers/analyticsController';
const router = Router();
// Analytics endpoints
router.get('/summary', analyticsController.getSummary.bind(analyticsController));
router.get('/trends', analyticsController.getTrends.bind(analyticsController));
router.get('/comparison', analyticsController.getComparison.bind(analyticsController));
router.get('/export', analyticsController.exportCSV.bind(analyticsController));
router.get('/aggregated', analyticsController.getAggregatedData.bind(analyticsController));
export default router;

View File

@@ -0,0 +1,127 @@
import axios from 'axios';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3005/api';
async function testAnalyticsEndpoints() {
console.log('🧪 Testing Analytics Endpoints\n');
try {
// Test 1: Get summary statistics
console.log('1️⃣ Testing GET /api/analytics/summary');
const summaryResponse = await axios.get(`${API_BASE_URL}/analytics/summary`);
console.log('✅ Summary response:', JSON.stringify(summaryResponse.data, null, 2));
console.log('');
// Test 2: Get summary with date range
console.log('2️⃣ Testing GET /api/analytics/summary with date range');
const startDate = new Date('2024-01-01').toISOString();
const endDate = new Date().toISOString();
const summaryWithDateResponse = await axios.get(
`${API_BASE_URL}/analytics/summary?startDate=${startDate}&endDate=${endDate}`
);
console.log('✅ Summary with date range:', JSON.stringify(summaryWithDateResponse.data, null, 2));
console.log('');
// Test 3: Get time series trends
console.log('3️⃣ Testing GET /api/analytics/trends');
const trendsResponse = await axios.get(
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=day`
);
console.log('✅ Trends response (first 5 entries):',
JSON.stringify(trendsResponse.data.data.slice(0, 5), null, 2)
);
console.log(` Total data points: ${trendsResponse.data.data.length}`);
console.log('');
// Test 4: Get comparison metrics
console.log('4️⃣ Testing GET /api/analytics/comparison');
const currentStart = new Date('2024-11-01').toISOString();
const currentEnd = new Date('2024-11-30').toISOString();
const previousStart = new Date('2024-10-01').toISOString();
const previousEnd = new Date('2024-10-31').toISOString();
const comparisonResponse = await axios.get(
`${API_BASE_URL}/analytics/comparison?currentStart=${currentStart}&currentEnd=${currentEnd}&previousStart=${previousStart}&previousEnd=${previousEnd}`
);
console.log('✅ Comparison metrics:', JSON.stringify(comparisonResponse.data, null, 2));
console.log('');
// Test 5: Get aggregated data
console.log('5️⃣ Testing GET /api/analytics/aggregated');
const aggregatedResponse = await axios.get(`${API_BASE_URL}/analytics/aggregated`);
console.log('✅ Aggregated data:', JSON.stringify(aggregatedResponse.data, null, 2));
console.log('');
// Test 6: Export CSV
console.log('6️⃣ Testing GET /api/analytics/export (CSV)');
const csvResponse = await axios.get(`${API_BASE_URL}/analytics/export`, {
responseType: 'text',
});
console.log('✅ CSV export successful');
console.log(' First 500 characters of CSV:');
console.log(csvResponse.data.substring(0, 500));
console.log(' ...');
console.log('');
// Test 7: Test trends with different intervals
console.log('7️⃣ Testing trends with week interval');
const weekTrendsResponse = await axios.get(
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=week`
);
console.log('✅ Week trends response:',
`${weekTrendsResponse.data.data.length} data points`
);
console.log('');
console.log('8️⃣ Testing trends with month interval');
const monthTrendsResponse = await axios.get(
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=month`
);
console.log('✅ Month trends response:',
`${monthTrendsResponse.data.data.length} data points`
);
console.log('');
// Test 9: Error handling - missing required parameters
console.log('9️⃣ Testing error handling - trends without dates');
try {
await axios.get(`${API_BASE_URL}/analytics/trends`);
console.log('❌ Should have thrown an error');
} catch (error: any) {
if (error.response?.status === 400) {
console.log('✅ Correctly returned 400 error:', error.response.data.error.message);
} else {
console.log('❌ Unexpected error:', error.message);
}
}
console.log('');
// Test 10: Error handling - invalid interval
console.log('🔟 Testing error handling - invalid interval');
try {
await axios.get(
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=invalid`
);
console.log('❌ Should have thrown an error');
} catch (error: any) {
if (error.response?.status === 400) {
console.log('✅ Correctly returned 400 error:', error.response.data.error.message);
} else {
console.log('❌ Unexpected error:', error.message);
}
}
console.log('');
console.log('✅ All analytics endpoint tests completed successfully!');
} catch (error: any) {
console.error('❌ Test failed:', error.message);
if (error.response) {
console.error('Response data:', error.response.data);
console.error('Response status:', error.response.status);
}
process.exit(1);
}
}
// Run tests
testAnalyticsEndpoints();

View File

@@ -0,0 +1,530 @@
import prisma from '../config/database';
import { Parser } from 'json2csv';
interface DateRange {
startDate: Date;
endDate: Date;
}
interface AnalyticsSummary {
totalReports: number;
totalEvents: number;
totalUsers: number;
totalLitterCollected: number;
reportsByType: Record<string, number>;
reportsByQuantity: Record<string, number>;
verifiedReports: number;
cleanedReports: number;
activeUsers: number;
upcomingEvents: number;
completedEvents: number;
}
interface TimeSeriesData {
date: string;
reportCount: number;
eventCount: number;
litterCollected: number;
}
interface ComparisonMetrics {
current: {
reports: number;
events: number;
users: number;
};
previous: {
reports: number;
events: number;
users: number;
};
percentageChange: {
reports: number;
events: number;
users: number;
};
}
export class AnalyticsService {
/**
* Get overall statistics summary
* Requirement 5.1: Display total count of litter reports aggregated by time period
*/
async getSummary(dateRange?: DateRange): Promise<AnalyticsSummary> {
const where: any = {};
if (dateRange) {
where.createdAt = {
gte: dateRange.startDate,
lte: dateRange.endDate,
};
}
// Total reports
const totalReports = await prisma.report.count({ where });
// Total events
const eventWhere: any = {};
if (dateRange) {
eventWhere.createdAt = {
gte: dateRange.startDate,
lte: dateRange.endDate,
};
}
const totalEvents = await prisma.event.count({ where: eventWhere });
// Total users
const userWhere: any = {};
if (dateRange) {
userWhere.createdAt = {
gte: dateRange.startDate,
lte: dateRange.endDate,
};
}
const totalUsers = await prisma.user.count({ where: userWhere });
// Reports by type (Requirement 5.2)
const reportsByTypeRaw = await prisma.report.groupBy({
by: ['litterType'],
where,
_count: {
id: true,
},
});
const reportsByType: Record<string, number> = {};
reportsByTypeRaw.forEach((item) => {
reportsByType[item.litterType] = item._count.id;
});
// Reports by quantity (Requirement 5.3)
const reportsByQuantityRaw = await prisma.report.groupBy({
by: ['quantity'],
where,
_count: {
id: true,
},
});
const reportsByQuantity: Record<string, number> = {};
reportsByQuantityRaw.forEach((item) => {
reportsByQuantity[item.quantity] = item._count.id;
});
// Verified reports
const verifiedReports = await prisma.report.count({
where: {
...where,
verificationStatus: 'verified',
},
});
// Cleaned reports
const cleanedReports = await prisma.report.count({
where: {
...where,
cleanedAt: { not: null },
},
});
// Active users (users who submitted reports or registered for events)
const activeUsersWithReports = await prisma.report.findMany({
where,
select: { userId: true },
distinct: ['userId'],
});
const activeUsersWithEvents = await prisma.eventRegistration.findMany({
where: dateRange ? {
registeredAt: {
gte: dateRange.startDate,
lte: dateRange.endDate,
},
} : {},
select: { userId: true },
distinct: ['userId'],
});
const uniqueActiveUsers = new Set([
...activeUsersWithReports.map(r => r.userId),
...activeUsersWithEvents.map(e => e.userId),
]);
const activeUsers = uniqueActiveUsers.size;
// Upcoming and completed events
const upcomingEvents = await prisma.event.count({
where: {
...eventWhere,
status: 'upcoming',
},
});
const completedEvents = await prisma.event.count({
where: {
...eventWhere,
status: 'completed',
},
});
// Total litter collected from completed events
const litterCollectedResult = await prisma.event.aggregate({
where: {
...eventWhere,
status: 'completed',
litterCollected: { not: null },
},
_sum: {
litterCollected: true,
},
});
const totalLitterCollected = litterCollectedResult._sum.litterCollected
? parseFloat(litterCollectedResult._sum.litterCollected.toString())
: 0;
return {
totalReports,
totalEvents,
totalUsers,
totalLitterCollected,
reportsByType,
reportsByQuantity,
verifiedReports,
cleanedReports,
activeUsers,
upcomingEvents,
completedEvents,
};
}
/**
* Get time series data for trends
* Requirement 5.4: Show trends comparing current period data to previous periods
*/
async getTimeSeries(
startDate: Date,
endDate: Date,
interval: 'day' | 'week' | 'month' = 'day'
): Promise<TimeSeriesData[]> {
// Determine date format based on interval
let dateFormat: string;
switch (interval) {
case 'day':
dateFormat = '%Y-%m-%d';
break;
case 'week':
dateFormat = '%Y-%u'; // Year-Week
break;
case 'month':
dateFormat = '%Y-%m';
break;
}
// Get report counts by date
const reportData = await prisma.$queryRaw<Array<{ date: string; count: bigint }>>`
SELECT
DATE_FORMAT(created_at, ${dateFormat}) as date,
COUNT(*) as count
FROM reports
WHERE created_at >= ${startDate} AND created_at <= ${endDate}
GROUP BY date
ORDER BY date ASC
`;
// Get event counts by date
const eventData = await prisma.$queryRaw<Array<{ date: string; count: bigint }>>`
SELECT
DATE_FORMAT(scheduled_date, ${dateFormat}) as date,
COUNT(*) as count
FROM events
WHERE scheduled_date >= ${startDate} AND scheduled_date <= ${endDate}
GROUP BY date
ORDER BY date ASC
`;
// Get litter collected by date
const litterData = await prisma.$queryRaw<Array<{ date: string; total: string | null }>>`
SELECT
DATE_FORMAT(scheduled_date, ${dateFormat}) as date,
SUM(litter_collected) as total
FROM events
WHERE scheduled_date >= ${startDate}
AND scheduled_date <= ${endDate}
AND status = 'completed'
AND litter_collected IS NOT NULL
GROUP BY date
ORDER BY date ASC
`;
// Merge data by date
const dateMap = new Map<string, TimeSeriesData>();
reportData.forEach((item) => {
dateMap.set(item.date, {
date: item.date,
reportCount: Number(item.count),
eventCount: 0,
litterCollected: 0,
});
});
eventData.forEach((item) => {
const existing = dateMap.get(item.date);
if (existing) {
existing.eventCount = Number(item.count);
} else {
dateMap.set(item.date, {
date: item.date,
reportCount: 0,
eventCount: Number(item.count),
litterCollected: 0,
});
}
});
litterData.forEach((item) => {
const existing = dateMap.get(item.date);
const litterAmount = item.total ? parseFloat(item.total) : 0;
if (existing) {
existing.litterCollected = litterAmount;
} else {
dateMap.set(item.date, {
date: item.date,
reportCount: 0,
eventCount: 0,
litterCollected: litterAmount,
});
}
});
return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Get comparison metrics for period-over-period analysis
* Requirement 5.4: Show trends comparing current period data to previous periods with percentage change indicators
*/
async getComparisonMetrics(
currentStart: Date,
currentEnd: Date,
previousStart: Date,
previousEnd: Date
): Promise<ComparisonMetrics> {
// Current period metrics
const currentReports = await prisma.report.count({
where: {
createdAt: {
gte: currentStart,
lte: currentEnd,
},
},
});
const currentEvents = await prisma.event.count({
where: {
createdAt: {
gte: currentStart,
lte: currentEnd,
},
},
});
const currentUsers = await prisma.user.count({
where: {
createdAt: {
gte: currentStart,
lte: currentEnd,
},
},
});
// Previous period metrics
const previousReports = await prisma.report.count({
where: {
createdAt: {
gte: previousStart,
lte: previousEnd,
},
},
});
const previousEvents = await prisma.event.count({
where: {
createdAt: {
gte: previousStart,
lte: previousEnd,
},
},
});
const previousUsers = await prisma.user.count({
where: {
createdAt: {
gte: previousStart,
lte: previousEnd,
},
},
});
// Calculate percentage changes
const calculatePercentageChange = (current: number, previous: number): number => {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
};
return {
current: {
reports: currentReports,
events: currentEvents,
users: currentUsers,
},
previous: {
reports: previousReports,
events: previousEvents,
users: previousUsers,
},
percentageChange: {
reports: calculatePercentageChange(currentReports, previousReports),
events: calculatePercentageChange(currentEvents, previousEvents),
users: calculatePercentageChange(currentUsers, previousUsers),
},
};
}
/**
* Export analytics data to CSV
* Requirement 5.5: Allow Users to export analytics data in CSV format for external analysis
*/
async exportToCSV(dateRange?: DateRange): Promise<string> {
const where: any = {};
if (dateRange) {
where.createdAt = {
gte: dateRange.startDate,
lte: dateRange.endDate,
};
}
// Fetch all reports with relevant data
const reports = await prisma.report.findMany({
where,
include: {
user: {
select: {
username: true,
email: true,
},
},
environmentalConcerns: true,
verifications: true,
},
orderBy: {
createdAt: 'desc',
},
});
// Transform data for CSV export
const csvData = reports.map((report) => ({
id: report.id,
submittedAt: report.submittedAt?.toISOString() || '',
username: report.user.username,
latitude: Number(report.latitude),
longitude: Number(report.longitude),
locationSource: report.locationSource,
litterType: report.litterType,
quantity: report.quantity,
description: report.description || '',
verificationStatus: report.verificationStatus,
verificationCount: report.verifications.filter(v => v.verificationType === 'verify').length,
disputeCount: report.verifications.filter(v => v.verificationType === 'dispute').length,
cleanedAt: report.cleanedAt?.toISOString() || '',
photoCount: Array.isArray(report.photoUrls) ? report.photoUrls.length : 0,
environmentalConcernCount: report.environmentalConcerns.length,
environmentalConcernTypes: report.environmentalConcerns.map(c => c.concernType).join('; '),
}));
// Convert to CSV
const parser = new Parser({
fields: [
'id',
'submittedAt',
'username',
'latitude',
'longitude',
'locationSource',
'litterType',
'quantity',
'description',
'verificationStatus',
'verificationCount',
'disputeCount',
'cleanedAt',
'photoCount',
'environmentalConcernCount',
'environmentalConcernTypes',
],
});
return parser.parse(csvData);
}
/**
* Get aggregated litter type and quantity data
* Requirement 5.2: Generate charts showing litter distribution by category type
* Requirement 5.3: Calculate and display the total estimated quantity of litter reported
*/
async getAggregatedData(dateRange?: DateRange) {
const where: any = {};
if (dateRange) {
where.createdAt = {
gte: dateRange.startDate,
lte: dateRange.endDate,
};
}
// Litter type distribution
const litterTypeData = await prisma.report.groupBy({
by: ['litterType'],
where,
_count: {
id: true,
},
});
// Quantity distribution
const quantityData = await prisma.report.groupBy({
by: ['quantity'],
where,
_count: {
id: true,
},
});
// Verification status distribution
const verificationData = await prisma.report.groupBy({
by: ['verificationStatus'],
where,
_count: {
id: true,
},
});
return {
litterTypes: litterTypeData.map(item => ({
type: item.litterType,
count: item._count.id,
})),
quantities: quantityData.map(item => ({
level: item.quantity,
count: item._count.id,
})),
verificationStatus: verificationData.map(item => ({
status: item.verificationStatus || 'pending',
count: item._count.id,
})),
};
}
}
export const analyticsService = new AnalyticsService();