Back to blog

Build a Video Platform: Admin Analytics & User Management

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Admin Analytics & User Management

The platform is live — courses, payments, streaming. But we're flying blind. How many subscribers do we have? What's the monthly revenue? Which lessons are most popular? Which users are stuck? In this post, we'll build an admin analytics dashboard that answers these questions at a glance, plus a user management system for handling support cases.

We'll keep the analytics simple and query-driven — no separate analytics service, no event streaming. Just well-crafted SQL queries that give us the numbers we need. For charts, we'll use Recharts, a composable React charting library built on D3.

Time commitment: 3–4 hours
Prerequisites: Phase 10: Public Course Catalog & SEO

What we'll build in this post:
✅ Dashboard overview with key metrics (MRR, subscribers, completions)
✅ Revenue chart with monthly breakdown using Recharts
✅ Subscriber growth chart (new vs churned)
✅ Popular lessons and completion rates
✅ User management with search and filtering
✅ Manual subscription grants for support cases
✅ CSV export for reports


Analytics API

Dashboard Metrics

// src/main/java/com/videoplatform/api/controller/AdminAnalyticsController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.DashboardMetrics;
import com.videoplatform.api.dto.response.RevenueDataPoint;
import com.videoplatform.api.dto.response.PopularLessonResponse;
import com.videoplatform.api.dto.response.SubscriberGrowthPoint;
import com.videoplatform.api.service.AnalyticsService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
 
import java.util.List;
 
@RestController
@RequestMapping("/api/admin/analytics")
@PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController {
 
    private final AnalyticsService analyticsService;
 
    public AdminAnalyticsController(AnalyticsService analyticsService) {
        this.analyticsService = analyticsService;
    }
 
    @GetMapping("/dashboard")
    public ResponseEntity<ApiResponse<DashboardMetrics>> getDashboardMetrics() {
        return ResponseEntity.ok(ApiResponse.success(analyticsService.getDashboardMetrics()));
    }
 
    @GetMapping("/revenue")
    public ResponseEntity<ApiResponse<List<RevenueDataPoint>>> getRevenueChart(
            @RequestParam(defaultValue = "12") int months) {
        return ResponseEntity.ok(ApiResponse.success(
                analyticsService.getMonthlyRevenue(months)));
    }
 
    @GetMapping("/subscribers")
    public ResponseEntity<ApiResponse<List<SubscriberGrowthPoint>>> getSubscriberGrowth(
            @RequestParam(defaultValue = "12") int months) {
        return ResponseEntity.ok(ApiResponse.success(
                analyticsService.getSubscriberGrowth(months)));
    }
 
    @GetMapping("/popular-lessons")
    public ResponseEntity<ApiResponse<List<PopularLessonResponse>>> getPopularLessons(
            @RequestParam(defaultValue = "10") int limit) {
        return ResponseEntity.ok(ApiResponse.success(
                analyticsService.getPopularLessons(limit)));
    }
}

DTOs

// src/main/java/com/videoplatform/api/dto/response/DashboardMetrics.java
package com.videoplatform.api.dto.response;
 
import java.math.BigDecimal;
 
public record DashboardMetrics(
    long totalUsers,
    long activeSubscribers,
    long totalCourses,
    long totalLessons,
    BigDecimal monthlyRevenue,      // Current month
    BigDecimal totalRevenue,         // All time
    double averageCompletionRate,    // Percentage
    long newSubscribersThisMonth,
    long churnedThisMonth
) {}
// src/main/java/com/videoplatform/api/dto/response/RevenueDataPoint.java
package com.videoplatform.api.dto.response;
 
import java.math.BigDecimal;
 
public record RevenueDataPoint(
    String month,           // "2026-01"
    BigDecimal revenue,     // in dollars
    long transactionCount
) {}
// src/main/java/com/videoplatform/api/dto/response/SubscriberGrowthPoint.java
package com.videoplatform.api.dto.response;
 
public record SubscriberGrowthPoint(
    String month,
    long newSubscribers,
    long churnedSubscribers,
    long totalActive
) {}
// src/main/java/com/videoplatform/api/dto/response/PopularLessonResponse.java
package com.videoplatform.api.dto.response;
 
public record PopularLessonResponse(
    Long lessonId,
    String lessonTitle,
    String courseTitle,
    long viewCount,
    double completionRate,      // Percentage of viewers who finished
    int averageWatchTimeSeconds
) {}

AnalyticsService

// src/main/java/com/videoplatform/api/service/AnalyticsService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.repository.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
 
@Service
public class AnalyticsService {
 
    private final UserRepository userRepository;
    private final SubscriptionRepository subscriptionRepository;
    private final CourseRepository courseRepository;
    private final LessonRepository lessonRepository;
    private final PaymentHistoryRepository paymentRepository;
    private final ProgressRepository progressRepository;
 
