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
10 changes: 10 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ const config: Config = {
'!src/**/*.test.ts',
'!src/generated/**',
],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
strict: true,
esModuleInterop: true,
skipLibCheck: true,
types: ['jest', 'node'],
},
}],
},
};

export default config;
14 changes: 14 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,21 @@ interface JwtPayload {
role: string;
}

const ANALYTICS_API_KEY = process.env.ANALYTICS_API_KEY;

export function authenticate(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
// Support both header and query param for API key authentication
const apiKey = req.headers['x-api-key'] as string || req.query.api_key as string;

if (apiKey && ANALYTICS_API_KEY && apiKey === ANALYTICS_API_KEY) {
req.user = {
id: 'api-key-user',
email: 'api@teamforge.dev',
role: 'ADMIN',
};
return next();
}

const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
Expand Down
118 changes: 118 additions & 0 deletions src/modules/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Response, NextFunction } from 'express';
import * as analyticsService from './analytics.service';
import * as exportService from './export.service';
import { AuthenticatedRequest } from '../../shared/types';

export async function getVelocity(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { projectId, startDate, endDate } = req.query;
const result = await analyticsService.calculateVelocity(
projectId as string,
startDate as string | undefined,
endDate as string | undefined,
);
res.json(result);
} catch (err) {
next(err);
}
}

export async function getChangeFailureRate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { projectId, startDate, endDate } = req.query;
const result = await analyticsService.calculateChangeFailureRate(
projectId as string,
startDate as string | undefined,
endDate as string | undefined,
);
res.json(result);
} catch (err) {
next(err);
}
}

export async function getProjectHealth(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const result = await analyticsService.calculateProjectHealth(req.params.projectId as string);
res.json(result);
} catch (err) {
next(err);
}
}

export async function getContributions(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { teamId, startDate, endDate } = req.query;
const result = await analyticsService.calculateContributions(
teamId as string,
startDate as string | undefined,
endDate as string | undefined,
);
res.json(result);
} catch (err) {
next(err);
}
}

export async function getDateRangeMetrics(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { projectId, startDate, endDate } = req.query;
const result = await analyticsService.getMetricsForDateRange(
projectId as string,
startDate as string,
endDate as string,
);
res.json(result);
} catch (err) {
next(err);
}
}

export async function exportReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { reportType, format, callbackUrl } = req.body;
const { projectId, startDate, endDate } = req.query;

let data: any;
switch (reportType) {
case 'velocity':
data = await analyticsService.calculateVelocity(
projectId as string,
startDate as string | undefined,
endDate as string | undefined,
);
break;
case 'health':
data = await analyticsService.calculateProjectHealth(projectId as string);
break;
case 'change-failure':
data = await analyticsService.calculateChangeFailureRate(
projectId as string,
startDate as string | undefined,
endDate as string | undefined,
);
break;
default:
res.status(400).json({ error: { message: `Unknown report type: ${reportType}` } });
return;
}

if (format === 'csv') {
const headers = Object.keys(data);
const csvData = exportService.generateCSV(headers, [data]);

if (callbackUrl) {
await exportService.sendExportCallback(callbackUrl, csvData);
res.json({ success: true, message: 'Report sent to callback URL' });
} else {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename=${reportType}-report.csv`);
res.send(csvData);
}
} else {
res.json(data);
}
} catch (err) {
next(err);
}
}
21 changes: 12 additions & 9 deletions src/modules/analytics/analytics.routes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { Router } from 'express';
import * as analyticsController from './analytics.controller';
import { authenticate } from '../../middleware/auth';
import { authorize } from '../../middleware/authorize';
import { validateRequest } from '../../middleware/validateRequest';
import { exportSchema } from './analytics.schema';

const router = Router();

// TODO: Implement analytics endpoints
// - GET /velocity Team velocity metrics (deployments per week)
// - GET /health Project health scores
// - GET /contributions Team member contribution reports
// - GET /change-failure Change failure rate (DORA metric)
// - GET /export CSV export for reports
// Analytics endpoints — admin/manager only
router.get('/velocity', authenticate, authorize('ADMIN', 'MANAGER'), analyticsController.getVelocity);
router.get('/change-failure', authenticate, authorize('ADMIN', 'MANAGER'), analyticsController.getChangeFailureRate);
router.get('/health/:projectId', authenticate, analyticsController.getProjectHealth);
router.get('/contributions', authenticate, authorize('ADMIN', 'MANAGER'), analyticsController.getContributions);
router.get('/metrics', authenticate, analyticsController.getDateRangeMetrics);

router.get('/', authenticate, (_req, res) => {
res.status(501).json({ error: { code: 'NOT_IMPLEMENTED', message: 'Analytics coming soon' } });
});
// Export
router.post('/export', authenticate, authorize('ADMIN', 'MANAGER'), validateRequest(exportSchema), analyticsController.exportReport);

export default router;
17 changes: 17 additions & 0 deletions src/modules/analytics/analytics.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from 'zod';

export const dateRangeSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
teamId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
});

export const exportSchema = z.object({
reportType: z.enum(['velocity', 'health', 'contributions', 'change-failure']),
format: z.enum(['csv', 'json']).default('csv'),
callbackUrl: z.string().url().optional(),
});

export type DateRangeInput = z.infer<typeof dateRangeSchema>;
export type ExportInput = z.infer<typeof exportSchema>;
Loading