diff --git a/frontend/web/src/app/admin/(protected)/dashboard/page.tsx b/frontend/web/src/app/admin/(protected)/dashboard/page.tsx index 94da31c..a07a9b3 100644 --- a/frontend/web/src/app/admin/(protected)/dashboard/page.tsx +++ b/frontend/web/src/app/admin/(protected)/dashboard/page.tsx @@ -1,11 +1,29 @@ +'use client'; + import ActivePromotions from '@/src/components/admin/dashboard/ActivePromotions'; import CustomerInsights from '@/src/components/admin/dashboard/CustomerInsights'; import QuickActionCard from '@/src/components/admin/dashboard/QuickActionCard'; import SalesChart from '@/src/components/admin/dashboard/SalesChart'; import StatCard from '@/src/components/admin/dashboard/StatCard'; +import { analyticsService } from '@/src/services/analytics.service'; +import { DashboardSummary } from '@/src/types/api/analytics.api'; import Link from 'next/link'; +import { useEffect, useState } from 'react'; export default function Home() { + const [stats, setStats] = useState(null); + + useEffect(() => { + const loadDashboard = async () => { + const data = await analyticsService.getDashboardSummary(); + setStats(data); + }; + + loadDashboard(); + }, []); + + if (!stats) return
Loading...
; + return (
@@ -52,39 +70,44 @@ export default function Home() {
+ + + {/* + value={`${stats.returningCustomers}%`} + trend={0} + trendLabel="Repeat buyers" + /> */} + +
diff --git a/frontend/web/src/components/admin/analytics/AnalyticsStats.tsx b/frontend/web/src/components/admin/analytics/AnalyticsStats.tsx index ea3afe8..b342c03 100644 --- a/frontend/web/src/components/admin/analytics/AnalyticsStats.tsx +++ b/frontend/web/src/components/admin/analytics/AnalyticsStats.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect, useState } from 'react'; import { DollarSign, ShoppingCart, @@ -9,42 +10,65 @@ import { ArrowDownRight, } from 'lucide-react'; -const stats = [ - { - title: 'Total Revenue', - value: '$54,239', - change: '+12.5%', - trend: 'up', - icon: DollarSign, - color: 'bg-emerald-100 text-emerald-600', - }, - { - title: 'Total Orders', - value: '1,253', - change: '+8.2%', - trend: 'up', - icon: ShoppingCart, - color: 'bg-blue-100 text-blue-600', - }, - { - title: 'Avg. Order Value', - value: '$43.28', - change: '-2.1%', - trend: 'down', - icon: CreditCard, - color: 'bg-purple-100 text-purple-600', - }, - { - title: 'Refund Rate', - value: '1.2%', - change: '+0.4%', - trend: 'down', // down is bad for refund rate usually, but visually down arrow red is consistent - icon: TrendingUp, // Maybe iterate on icon - color: 'bg-amber-100 text-amber-600', - }, -]; +import { analyticsService } from '@/src/services/analytics.service'; +import { OverviewStats, GrowthStats } from '@/src/types/api/analytics.api'; + +interface StatsState { + overview: OverviewStats; + growth: GrowthStats; +} export default function AnalyticsStats() { + const [data, setData] = useState(null); + + useEffect(() => { + const loadStats = async () => { + const overview = await analyticsService.getOverview(); + const growth = await analyticsService.getGrowthStats(); + + setData({ overview, growth }); + }; + + loadStats(); + }, []); + + if (!data) return null; + + const stats = [ + { + title: 'Total Revenue', + value: `₹${data.overview.revenue.toLocaleString()}`, + change: `${data.growth.revenueGrowth.toFixed(1)}%`, + trend: data.growth.revenueGrowth >= 0 ? 'up' : 'down', + icon: DollarSign, + color: 'bg-emerald-100 text-emerald-600', + }, + { + title: 'Total Orders', + value: data.overview.orders.toString(), + change: `${data.growth.orderGrowth.toFixed(1)}%`, + trend: data.growth.orderGrowth >= 0 ? 'up' : 'down', + icon: ShoppingCart, + color: 'bg-blue-100 text-blue-600', + }, + { + title: 'Avg. Order Value', + value: `₹${data.overview.avgOrderValue.toFixed(2)}`, + change: '—', + trend: 'up', + icon: CreditCard, + color: 'bg-purple-100 text-purple-600', + }, + { + title: 'Refund Rate', + value: `${data.overview.refundRate.toFixed(1)}%`, + change: '—', + trend: 'down', + icon: TrendingUp, + color: 'bg-amber-100 text-amber-600', + }, + ]; + return (
{stats.map((stat, index) => ( @@ -56,6 +80,7 @@ export default function AnalyticsStats() {
+
+

{stat.title}

+

{stat.value}

diff --git a/frontend/web/src/components/admin/analytics/RevenueChart.tsx b/frontend/web/src/components/admin/analytics/RevenueChart.tsx index f1f9ff2..bfc88e6 100644 --- a/frontend/web/src/components/admin/analytics/RevenueChart.tsx +++ b/frontend/web/src/components/admin/analytics/RevenueChart.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect, useState } from 'react'; import { AreaChart, Area, @@ -10,22 +11,36 @@ import { ResponsiveContainer, } from 'recharts'; -const data = [ - { name: 'Jan', revenue: 4000, profit: 2400 }, - { name: 'Feb', revenue: 3000, profit: 1398 }, - { name: 'Mar', revenue: 2000, profit: 9800 }, - { name: 'Apr', revenue: 2780, profit: 3908 }, - { name: 'May', revenue: 1890, profit: 4800 }, - { name: 'Jun', revenue: 2390, profit: 3800 }, - { name: 'Jul', revenue: 3490, profit: 4300 }, - { name: 'Aug', revenue: 4200, profit: 5100 }, - { name: 'Sep', revenue: 5100, profit: 5900 }, - { name: 'Oct', revenue: 6200, profit: 6500 }, - { name: 'Nov', revenue: 5800, profit: 5400 }, - { name: 'Dec', revenue: 7500, profit: 6800 }, -]; +import { analyticsService } from '@/src/services/analytics.service'; +import { RevenueChartPoint } from '@/src/types/api/analytics.api'; + +interface ChartPoint { + name: string; + revenue: number; + profit: number; +} export default function RevenueChart() { + const [data, setData] = useState([]); + + useEffect(() => { + const loadRevenue = async () => { + const res: RevenueChartPoint[] = + await analyticsService.getRevenueChart(365); + + const chartData: ChartPoint[] = res.map((item) => ({ + name: new Date(item.date).toLocaleDateString('en-US', { + month: 'short', + }), + revenue: item.revenue, + profit: Math.round(item.revenue * 0.3), // placeholder profit calc + })); + + setData(chartData); + }; + + loadRevenue(); + }, []); return (
diff --git a/frontend/web/src/components/admin/dashboard/SalesChart.tsx b/frontend/web/src/components/admin/dashboard/SalesChart.tsx index 88560b5..4dfa885 100644 --- a/frontend/web/src/components/admin/dashboard/SalesChart.tsx +++ b/frontend/web/src/components/admin/dashboard/SalesChart.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect, useState } from 'react'; import { BarChart, Bar, @@ -10,22 +11,34 @@ import { ResponsiveContainer, } from 'recharts'; -const data = [ - { name: 'Jan', sales: 2500 }, - { name: 'Feb', sales: 7500 }, - { name: 'Mar', sales: 6000 }, - { name: 'Apr', sales: 9000 }, - { name: 'May', sales: 7800 }, - { name: 'Jun', sales: 5500 }, - { name: 'Jul', sales: 3000 }, - { name: 'Aug', sales: 8500 }, - { name: 'Sep', sales: 7000 }, - { name: 'Oct', sales: 10000 }, - { name: 'Nov', sales: 6500 }, - { name: 'Dec', sales: 3500 }, -]; +import { analyticsService } from '@/src/services/analytics.service'; +import { RevenueChartPoint } from '@/src/types/api/analytics.api'; + +interface ChartPoint { + name: string; + sales: number; +} export default function SalesChart() { + const [data, setData] = useState([]); + + useEffect(() => { + const loadRevenue = async () => { + const res: RevenueChartPoint[] = + await analyticsService.getRevenueChart(30); + + const chartData: ChartPoint[] = res.map((item) => ({ + name: new Date(item.date).toLocaleDateString('en-US', { + month: 'short', + }), + sales: item.revenue, + })); + + setData(chartData); + }; + + loadRevenue(); + }, []); return (
diff --git a/frontend/web/src/services/analytics.service.ts b/frontend/web/src/services/analytics.service.ts new file mode 100644 index 0000000..ef0a8d1 --- /dev/null +++ b/frontend/web/src/services/analytics.service.ts @@ -0,0 +1,86 @@ +import adminApi from '../lib/axios.admin'; +import userApi from '../lib/axios.user'; + +import { + DashboardSummary, + OverviewStats, + RevenueChartPoint, + CartStats, + FunnelStats, + GrowthStats, + ProductStats, +} from '../types/api/analytics.api'; + +export const analyticsService = { + // Dashboard summary + getDashboardSummary: async (): Promise => { + const res = await adminApi.get('/analytics/dashboard'); + return res.data; + }, + + // Overview stats + getOverview: async (): Promise => { + const res = await adminApi.get('/analytics/overview'); + return res.data; + }, + + // Revenue chart + getRevenueChart: async (range = 30): Promise => { + const res = await adminApi.get('/analytics/revenue', { + params: { range }, + }); + return res.data; + }, + + // Cart analytics + getCartStats: async (): Promise => { + const res = await adminApi.get('/analytics/cart'); + return res.data; + }, + + // Funnel analytics + getFunnelStats: async (): Promise => { + const res = await adminApi.get('/analytics/funnel'); + return res.data; + }, + + // Growth analytics + getGrowthStats: async (): Promise => { + const res = await adminApi.get('/analytics/growth'); + return res.data; + }, + + // Top selling products + getTopProducts: async (limit = 5): Promise => { + const res = await adminApi.get('/analytics/products/top', { + params: { limit }, + }); + return res.data; + }, + + // Most viewed products + getMostViewedProducts: async (limit = 5): Promise => { + const res = await adminApi.get('/analytics/products/views', { + params: { limit }, + }); + return res.data; + }, + + // Highest revenue products + getTopRevenueProducts: async (limit = 5): Promise => { + const res = await adminApi.get('/analytics/products/revenue', { + params: { limit }, + }); + return res.data; + }, + + // Product view tracking + trackProductView: async ( + productId: string, + ): Promise<{ success: boolean }> => { + const res = await userApi.post('/analytics/product-view', { + productId, + }); + return res.data; + }, +}; diff --git a/frontend/web/src/types/api/analytics.api.ts b/frontend/web/src/types/api/analytics.api.ts new file mode 100644 index 0000000..b341d8f --- /dev/null +++ b/frontend/web/src/types/api/analytics.api.ts @@ -0,0 +1,47 @@ +export interface DashboardSummary { + revenue: number; + orders: number; + avgOrderValue: number; + refundRate: number; + cartAbandonment: number; + productViews: number; + returningCustomers: number; +} + +export interface OverviewStats { + revenue: number; + orders: number; + avgOrderValue: number; + refundRate: number; +} + +export interface RevenueChartPoint { + date: string; + revenue: number; + orders: number; +} + +export interface CartStats { + cartsCreated: number; + cartsConverted: number; + abandonmentRate: number; +} + +export interface FunnelStats { + views: number; + carts: number; + checkout: number; + orders: number; +} + +export interface GrowthStats { + revenueGrowth: number; + orderGrowth: number; +} + +export interface ProductStats { + productId: string; + sold: number; + revenue: number; + views: number; +} diff --git a/gateway/src/routes/checkout-service-routes/analytics.route.ts b/gateway/src/routes/checkout-service-routes/analytics.route.ts new file mode 100644 index 0000000..c20c41d --- /dev/null +++ b/gateway/src/routes/checkout-service-routes/analytics.route.ts @@ -0,0 +1,77 @@ +import { Router, Request } from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { verifyToken } from '../../middleware/auth.middleware.js'; +import { requireRole } from '../../middleware/requireRole.middleware.js'; +import { ROLES } from '../../auth/roles.js'; + +const route = Router(); + +const analyticsProxy = createProxyMiddleware({ + target: process.env.CHECKOUT_SERVICE_URL, + changeOrigin: true, + + pathRewrite: (path) => { + return `/api/v1/analytics${path === '/' ? '' : path}`; + }, + + on: { + proxyReq(proxyReq, req: Request) { + if (req.user) { + proxyReq.setHeader('x-user-id', req.user.sub); + proxyReq.setHeader('x-user-role', req.user.role); + proxyReq.setHeader('x-user-type', req.user.type); + } + }, + }, +}); + +/* + ADMIN ANALYTICS ROUTES +*/ + +route.get( + '/dashboard', + verifyToken, + requireRole([ROLES.ADMIN]), + analyticsProxy, +); + +route.get('/overview', verifyToken, requireRole([ROLES.ADMIN]), analyticsProxy); + +route.get('/revenue', verifyToken, requireRole([ROLES.ADMIN]), analyticsProxy); + +route.get('/cart', verifyToken, requireRole([ROLES.ADMIN]), analyticsProxy); + +route.get( + '/products/top', + verifyToken, + requireRole([ROLES.ADMIN]), + analyticsProxy, +); + +route.get( + '/products/views', + verifyToken, + requireRole([ROLES.ADMIN]), + analyticsProxy, +); + +route.get( + '/products/revenue', + verifyToken, + requireRole([ROLES.ADMIN]), + analyticsProxy, +); + +route.get('/funnel', verifyToken, requireRole([ROLES.ADMIN]), analyticsProxy); + +route.get('/growth', verifyToken, requireRole([ROLES.ADMIN]), analyticsProxy); + +/* + PUBLIC ANALYTICS ROUTE + (product view tracking) +*/ + +route.post('/product-view', analyticsProxy); + +export default route; diff --git a/gateway/src/routes/index.ts b/gateway/src/routes/index.ts index 3c0a545..9500dc2 100644 --- a/gateway/src/routes/index.ts +++ b/gateway/src/routes/index.ts @@ -15,6 +15,7 @@ import searchRoutes from './product-service-routes/search.routes.js'; import wishlistRoutes from './product-service-routes/wishlist.route.js'; import adminUserRoutes from './user-service-routes/admin.user.routes.js'; import reviewRoutes from './user-service-routes/review.route.js'; +import analyticsRoute from './checkout-service-routes/analytics.route.js'; const route = Router(); @@ -39,4 +40,5 @@ route.use('/wishlist', wishlistRoutes); route.use('/cart', cartRoute); route.use('/checkout', checkoutRoute); route.use('/order', orderRoute); +route.use('/analytics', analyticsRoute); export default route; diff --git a/services/checkout-service/src/analytics/analytics.controller.ts b/services/checkout-service/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..e311afd --- /dev/null +++ b/services/checkout-service/src/analytics/analytics.controller.ts @@ -0,0 +1,114 @@ +import { Request, Response } from 'express'; +import { AnalyticsService } from './analytics.service.js'; + +export class AnalyticsController { + constructor(private analyticsService: AnalyticsService) {} + + getOverview = async (req: Request, res: Response) => { + try { + const data = await this.analyticsService.getOverview(); + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch overview' }); + } + }; + + getRevenueChart = async (req: Request, res: Response) => { + try { + const days = Number(req.query.range) || 30; + const data = await this.analyticsService.getRevenueChart(days); + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch revenue chart' }); + } + }; + + getCartStats = async (req: Request, res: Response) => { + try { + const data = await this.analyticsService.getCartStats(); + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch cart analytics' }); + } + }; + + // TOP SELLING PRODUCTS + getTopProducts = async (req: Request, res: Response) => { + try { + const limit = Number(req.query.limit) || 5; + const data = await this.analyticsService.getTopProducts(limit); + + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch top products' }); + } + }; + + // MOST VIEWED PRODUCTS + getMostViewedProducts = async (req: Request, res: Response) => { + try { + const limit = Number(req.query.limit) || 5; + const data = await this.analyticsService.getMostViewedProducts(limit); + + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch viewed products' }); + } + }; + + // HIGHEST REVENUE PRODUCTS + getTopRevenueProducts = async (req: Request, res: Response) => { + try { + const limit = Number(req.query.limit) || 5; + const data = await this.analyticsService.getTopRevenueProducts(limit); + + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch revenue products' }); + } + }; + + trackProductView = async (req: Request, res: Response) => { + try { + const { productId } = req.body; + await this.analyticsService.trackProductView(productId); + + res.json({ success: true }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to set views' }); + } + }; + + getFunnelStats = async (req: Request, res: Response) => { + try { + const data = await this.analyticsService.getFunnelStats(); + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch funnel stats' }); + } + }; + + getDashboardSummary = async (req: Request, res: Response) => { + try { + const data = await this.analyticsService.getDashboardSummary(); + res.json(data); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to fetch dashboard summary' }); + } + }; + + getGrowthStats = async (req: Request, res: Response) => { + const data = await this.analyticsService.getGrowthStats(); + + res.json(data); + }; +} diff --git a/services/checkout-service/src/analytics/analytics.routes.ts b/services/checkout-service/src/analytics/analytics.routes.ts new file mode 100644 index 0000000..718ce4c --- /dev/null +++ b/services/checkout-service/src/analytics/analytics.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { AnalyticsService } from './analytics.service.js'; +import { AnalyticsController } from './analytics.controller.js'; + +const router = Router(); + +const analyticsService = new AnalyticsService(); +const controller = new AnalyticsController(analyticsService); + +router.get('/overview', controller.getOverview); +router.get('/revenue', controller.getRevenueChart); +router.get('/cart', controller.getCartStats); +router.get('/products/top', controller.getTopProducts); +router.get('/products/views', controller.getMostViewedProducts); +router.get('/products/revenue', controller.getTopRevenueProducts); +router.post('/product-view', controller.trackProductView); +router.get('/funnel', controller.getFunnelStats); +router.get('/dashboard', controller.getDashboardSummary); +router.get('/growth', controller.getGrowthStats); + +export default router; diff --git a/services/checkout-service/src/analytics/analytics.service.ts b/services/checkout-service/src/analytics/analytics.service.ts new file mode 100644 index 0000000..11df4a2 --- /dev/null +++ b/services/checkout-service/src/analytics/analytics.service.ts @@ -0,0 +1,352 @@ +import { Order } from '../domain/order.type.js'; +import { CartStats } from './models/cartStats.model.js'; +import { FunnelStats } from './models/funnelStats.model.js'; +import { ProductStats } from './models/productStats.model.js'; +import { SalesDaily } from './models/salesDaily.model.js'; + +export class AnalyticsService { + //ORDER CREATED + + async trackOrder(order: Order): Promise { + const date = new Date(order.createdAt).toISOString().split('T')[0]; + + await SalesDaily.updateOne( + { date }, + { + $inc: { + revenue: order.totalAmount, + orders: 1, + }, + }, + { upsert: true }, + ); + + for (const item of order.items) { + await ProductStats.updateOne( + { productId: item.productId }, + { + $inc: { + sold: item.quantity, + revenue: item.subtotal, + }, + }, + { upsert: true }, + ); + } + + await CartStats.updateOne( + { date }, + { + $inc: { cartsConverted: 1 }, + }, + { upsert: true }, + ); + + await FunnelStats.updateOne( + { date }, + { $inc: { ordersCompleted: 1 } }, + { upsert: true }, + ); + } + + //REFUND CREATED + + async trackRefund(amount: number, createdAt: string): Promise { + const date = new Date(createdAt).toISOString().split('T')[0]; + + await SalesDaily.updateOne( + { date }, + { + $inc: { + refunds: 1, + revenue: -amount, + }, + }, + ); + } + + //CART CREATED + + async trackCartCreated(): Promise { + const date = new Date().toISOString().split('T')[0]; + + await CartStats.updateOne( + { date }, + { + $inc: { + cartsCreated: 1, + }, + }, + { upsert: true }, + ); + } + + //PRODUCT VIEW + + async trackProductView(productId: string): Promise { + await ProductStats.updateOne( + { productId }, + { + $inc: { + views: 1, + }, + }, + { upsert: true }, + ); + + const date = new Date().toISOString().split('T')[0]; + + await FunnelStats.updateOne( + { date }, + { $inc: { productViews: 1 } }, + { upsert: true }, + ); + } + + //DASHBOARD OVERVIEW + + async getOverview(days = 30) { + const start = new Date(); + start.setDate(start.getDate() - days); + + const stats = await SalesDaily.aggregate([ + { + $match: { + date: { $gte: start.toISOString().split('T')[0] }, + }, + }, + { + $group: { + _id: null, + revenue: { $sum: '$revenue' }, + orders: { $sum: '$orders' }, + refunds: { $sum: '$refunds' }, + }, + }, + ]); + + const data = stats[0] ?? { + revenue: 0, + orders: 0, + refunds: 0, + }; + + const avgOrderValue = data.orders > 0 ? data.revenue / data.orders : 0; + + const refundRate = data.orders > 0 ? (data.refunds / data.orders) * 100 : 0; + + return { + revenue: data.revenue, + orders: data.orders, + avgOrderValue, + refundRate, + }; + } + //REVENUE CHART + + async getRevenueChart(days = 30) { + const start = new Date(); + start.setDate(start.getDate() - days); + + return SalesDaily.find({ + date: { $gte: start.toISOString().split('T')[0] }, + }).sort({ date: 1 }); + } + + // CART ANALYTICS + + async getCartStats() { + const stats = await CartStats.aggregate([ + { + $group: { + _id: null, + cartsCreated: { $sum: '$cartsCreated' }, + cartsConverted: { $sum: '$cartsConverted' }, + }, + }, + ]); + + const data = stats[0] ?? { + cartsCreated: 0, + cartsConverted: 0, + }; + + const abandonmentRate = + data.cartsCreated > 0 + ? ((data.cartsCreated - data.cartsConverted) / data.cartsCreated) * 100 + : 0; + + return { + cartsCreated: data.cartsCreated, + cartsConverted: data.cartsConverted, + abandonmentRate, + }; + } + + async getTopProducts(limit = 5) { + return ProductStats.find().sort({ sold: -1 }).limit(limit); + } + + async getMostViewedProducts(limit = 5) { + return ProductStats.find().sort({ views: -1 }).limit(limit); + } + + async getTopRevenueProducts(limit = 5) { + return ProductStats.find().sort({ revenue: -1 }).limit(limit); + } + + async getProductOverview() { + const stats = await ProductStats.aggregate([ + { + $group: { + _id: null, + totalViews: { $sum: '$views' }, + totalSold: { $sum: '$sold' }, + totalRevenue: { $sum: '$revenue' }, + }, + }, + ]); + + return ( + stats[0] ?? { + totalViews: 0, + totalSold: 0, + totalRevenue: 0, + } + ); + } + + async trackAddToCart(): Promise { + const date = new Date().toISOString().split('T')[0]; + + await FunnelStats.updateOne( + { date }, + { $inc: { addToCart: 1 } }, + { upsert: true }, + ); + } + + async trackCheckoutStarted(): Promise { + const date = new Date().toISOString().split('T')[0]; + + await FunnelStats.updateOne( + { date }, + { $inc: { checkoutStarted: 1 } }, + { upsert: true }, + ); + } + + async getFunnelStats() { + const stats = await FunnelStats.aggregate([ + { + $group: { + _id: null, + views: { $sum: '$productViews' }, + carts: { $sum: '$addToCart' }, + checkout: { $sum: '$checkoutStarted' }, + orders: { $sum: '$ordersCompleted' }, + }, + }, + ]); + + return ( + stats[0] ?? { + views: 0, + carts: 0, + checkout: 0, + orders: 0, + } + ); + } + + async getDashboardSummary() { + const overview = await this.getOverview(); + const cart = await this.getCartStats(); + + const productViews = await ProductStats.aggregate([ + { + $group: { + _id: null, + views: { $sum: '$views' }, + }, + }, + ]); + + const views = productViews[0]?.views ?? 0; + + return { + revenue: overview.revenue, + orders: overview.orders, + avgOrderValue: overview.avgOrderValue, + refundRate: overview.refundRate, + + cartAbandonment: cart.abandonmentRate, + + productViews: views, + }; + } + + async getGrowthStats() { + const today = new Date(); + + const startCurrent = new Date(); + startCurrent.setDate(today.getDate() - 30); + + const startPrevious = new Date(); + startPrevious.setDate(today.getDate() - 60); + + const current = await SalesDaily.aggregate([ + { + $match: { + date: { $gte: startCurrent.toISOString().split('T')[0] }, + }, + }, + { + $group: { + _id: null, + revenue: { $sum: '$revenue' }, + orders: { $sum: '$orders' }, + }, + }, + ]); + + const previous = await SalesDaily.aggregate([ + { + $match: { + date: { + $gte: startPrevious.toISOString().split('T')[0], + $lt: startCurrent.toISOString().split('T')[0], + }, + }, + }, + { + $group: { + _id: null, + revenue: { $sum: '$revenue' }, + orders: { $sum: '$orders' }, + }, + }, + ]); + + const currentData = current[0] ?? { revenue: 0, orders: 0 }; + const previousData = previous[0] ?? { revenue: 0, orders: 0 }; + + const revenueGrowth = + previousData.revenue > 0 + ? ((currentData.revenue - previousData.revenue) / + previousData.revenue) * + 100 + : 0; + + const orderGrowth = + previousData.orders > 0 + ? ((currentData.orders - previousData.orders) / previousData.orders) * + 100 + : 0; + + return { + revenueGrowth, + orderGrowth, + }; + } +} diff --git a/services/checkout-service/src/analytics/models/cartStats.model.ts b/services/checkout-service/src/analytics/models/cartStats.model.ts new file mode 100644 index 0000000..0095055 --- /dev/null +++ b/services/checkout-service/src/analytics/models/cartStats.model.ts @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +const cartStatsSchema = new mongoose.Schema({ + date: { type: String, unique: true }, + + cartsCreated: { + type: Number, + default: 0, + }, + + cartsConverted: { + type: Number, + default: 0, + }, +}); + +export const CartStats = mongoose.model('CartStats', cartStatsSchema); diff --git a/services/checkout-service/src/analytics/models/funnelStats.model.ts b/services/checkout-service/src/analytics/models/funnelStats.model.ts new file mode 100644 index 0000000..c2a3361 --- /dev/null +++ b/services/checkout-service/src/analytics/models/funnelStats.model.ts @@ -0,0 +1,27 @@ +import mongoose from 'mongoose'; + +const funnelStatsSchema = new mongoose.Schema({ + date: { type: String, unique: true }, + + productViews: { + type: Number, + default: 0, + }, + + addToCart: { + type: Number, + default: 0, + }, + + checkoutStarted: { + type: Number, + default: 0, + }, + + ordersCompleted: { + type: Number, + default: 0, + }, +}); + +export const FunnelStats = mongoose.model('FunnelStats', funnelStatsSchema); diff --git a/services/checkout-service/src/analytics/models/productStats.model.ts b/services/checkout-service/src/analytics/models/productStats.model.ts new file mode 100644 index 0000000..57510ea --- /dev/null +++ b/services/checkout-service/src/analytics/models/productStats.model.ts @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; + +const schema = new mongoose.Schema({ + productId: String, + + views: { type: Number, default: 0 }, + + sold: { type: Number, default: 0 }, + + revenue: { type: Number, default: 0 }, +}); + +export const ProductStats = mongoose.model('ProductStats', schema); diff --git a/services/checkout-service/src/analytics/models/salesDaily.model.ts b/services/checkout-service/src/analytics/models/salesDaily.model.ts new file mode 100644 index 0000000..cd70176 --- /dev/null +++ b/services/checkout-service/src/analytics/models/salesDaily.model.ts @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; + +const salesDailySchema = new mongoose.Schema({ + date: { type: String, unique: true }, + + revenue: { type: Number, default: 0 }, + + orders: { type: Number, default: 0 }, + + refunds: { type: Number, default: 0 }, +}); + +export const SalesDaily = mongoose.model('SalesDaily', salesDailySchema); diff --git a/services/checkout-service/src/analytics/models/trafficStats.model.ts b/services/checkout-service/src/analytics/models/trafficStats.model.ts new file mode 100644 index 0000000..7340658 --- /dev/null +++ b/services/checkout-service/src/analytics/models/trafficStats.model.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const schema = new mongoose.Schema({ + date: String, + + source: String, + + visits: Number, +}); + +export const TrafficStats = mongoose.model('TrafficStats', schema); diff --git a/services/checkout-service/src/api/routes/cart.route.ts b/services/checkout-service/src/api/routes/cart.route.ts index 95d7f5c..3249151 100644 --- a/services/checkout-service/src/api/routes/cart.route.ts +++ b/services/checkout-service/src/api/routes/cart.route.ts @@ -2,10 +2,12 @@ import { Router } from 'express'; import { CartController } from '../../controllers/cart.controller.js'; import { CartService } from '../../services/cart.service.js'; import { cartRepository } from '../../repositories/cart.repository.factory.js'; +import { AnalyticsService } from '../../analytics/analytics.service.js'; const route = Router(); +const analyticsService = new AnalyticsService(); -const cartService = new CartService(cartRepository); +const cartService = new CartService(cartRepository, analyticsService); const cartController = new CartController(cartService); route.get('/', cartController.getCart); route.post('/', cartController.addItem); diff --git a/services/checkout-service/src/api/routes/checkout.route.ts b/services/checkout-service/src/api/routes/checkout.route.ts index 6fd49ee..f281075 100644 --- a/services/checkout-service/src/api/routes/checkout.route.ts +++ b/services/checkout-service/src/api/routes/checkout.route.ts @@ -5,16 +5,19 @@ import { DynamoOrderRepository } from '../../repositories/order.repository.dynam import { PaymentService } from '../../services/payment.service.js'; import { DynamoPaymentRepository } from '../../repositories/payment.repository.dynamo.js'; import { cartRepository } from '../../repositories/cart.repository.factory.js'; +import { AnalyticsService } from '../../analytics/analytics.service.js'; const router = Router(); const orderRepo = new DynamoOrderRepository(); const paymentRepo = new DynamoPaymentRepository(); const paymentService = new PaymentService(paymentRepo); +const analyticsService = new AnalyticsService(); const checkoutService = new CheckoutService( cartRepository, orderRepo, paymentService, + analyticsService, ); const checkoutController = new CheckoutController(checkoutService); diff --git a/services/checkout-service/src/api/routes/webhook.route.ts b/services/checkout-service/src/api/routes/webhook.route.ts index bfe3f95..04a45f1 100644 --- a/services/checkout-service/src/api/routes/webhook.route.ts +++ b/services/checkout-service/src/api/routes/webhook.route.ts @@ -12,6 +12,7 @@ import { webhookIdempotency } from '../../utils/webhook-idempotency.js'; import { redis } from '../../config/redis.js'; import { DynamoRefundRepository } from '../../repositories/refund.repository.dynamo.js'; import { RefundService } from '../../services/refund.service.js'; +import { AnalyticsService } from '../../analytics/analytics.service.js'; const router = Router(); @@ -24,6 +25,7 @@ const refundService = new RefundService(refundRepo, paymentRepo); const orderService = new OrderService(orderRepo, refundService, paymentRepo); const webhookidempotency = new webhookIdempotency(redis); +const analyticsService = new AnalyticsService(); const webhookcontroller = new StripeWebHookController( paymentService, @@ -31,6 +33,7 @@ const webhookcontroller = new StripeWebHookController( cartRepository, webhookidempotency, refundRepo, + analyticsService, ); router.post( diff --git a/services/checkout-service/src/app.ts b/services/checkout-service/src/app.ts index 760a6c8..67c0f6d 100644 --- a/services/checkout-service/src/app.ts +++ b/services/checkout-service/src/app.ts @@ -5,6 +5,7 @@ import checkoutRoute from './api/routes/checkout.route.js'; import webhookRoutes from './api/routes/webhook.route.js'; import orderRoute from './api/routes/order.route.js'; import { releaseExpiredReservations } from './utils/releaseExpiredReservations.js'; +import analyticsRoutes from './analytics/analytics.routes.js'; const app = express(); //stripe webhook @@ -41,6 +42,7 @@ app.use((req, _res, next) => { app.use('/api/v1/cart', cartRoute); app.use('/api/v1/checkout', checkoutRoute); app.use('/api/v1/order', orderRoute); +app.use('/api/v1/analytics', analyticsRoutes); //error handler diff --git a/services/checkout-service/src/config/env.ts b/services/checkout-service/src/config/env.ts index 1830597..1cbc529 100644 --- a/services/checkout-service/src/config/env.ts +++ b/services/checkout-service/src/config/env.ts @@ -10,6 +10,7 @@ if (!stripeSecretKey.startsWith('sk_')) { } export const env = { + mongoUri: process.env.MONGO_URI!, port: Number(process.env.PORT || 4003), awsRegion: process.env.AWS_REGION || 'ap-south-1', dynamoEndpoint: process.env.DYNAMODB_ENDPOINT, diff --git a/services/checkout-service/src/config/mongo.ts b/services/checkout-service/src/config/mongo.ts new file mode 100644 index 0000000..9b25268 --- /dev/null +++ b/services/checkout-service/src/config/mongo.ts @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; +import { env } from './env.js'; + +export const connectMongo = async () => { + try { + await mongoose.connect(env.mongoUri); + + console.log(' MongoDB connected for analytics'); + } catch (error) { + console.error(' MongoDB connection failed', error); + process.exit(1); + } +}; diff --git a/services/checkout-service/src/controllers/stripe.webhook.controller.ts b/services/checkout-service/src/controllers/stripe.webhook.controller.ts index c570db8..4abc50b 100644 --- a/services/checkout-service/src/controllers/stripe.webhook.controller.ts +++ b/services/checkout-service/src/controllers/stripe.webhook.controller.ts @@ -8,6 +8,7 @@ import { OrderService } from '../services/order.service.js'; import { CartRepository } from '../repositories/cart.repository.js'; import { webhookIdempotency } from '../utils/webhook-idempotency.js'; import { DynamoRefundRepository } from '../repositories/refund.repository.dynamo.js'; +import { AnalyticsService } from '../analytics/analytics.service.js'; export class StripeWebHookController { constructor( @@ -16,6 +17,7 @@ export class StripeWebHookController { private cartRepo: CartRepository, private webhookIdempotancy: webhookIdempotency, private refundRepo: DynamoRefundRepository, + private analyticsService: AnalyticsService, ) {} handle = async (req: Request, res: Response) => { @@ -53,11 +55,12 @@ export class StripeWebHookController { const payment = await this.paymentService.markSuccess(intent.id); - // fallback using metadata if payment not found + let orderId: string | null = null; + if (!payment) { console.log('⚠️ Payment record not found. Using metadata fallback.'); - const orderId = intent.metadata?.orderId; + orderId = intent.metadata?.orderId; if (!orderId) { console.log('❌ orderId missing in metadata'); @@ -66,7 +69,8 @@ export class StripeWebHookController { await this.orderService.markPaid(orderId); } else { - await this.orderService.markPaid(payment.orderId); + orderId = payment.orderId; + await this.orderService.markPaid(orderId); } const items = JSON.parse(intent.metadata.items || '[]'); @@ -82,6 +86,12 @@ export class StripeWebHookController { await this.cartRepo.clearCart(payment.userId); } + // ✅ TRACK ANALYTICS HERE + if (orderId) { + const order = await this.orderService.getOrder(orderId); + if (order) await this.analyticsService.trackOrder(order); + } + console.log('✅ Payment processed successfully'); } @@ -138,6 +148,12 @@ export class StripeWebHookController { refund.id, ); + // ✅ TRACK REFUND ANALYTICS + await this.analyticsService.trackRefund( + refund.amount / 100, + new Date().toISOString(), + ); + console.log('↩️ Refund processed'); } diff --git a/services/checkout-service/src/server.ts b/services/checkout-service/src/server.ts index 619e3b4..95001aa 100644 --- a/services/checkout-service/src/server.ts +++ b/services/checkout-service/src/server.ts @@ -1,9 +1,11 @@ import app from './app.js'; import { checkoutDBEvents, connectDB } from './config/db.js'; import { env } from './config/env.js'; +import { connectMongo } from './config/mongo.js'; checkoutDBEvents(); connectDB(); +connectMongo(); //server app.listen(env.port, () => { diff --git a/services/checkout-service/src/services/cart.service.ts b/services/checkout-service/src/services/cart.service.ts index 474da6f..35222d9 100644 --- a/services/checkout-service/src/services/cart.service.ts +++ b/services/checkout-service/src/services/cart.service.ts @@ -1,9 +1,13 @@ import { CartRepository } from '../repositories/cart.repository.js'; import { Cart, CartItem } from '../domain/cart.types.js'; import axios from 'axios'; +import { AnalyticsService } from '../analytics/analytics.service.js'; export class CartService { - constructor(private cartRepository: CartRepository) {} + constructor( + private cartRepository: CartRepository, + private analyticsService: AnalyticsService, + ) {} async getCart(userId: string): Promise { return this.cartRepository.getCart(userId); } @@ -66,9 +70,17 @@ export class CartService { }; const existingCart = await this.cartRepository.getCart(userId); + const isNewCart = !existingCart || existingCart.items.length === 0; await this.cartRepository.upsertItem(userId, normalizedItem); + // track cart creation only once + if (isNewCart) { + await this.analyticsService.trackCartCreated(); + } + + await this.analyticsService.trackAddToCart(); + const items = existingCart ? [ ...existingCart.items.filter( diff --git a/services/checkout-service/src/services/checkout.service.ts b/services/checkout-service/src/services/checkout.service.ts index cb33836..fb4eded 100644 --- a/services/checkout-service/src/services/checkout.service.ts +++ b/services/checkout-service/src/services/checkout.service.ts @@ -6,12 +6,14 @@ import { OrderRespository } from '../repositories/order.repository.js'; import { Order } from '../domain/order.type.js'; import { PaymentService } from './payment.service.js'; import { generateOrderNumber } from '../utils/order-number.js'; +import { AnalyticsService } from '../analytics/analytics.service.js'; export class CheckoutService { constructor( private cartRepository: CartRepository, private orderRepository: OrderRespository, private paymentService: PaymentService, + private analyticsService: AnalyticsService, ) {} async checkOut( @@ -21,6 +23,8 @@ export class CheckoutService { ) { const existingOrder = await this.orderRepository.getPendingOrderByUser(userId); + + await this.analyticsService.trackCheckoutStarted(); if (existingOrder) { return { canProceed: false, @@ -236,6 +240,9 @@ export class CheckoutService { await this.cartRepository.clearCart(userId); + // ✅ TRACK ANALYTICS + await this.analyticsService.trackOrder(order); + return { canProceed: true, orderId: order.orderId,