    public AnalyticsService(
            UserRepository userRepository,
            SubscriptionRepository subscriptionRepository,
            CourseRepository courseRepository,
            LessonRepository lessonRepository,
            PaymentHistoryRepository paymentRepository,
            ProgressRepository progressRepository) {
        this.userRepository = userRepository;
        this.subscriptionRepository = subscriptionRepository;
        this.courseRepository = courseRepository;
        this.lessonRepository = lessonRepository;
        this.paymentRepository = paymentRepository;
        this.progressRepository = progressRepository;
    }
 
    @Transactional(readOnly = true)
    public DashboardMetrics getDashboardMetrics() {
        long totalUsers = userRepository.count();
        long activeSubscribers = subscriptionRepository.countByStatus(SubscriptionStatus.ACTIVE);
        long totalCourses = courseRepository.count();
        long totalLessons = lessonRepository.count();
 
        // Revenue this month
        LocalDateTime monthStart = YearMonth.now().atDay(1).atStartOfDay();
        BigDecimal monthlyRevenue = paymentRepository
                .sumAmountByStatusAndCreatedAtAfter("succeeded", monthStart)
                .map(cents -> BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP))
                .orElse(BigDecimal.ZERO);
 
        // Total revenue
        BigDecimal totalRevenue = paymentRepository
                .sumAmountByStatus("succeeded")
                .map(cents -> BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP))
                .orElse(BigDecimal.ZERO);
 
        // Average completion rate
        double avgCompletion = progressRepository.averageCompletionRate()
                .orElse(0.0);
 
        // New subscribers this month
        long newThisMonth = subscriptionRepository.countByCreatedAtAfter(monthStart);
 
        // Churned this month
        long churnedThisMonth = subscriptionRepository
                .countByStatusAndUpdatedAtAfter(SubscriptionStatus.EXPIRED, monthStart);
 
        return new DashboardMetrics(
                totalUsers, activeSubscribers, totalCourses, totalLessons,
                monthlyRevenue, totalRevenue, avgCompletion,
                newThisMonth, churnedThisMonth
        );
    }
 
    @Transactional(readOnly = true)
    public List<RevenueDataPoint> getMonthlyRevenue(int months) {
        List<RevenueDataPoint> result = new ArrayList<>();
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM");
 
        for (int i = months - 1; i >= 0; i--) {
            YearMonth ym = YearMonth.now().minusMonths(i);
            LocalDateTime start = ym.atDay(1).atStartOfDay();
            LocalDateTime end = ym.atEndOfMonth().atTime(23, 59, 59);
 
            BigDecimal revenue = paymentRepository
                    .sumAmountByStatusAndCreatedAtBetween("succeeded", start, end)
                    .map(cents -> BigDecimal.valueOf(cents)
                            .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP))
                    .orElse(BigDecimal.ZERO);
 
            long count = paymentRepository
                    .countByStatusAndCreatedAtBetween("succeeded", start, end);
 
            result.add(new RevenueDataPoint(ym.format(fmt), revenue, count));
        }
 
        return result;
    }
 
    @Transactional(readOnly = true)
    public List<SubscriberGrowthPoint> getSubscriberGrowth(int months) {
        List<SubscriberGrowthPoint> result = new ArrayList<>();
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM");
 
        for (int i = months - 1; i >= 0; i--) {
            YearMonth ym = YearMonth.now().minusMonths(i);
            LocalDateTime start = ym.atDay(1).atStartOfDay();
            LocalDateTime end = ym.atEndOfMonth().atTime(23, 59, 59);
 
            long newSubs = subscriptionRepository
                    .countByCreatedAtBetween(start, end);
            long churned = subscriptionRepository
                    .countByStatusAndUpdatedAtBetween(SubscriptionStatus.EXPIRED, start, end);
 
            // Active at end of month
            long totalActive = subscriptionRepository
                    .countByStatusAndCurrentPeriodEndAfter(SubscriptionStatus.ACTIVE, start);
 
            result.add(new SubscriberGrowthPoint(
                    ym.format(fmt), newSubs, churned, totalActive));
        }
 
        return result;
    }
 
    @Transactional(readOnly = true)
    public List<PopularLessonResponse> getPopularLessons(int limit) {
        return progressRepository.findPopularLessons(limit);
    }
}

Repository Queries

// Add to PaymentHistoryRepository
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status AND p.createdAt > :after")
Optional<Long> sumAmountByStatusAndCreatedAtAfter(String status, LocalDateTime after);
 
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status")
Optional<Long> sumAmountByStatus(String status);
 
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end")
Optional<Long> sumAmountByStatusAndCreatedAtBetween(String status, LocalDateTime start, LocalDateTime end);
 
long countByStatusAndCreatedAtBetween(String status, LocalDateTime start, LocalDateTime end);
// Add to SubscriptionRepository
long countByStatus(SubscriptionStatus status);
long countByCreatedAtAfter(LocalDateTime after);
long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
long countByStatusAndUpdatedAtAfter(SubscriptionStatus status, LocalDateTime after);
long countByStatusAndUpdatedAtBetween(SubscriptionStatus status, LocalDateTime start, LocalDateTime end);
long countByStatusAndCurrentPeriodEndAfter(SubscriptionStatus status, LocalDateTime after);
// Add to ProgressRepository
@Query("""
    SELECT new com.videoplatform.api.dto.response.PopularLessonResponse(
        l.id, l.title, c.title,
        COUNT(p),
        CAST(SUM(CASE WHEN p.completed = true THEN 1 ELSE 0 END) AS double) / COUNT(p) * 100,
        CAST(AVG(p.watchTimeSeconds) AS int)
    )
    FROM Progress p
    JOIN p.lesson l
    JOIN l.section s
    JOIN s.course c
    GROUP BY l.id, l.title, c.title
    ORDER BY COUNT(p) DESC
    LIMIT :limit
    """)
List<PopularLessonResponse> findPopularLessons(int limit);
 
@Query("SELECT AVG(CASE WHEN p.completed = true THEN 100.0 ELSE (p.watchTimeSeconds * 100.0 / p.lesson.durationSeconds) END) FROM Progress p")
Optional<Double> averageCompletionRate();

Frontend: Dashboard Overview

Dashboard Page

// web/src/app/admin/analytics/page.tsx
"use client";
 
import { useEffect, useState } from "react";
import { MetricCard } from "@/components/admin/MetricCard";
import { RevenueChart } from "@/components/admin/RevenueChart";
import { SubscriberChart } from "@/components/admin/SubscriberChart";
import { PopularLessons } from "@/components/admin/PopularLessons";
import { DollarSign, Users, BookOpen, TrendingUp, TrendingDown, BarChart3 } from "lucide-react";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface DashboardMetrics {
  totalUsers: number;
  activeSubscribers: number;
  totalCourses: number;
  totalLessons: number;
  monthlyRevenue: number;
  totalRevenue: number;
  averageCompletionRate: number;
  newSubscribersThisMonth: number;
  churnedThisMonth: number;
}
 
export default function AnalyticsDashboard() {
  const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    const token = localStorage.getItem("accessToken");
    fetch(`${API_BASE}/api/admin/analytics/dashboard`, {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json())
      .then((data) => setMetrics(data.data))
      .finally(() => setLoading(false));
  }, []);
 
  if (loading) {
    return (
      <div className="p-8">
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
          {Array.from({ length: 4 }).map((_, i) => (
            <div key={i} className="h-32 rounded-lg bg-muted animate-pulse" />
          ))}
        </div>
      </div>
    );
  }
 
  if (!metrics) return <div className="p-8">Failed to load metrics</div>;
 
  return (
    <div className="p-8 space-y-8">
      <h1 className="text-3xl font-bold">Analytics Dashboard</h1>
 
      {/* Key metrics */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
        <MetricCard
          title="Monthly Revenue"
          value={`$${metrics.monthlyRevenue.toFixed(2)}`}
          icon={<DollarSign className="h-5 w-5" />}
          subtitle={`$${metrics.totalRevenue.toFixed(2)} total`}
        />
        <MetricCard
          title="Active Subscribers"
          value={metrics.activeSubscribers.toString()}
          icon={<Users className="h-5 w-5" />}
          subtitle={`${metrics.totalUsers} total users`}
        />
        <MetricCard
          title="New This Month"
          value={`+${metrics.newSubscribersThisMonth}`}
          icon={<TrendingUp className="h-5 w-5" />}
          subtitle={`${metrics.churnedThisMonth} churned`}
          trend={metrics.newSubscribersThisMonth > metrics.churnedThisMonth ? "up" : "down"}
        />
        <MetricCard
          title="Avg Completion"
          value={`${metrics.averageCompletionRate.toFixed(1)}%`}
          icon={<BarChart3 className="h-5 w-5" />}
          subtitle={`${metrics.totalLessons} lessons`}
        />
      </div>
 
      {/* Charts */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <RevenueChart />
        <SubscriberChart />
      </div>
 
      {/* Popular lessons */}
      <PopularLessons />
    </div>
  );
}

MetricCard Component

// web/src/components/admin/MetricCard.tsx
import { ReactNode } from "react";
 
interface Props {
  title: string;
  value: string;
  icon: ReactNode;
  subtitle?: string;
  trend?: "up" | "down";
}
 
export function MetricCard({ title, value, icon, subtitle, trend }: Props) {
  return (
    <div className="rounded-xl border bg-card p-6">
      <div className="flex items-center justify-between mb-2">
        <span className="text-sm text-muted-foreground">{title}</span>
        <span className="text-muted-foreground">{icon}</span>
      </div>
      <div className="text-3xl font-bold">{value}</div>
      {subtitle && (
        <p className={`text-sm mt-1 ${
          trend === "up" ? "text-green-600" :
          trend === "down" ? "text-red-600" :
          "text-muted-foreground"
        }`}>
          {subtitle}
        </p>
      )}
    </div>
  );
}

Revenue Chart with Recharts

Install Recharts

cd web && npm install recharts

Revenue Chart Component

// web/src/components/admin/RevenueChart.tsx
"use client";
 
import { useEffect, useState } from "react";
import {
  AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
  ResponsiveContainer,
} from "recharts";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface RevenueData {
  month: string;
  revenue: number;
  transactionCount: number;
}
 
export function RevenueChart() {
  const [data, setData] = useState<RevenueData[]>([]);
 
  useEffect(() => {
    const token = localStorage.getItem("accessToken");
    fetch(`${API_BASE}/api/admin/analytics/revenue?months=12`, {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json())
      .then((res) => setData(res.data));
  }, []);
 
  // Format month label: "2026-01" -> "Jan"
  const formatMonth = (month: string) => {
    const date = new Date(month + "-01");
    return date.toLocaleString("en", { month: "short" });
  };
 
  return (
    <div className="rounded-xl border bg-card p-6">
      <h2 className="text-lg font-semibold mb-4">Monthly Revenue</h2>
      <ResponsiveContainer width="100%" height={300}>
        <AreaChart data={data}>
          <defs>
            <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
              <stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
            </linearGradient>
          </defs>
          <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
          <XAxis
            dataKey="month"
            tickFormatter={formatMonth}
            className="text-xs"
          />
          <YAxis
            tickFormatter={(v) => `$${v}`}
            className="text-xs"
          />
          <Tooltip
            content={({ active, payload, label }) => {
              if (!active || !payload?.length) return null;
              return (
                <div className="rounded-lg border bg-popover p-3 shadow-md">
                  <p className="font-medium">{label}</p>
                  <p className="text-primary">
                    ${payload[0].value?.toLocaleString()}
                  </p>
                  <p className="text-sm text-muted-foreground">
                    {payload[0].payload.transactionCount} transactions
                  </p>
                </div>
              );
            }}
          />
          <Area
            type="monotone"
            dataKey="revenue"
            stroke="hsl(var(--primary))"
            fill="url(#revenueGradient)"
            strokeWidth={2}
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
}

Subscriber Growth Chart

// web/src/components/admin/SubscriberChart.tsx
"use client";
 
import { useEffect, useState } from "react";
import {
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
  ResponsiveContainer, Legend,
} from "recharts";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface GrowthData {
  month: string;
  newSubscribers: number;
  churnedSubscribers: number;
  totalActive: number;
}
 
export function SubscriberChart() {
  const [data, setData] = useState<GrowthData[]>([]);
 
  useEffect(() => {
    const token = localStorage.getItem("accessToken");
    fetch(`${API_BASE}/api/admin/analytics/subscribers?months=12`, {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json())
      .then((res) => setData(res.data));
  }, []);
 
  const formatMonth = (month: string) => {
    const date = new Date(month + "-01");
    return date.toLocaleString("en", { month: "short" });
  };
 
  return (
    <div className="rounded-xl border bg-card p-6">
      <h2 className="text-lg font-semibold mb-4">Subscriber Growth</h2>
      <ResponsiveContainer width="100%" height={300}>
        <BarChart data={data}>
          <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
          <XAxis dataKey="month" tickFormatter={formatMonth} className="text-xs" />
          <YAxis className="text-xs" />
          <Tooltip
            content={({ active, payload, label }) => {
              if (!active || !payload?.length) return null;
              return (
                <div className="rounded-lg border bg-popover p-3 shadow-md">
                  <p className="font-medium">{label}</p>
                  <p className="text-green-600">+{payload[0].value} new</p>
                  <p className="text-red-600">-{payload[1].value} churned</p>
                  <p className="text-muted-foreground">
                    {payload[0].payload.totalActive} total active
                  </p>
                </div>
              );
            }}
          />
          <Legend />
          <Bar dataKey="newSubscribers" name="New" fill="#22c55e" radius={[4, 4, 0, 0]} />
          <Bar dataKey="churnedSubscribers" name="Churned" fill="#ef4444" radius={[4, 4, 0, 0]} />
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}

// web/src/components/admin/PopularLessons.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Eye, CheckCircle, Clock } from "lucide-react";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface PopularLesson {
  lessonId: number;
  lessonTitle: string;
  courseTitle: string;
  viewCount: number;
  completionRate: number;
  averageWatchTimeSeconds: number;
}
 
export function PopularLessons() {
  const [lessons, setLessons] = useState<PopularLesson[]>([]);
 
  useEffect(() => {
    const token = localStorage.getItem("accessToken");
    fetch(`${API_BASE}/api/admin/analytics/popular-lessons?limit=10`, {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json())
      .then((res) => setLessons(res.data));
  }, []);
 
  const formatTime = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    return `${mins}m ${seconds % 60}s`;
  };
 
  return (
    <div className="rounded-xl border bg-card p-6">
      <h2 className="text-lg font-semibold mb-4">Popular Lessons</h2>
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead>
            <tr className="border-b text-left">
              <th className="pb-3 font-medium text-muted-foreground">Lesson</th>
              <th className="pb-3 font-medium text-muted-foreground">Course</th>
              <th className="pb-3 font-medium text-muted-foreground text-right">
                <Eye className="h-4 w-4 inline mr-1" />Views
              </th>
              <th className="pb-3 font-medium text-muted-foreground text-right">
                <CheckCircle className="h-4 w-4 inline mr-1" />Completion
              </th>
              <th className="pb-3 font-medium text-muted-foreground text-right">
                <Clock className="h-4 w-4 inline mr-1" />Avg Watch
              </th>
            </tr>
          </thead>
          <tbody>
            {lessons.map((lesson, idx) => (
              <tr key={lesson.lessonId} className="border-b last:border-0">
                <td className="py-3">
                  <span className="text-muted-foreground mr-2">#{idx + 1}</span>
                  {lesson.lessonTitle}
                </td>
                <td className="py-3 text-muted-foreground">{lesson.courseTitle}</td>
                <td className="py-3 text-right font-medium">{lesson.viewCount}</td>
                <td className="py-3 text-right">
                  <span className={
                    lesson.completionRate >= 80 ? "text-green-600" :
                    lesson.completionRate >= 50 ? "text-yellow-600" :
                    "text-red-600"
                  }>
                    {lesson.completionRate.toFixed(1)}%
                  </span>
                </td>
                <td className="py-3 text-right text-muted-foreground">
                  {formatTime(lesson.averageWatchTimeSeconds)}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

User Management

User Management API

// src/main/java/com/videoplatform/api/controller/AdminUserController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.request.GrantSubscriptionRequest;
import com.videoplatform.api.dto.response.AdminUserResponse;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.service.AdminUserService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public class AdminUserController {
 
    private final AdminUserService userService;
 
    public AdminUserController(AdminUserService userService) {
        this.userService = userService;
    }
 
    @GetMapping
    public ResponseEntity<ApiResponse<Page<AdminUserResponse>>> listUsers(
            @RequestParam(defaultValue = "") String search,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(required = false) String status) {
 
        return ResponseEntity.ok(ApiResponse.success(
                userService.searchUsers(search, status, page, size)));
    }
 
    @GetMapping("/{id}")
    public ResponseEntity<ApiResponse<AdminUserResponse>> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(ApiResponse.success(userService.getUserDetail(id)));
    }
 
    @PostMapping("/{id}/grant-subscription")
    public ResponseEntity<ApiResponse<String>> grantSubscription(
            @PathVariable Long id,
            @RequestBody GrantSubscriptionRequest request) {
 
        userService.grantSubscription(id, request);
        return ResponseEntity.ok(ApiResponse.success("Subscription granted"));
    }
 
    @PostMapping("/{id}/revoke-subscription")
    public ResponseEntity<ApiResponse<String>> revokeSubscription(@PathVariable Long id) {
        userService.revokeSubscription(id);
        return ResponseEntity.ok(ApiResponse.success("Subscription revoked"));
    }
}

DTOs

// src/main/java/com/videoplatform/api/dto/response/AdminUserResponse.java
package com.videoplatform.api.dto.response;
 
import java.time.LocalDateTime;
 
public record AdminUserResponse(
    Long id,
    String email,
    String name,
    String role,
    String subscriptionStatus,   // ACTIVE, PAST_DUE, EXPIRED, NONE
    String planType,             // MONTHLY, YEARLY, MANUAL, null
    LocalDateTime subscriptionEnd,
    int completedLessons,
    LocalDateTime createdAt,
    LocalDateTime lastLoginAt
) {}
// src/main/java/com/videoplatform/api/dto/request/GrantSubscriptionRequest.java
package com.videoplatform.api.dto.request;
 
public record GrantSubscriptionRequest(
    int durationDays,   // How many days to grant
    String reason       // For audit log
) {}

AdminUserService

// src/main/java/com/videoplatform/api/service/AdminUserService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.dto.request.GrantSubscriptionRequest;
import com.videoplatform.api.dto.response.AdminUserResponse;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.time.LocalDateTime;
import java.util.List;
 
@Service
public class AdminUserService {
 
    private static final Logger log = LoggerFactory.getLogger(AdminUserService.class);
 
    private final UserRepository userRepository;
    private final SubscriptionRepository subscriptionRepository;
    private final ProgressRepository progressRepository;
 
    public AdminUserService(
            UserRepository userRepository,
            SubscriptionRepository subscriptionRepository,
            ProgressRepository progressRepository) {
        this.userRepository = userRepository;
        this.subscriptionRepository = subscriptionRepository;
        this.progressRepository = progressRepository;
    }
 
    @Transactional(readOnly = true)
    public Page<AdminUserResponse> searchUsers(String search, String status, int page, int size) {
        var pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
 
        Page<User> users;
        if (search != null && !search.isBlank()) {
            users = userRepository.findByEmailContainingOrNameContaining(
                    search, search, pageable);
        } else {
            users = userRepository.findAll(pageable);
        }
 
        return users.map(this::toAdminResponse);
    }
 
    @Transactional(readOnly = true)
    public AdminUserResponse getUserDetail(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("User not found"));
        return toAdminResponse(user);
    }
 
    @Transactional
    public void grantSubscription(Long userId, GrantSubscriptionRequest request) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new ResourceNotFoundException("User not found"));
 
        // Check for existing active subscription
        subscriptionRepository
                .findByUserAndStatus(user, SubscriptionStatus.ACTIVE)
                .ifPresent(sub -> {
                    throw new IllegalStateException("User already has an active subscription");
                });
 
        // Create manual subscription
        Subscription subscription = new Subscription();
        subscription.setUser(user);
        subscription.setStripeSubscriptionId("manual_" + System.currentTimeMillis());
        subscription.setStripeCustomerId("manual");
        subscription.setPlanType(PlanType.YEARLY); // Manual grants use YEARLY
        subscription.setStatus(SubscriptionStatus.ACTIVE);
        subscription.setCurrentPeriodStart(LocalDateTime.now());
        subscription.setCurrentPeriodEnd(
                LocalDateTime.now().plusDays(request.durationDays()));
 
        subscriptionRepository.save(subscription);
 
        log.info("Manual subscription granted to user {} for {} days. Reason: {}",
                userId, request.durationDays(), request.reason());
    }
 
    @Transactional
    public void revokeSubscription(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new ResourceNotFoundException("User not found"));
 
        subscriptionRepository.findByUserAndStatus(user, SubscriptionStatus.ACTIVE)
                .ifPresent(sub -> {
                    sub.setStatus(SubscriptionStatus.EXPIRED);
                    sub.setUpdatedAt(LocalDateTime.now());
                    subscriptionRepository.save(sub);
                    log.info("Subscription revoked for user {}", userId);
                });
    }
 
    private AdminUserResponse toAdminResponse(User user) {
        var subscription = subscriptionRepository
                .findByUserAndStatusIn(user,
                    List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .orElse(null);
 
        int completedLessons = (int) progressRepository.countByUserAndCompletedTrue(user);
 
        return new AdminUserResponse(
                user.getId(),
                user.getEmail(),
                user.getName(),
                user.getRole().name(),
                subscription != null ? subscription.getStatus().name() : "NONE",
                subscription != null ? subscription.getPlanType().name() : null,
                subscription != null ? subscription.getCurrentPeriodEnd() : null,
                completedLessons,
                user.getCreatedAt(),
                user.getLastLoginAt()
        );
    }
}

User Management UI

// web/src/app/admin/users/page.tsx
"use client";
 
import { useEffect, useState, useCallback } from "react";
import { Search, Gift, Ban, ChevronLeft, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface AdminUser {
  id: number;
  email: string;
  name: string;
  role: string;
  subscriptionStatus: string;
  planType: string | null;
  subscriptionEnd: string | null;
  completedLessons: number;
  createdAt: string;
  lastLoginAt: string | null;
}
 
export default function UserManagement() {
  const [users, setUsers] = useState<AdminUser[]>([]);
  const [search, setSearch] = useState("");
  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(0);
  const [loading, setLoading] = useState(true);
 
  const loadUsers = useCallback(async () => {
    setLoading(true);
    const token = localStorage.getItem("accessToken");
    const params = new URLSearchParams({
      search,
      page: page.toString(),
      size: "20",
    });
 
    const res = await fetch(`${API_BASE}/api/admin/users?${params}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const data = await res.json();
    setUsers(data.data.content);
    setTotalPages(data.data.totalPages);
    setLoading(false);
  }, [search, page]);
 
  useEffect(() => { loadUsers(); }, [loadUsers]);
 
  const handleGrantSubscription = async (userId: number) => {
    const days = prompt("Grant subscription for how many days?", "30");
    if (!days) return;
 
    const reason = prompt("Reason for grant?", "Support case");
    if (!reason) return;
 
    const token = localStorage.getItem("accessToken");
    await fetch(`${API_BASE}/api/admin/users/${userId}/grant-subscription`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ durationDays: parseInt(days), reason }),
    });
 
    loadUsers();
  };
 
  const handleRevokeSubscription = async (userId: number) => {
    if (!confirm("Revoke this user's subscription?")) return;
 
    const token = localStorage.getItem("accessToken");
    await fetch(`${API_BASE}/api/admin/users/${userId}/revoke-subscription`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}` },
    });
 
    loadUsers();
  };
 
  const statusColor = (status: string) => {
    switch (status) {
      case "ACTIVE": return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
      case "PAST_DUE": return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
      case "EXPIRED": return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
      default: return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
    }
  };
 
  return (
    <div className="p-8 space-y-6">
      <h1 className="text-3xl font-bold">User Management</h1>
 
      {/* Search bar */}
      <div className="relative max-w-md">
        <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
        <input
          type="text"
          placeholder="Search by name or email..."
          value={search}
          onChange={(e) => { setSearch(e.target.value); setPage(0); }}
          className="w-full pl-10 pr-4 py-2 rounded-lg border bg-background
            focus:outline-none focus:ring-2 focus:ring-primary"
        />
      </div>
 
      {/* User table */}
      <div className="rounded-xl border overflow-hidden">
        <table className="w-full text-sm">
          <thead className="bg-muted/50">
            <tr>
              <th className="p-3 text-left font-medium">User</th>
              <th className="p-3 text-left font-medium">Subscription</th>
              <th className="p-3 text-right font-medium">Lessons</th>
              <th className="p-3 text-left font-medium">Joined</th>
              <th className="p-3 text-right font-medium">Actions</th>
            </tr>
          </thead>
          <tbody>
            {users.map((user) => (
              <tr key={user.id} className="border-t hover:bg-muted/30">
                <td className="p-3">
                  <div className="font-medium">{user.name || "No name"}</div>
                  <div className="text-muted-foreground">{user.email}</div>
                </td>
                <td className="p-3">
                  <Badge className={statusColor(user.subscriptionStatus)}>
                    {user.subscriptionStatus}
                  </Badge>
                  {user.planType && (
                    <span className="text-xs text-muted-foreground ml-2">
                      {user.planType}
                    </span>
                  )}
                </td>
                <td className="p-3 text-right">{user.completedLessons}</td>
                <td className="p-3 text-muted-foreground">
                  {new Date(user.createdAt).toLocaleDateString()}
                </td>
                <td className="p-3 text-right">
                  <div className="flex justify-end gap-2">
                    {user.subscriptionStatus === "NONE" || user.subscriptionStatus === "EXPIRED" ? (
                      <button
                        onClick={() => handleGrantSubscription(user.id)}
                        className="p-1.5 rounded hover:bg-green-100 dark:hover:bg-green-900
                          text-green-600 transition-colors"
                        title="Grant subscription"
                      >
                        <Gift className="h-4 w-4" />
                      </button>
                    ) : (
                      <button
                        onClick={() => handleRevokeSubscription(user.id)}
                        className="p-1.5 rounded hover:bg-red-100 dark:hover:bg-red-900
                          text-red-600 transition-colors"
                        title="Revoke subscription"
                      >
                        <Ban className="h-4 w-4" />
                      </button>
                    )}
                  </div>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
 
      {/* Pagination */}
      <div className="flex items-center justify-between">
        <span className="text-sm text-muted-foreground">
          Page {page + 1} of {totalPages}
        </span>
        <div className="flex gap-2">
          <button
            onClick={() => setPage(p => Math.max(0, p - 1))}
            disabled={page === 0}
            className="p-2 rounded border hover:bg-muted disabled:opacity-50"
          >
            <ChevronLeft className="h-4 w-4" />
          </button>
          <button
            onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
            disabled={page >= totalPages - 1}
            className="p-2 rounded border hover:bg-muted disabled:opacity-50"
          >
            <ChevronRight className="h-4 w-4" />
          </button>
        </div>
      </div>
    </div>
  );
}

CSV Export

Export Endpoint

// Add to AdminAnalyticsController
@GetMapping("/export/revenue")
public ResponseEntity<byte[]> exportRevenue(
        @RequestParam(defaultValue = "12") int months) {
 
    List<RevenueDataPoint> data = analyticsService.getMonthlyRevenue(months);
 
    StringBuilder csv = new StringBuilder();
    csv.append("Month,Revenue,Transactions\n");
    for (var point : data) {
        csv.append(String.format("%s,%s,%d\n",
                point.month(), point.revenue(), point.transactionCount()));
    }
 
    return ResponseEntity.ok()
            .header("Content-Type", "text/csv")
            .header("Content-Disposition", "attachment; filename=revenue.csv")
            .body(csv.toString().getBytes());
}
 
@GetMapping("/export/users")
public ResponseEntity<byte[]> exportUsers() {
    List<AdminUserResponse> users = userService.getAllUsersForExport();
 
    StringBuilder csv = new StringBuilder();
    csv.append("ID,Email,Name,Role,Subscription,Plan,Joined\n");
    for (var user : users) {
        csv.append(String.format("%d,%s,%s,%s,%s,%s,%s\n",
                user.id(), user.email(), user.name(), user.role(),
                user.subscriptionStatus(),
                user.planType() != null ? user.planType() : "",
                user.createdAt()));
    }
 
    return ResponseEntity.ok()
            .header("Content-Type", "text/csv")
            .header("Content-Disposition", "attachment; filename=users.csv")
            .body(csv.toString().getBytes());
}

Export Button

// Add to dashboard or user management page
function ExportButton({ endpoint, filename }: { endpoint: string; filename: string }) {
  const handleExport = async () => {
    const token = localStorage.getItem("accessToken");
    const res = await fetch(`${API_BASE}/api/admin/analytics/export/${endpoint}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const blob = await res.blob();
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    a.click();
    URL.revokeObjectURL(url);
  };
 
  return (
    <button onClick={handleExport} className="text-sm text-primary hover:underline">
      Export CSV
    </button>
  );
}

Testing

1. Dashboard Metrics

TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@test.com","password":"admin123"}' | jq -r '.data.accessToken')
 
curl -s http://localhost:8080/api/admin/analytics/dashboard \
  -H "Authorization: Bearer ${TOKEN}" | jq

2. Revenue Chart Data

curl -s "http://localhost:8080/api/admin/analytics/revenue?months=6" \
  -H "Authorization: Bearer ${TOKEN}" | jq
curl -s "http://localhost:8080/api/admin/users?search=john&page=0&size=10" \
  -H "Authorization: Bearer ${TOKEN}" | jq

4. Grant Manual Subscription

curl -X POST http://localhost:8080/api/admin/users/5/grant-subscription \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"durationDays": 30, "reason": "Beta tester reward"}'

5. CSV Export

curl -o revenue.csv "http://localhost:8080/api/admin/analytics/export/revenue?months=12" \
  -H "Authorization: Bearer ${TOKEN}"
 
cat revenue.csv
# Month,Revenue,Transactions
# 2025-04,0.00,0
# 2025-05,49.95,5
# ...

Common Mistakes

1. N+1 Queries in Analytics

// WRONG — triggers N+1 for each user's subscription
users.stream().map(user -> {
    var sub = subscriptionRepository.findByUser(user); // N queries!
    return toResponse(user, sub);
});
 
// RIGHT — batch fetch with JOIN FETCH
@Query("SELECT u FROM User u LEFT JOIN FETCH u.subscriptions WHERE u.id IN :ids")
List<User> findByIdsWithSubscriptions(List<Long> ids);

2. Querying All Records for Aggregation

// WRONG — loads all payments into memory
List<PaymentHistory> all = paymentRepository.findAll();
BigDecimal total = all.stream().map(PaymentHistory::getAmount).reduce(BigDecimal::add);
 
// RIGHT — aggregate in the database
@Query("SELECT SUM(p.amount) FROM PaymentHistory p WHERE p.status = :status")
Optional<Long> sumAmountByStatus(String status);

3. Not Restricting Admin Endpoints

// WRONG — any authenticated user can see analytics
@GetMapping("/api/admin/analytics/dashboard")
public DashboardMetrics getDashboard() { ... }
 
// RIGHT — restrict to ADMIN role
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/analytics/dashboard")
public DashboardMetrics getDashboard() { ... }

4. Hardcoding Chart Colors

// WRONG — doesn't respect dark mode
<Bar fill="#3b82f6" />
 
// RIGHT — use CSS variables that adapt to theme
<Bar fill="hsl(var(--primary))" />
// Or use theme-aware colors
<Bar fill="#22c55e" /> {/* Green is readable in both themes */}

What's Next?

The admin now has full visibility into the platform's health — revenue trends, subscriber growth, content performance, and user management tools. In Post #13, we'll harden everything:

  • Rate limiting with Spring Boot + Redis
  • HLS AES-128 encryption for video segments
  • CORS and CSP headers
  • Redis caching for API responses
  • Next.js ISR for static page optimization
  • Email notifications for key events

Time to make this platform production-ready.

Series: Build a Video Streaming Platform
Previous: Phase 10: Public Course Catalog & SEO
Next: Phase 12: Security & Performance Hardening

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.