Back to blog

Xây Dựng URL Shortener: System Analytics & Audit Logs

typescriptnodejsanalyticsadminbackend
Xây Dựng URL Shortener: System Analytics & Audit Logs

Admin panel của bạn đã có thể quản lý users, moderate URLs, và thực hiện bulk operations. Nhưng bây giờ, admins đang "mù" hoàn toàn. Họ có thể suspend một user hay disable một link, nhưng chẳng biết có bao nhiêu URLs được tạo mỗi ngày, link nào đang viral, hay hệ thống đang tăng trưởng hay chững lại. Không có data, mọi quyết định của admin đều là đoán mò.

Một admin panel xịn cần hai thứ: system-wide analytics để nhìn thấy toàn cảnh những gì đang xảy ra trên platform, và audit logs để đảm bảo accountability — một bản ghi không thể thay đổi của mọi admin action. Khi ai đó hỏi "ai suspend user đó và tại sao?", bạn cần có câu trả lời ngay.

Trong bài này, chúng ta sẽ build cả hai. Đầu tiên là comprehensive dashboard API hiển thị key metrics, time-series trends, và top performers. Sau đó là full audit logging system ghi lại mọi admin action với before/after snapshots — cùng API để query những logs đó.

Thời gian: 2–3 giờ
Yêu cầu: Phase 11: URL Moderation & Bulk Operations

Những gì chúng ta sẽ build:
✅ System dashboard API với key metrics (total users, URLs, clicks)
✅ Time-series analytics (clicks theo ngày/tuần/tháng, URL creation trends)
✅ Top performers (URLs được click nhiều nhất, users hoạt động nhiều nhất)
✅ User growth và retention metrics
✅ Admin audit log system (track mọi admin action)
✅ Audit log querying và filtering API


Analytics Architecture

Trước khi viết endpoints, hãy hiểu data flow qua analytics system. Tin tốt là: chúng ta đã có sẵn hầu hết raw data rồi. Click model từ Phase 4 lưu mọi click event với timestamps, IP addresses, user agents, referrers, và geo data. Url và User models track creation dates. Việc cần làm chỉ là aggregate data một cách thông minh.

Điểm mấu chốt ở đây là aggregation queries rất tốn kém. Đếm hàng triệu clicks grouped by date không phải thứ bạn muốn chạy mỗi lần admin load dashboard. Chúng ta sẽ dùng Redis caching với 5-minute TTL cho những phép tính nặng, để database chỉ bị hit một lần mỗi cache cycle.


Dashboard Metrics API

Dashboard endpoint là thứ đầu tiên admin nhìn thấy. Nó cần cho một cái nhìn tổng quan tức thì về system health — tổng số liệu, hoạt động hôm nay, và xu hướng so với hôm qua.

Response Interface

// src/types/admin.ts
interface DashboardMetrics {
  overview: {
    totalUsers: number;
    totalUrls: number;
    totalClicks: number;
    activeUrls: number;
    flaggedUrls: number;
    suspendedUsers: number;
  };
  today: {
    newUsers: number;
    newUrls: number;
    clicks: number;
  };
  trends: {
    usersChange: number;    // % thay đổi so với hôm qua
    urlsChange: number;
    clicksChange: number;
  };
}

Computing Dashboard Metrics

Với các phép đếm đơn giản, count() của Prisma là đủ. Nhưng để tính trend "hôm nay so với hôm qua", chúng ta cần vài queries phối hợp với nhau:

// src/services/analytics.service.ts
import { prisma } from '../config/database';
import { redis } from '../config/redis';
 
function startOfDay(date: Date): Date {
  const d = new Date(date);
  d.setHours(0, 0, 0, 0);
  return d;
}
 
function startOfYesterday(): Date {
  const d = new Date();
  d.setDate(d.getDate() - 1);
  d.setHours(0, 0, 0, 0);
  return d;
}
 
async function computeDashboardMetrics(): Promise<DashboardMetrics> {
  const now = new Date();
  const todayStart = startOfDay(now);
  const yesterdayStart = startOfYesterday();
 
  // Chạy tất cả queries song song cho nhanh
  const [
    totalUsers,
    totalUrls,
    totalClicks,
    activeUrls,
    flaggedUrls,
    suspendedUsers,
    todayUsers,
    todayUrls,
    todayClicks,
    yesterdayUsers,
    yesterdayUrls,
    yesterdayClicks,
  ] = await Promise.all([
    // Overview counts
    prisma.user.count(),
    prisma.url.count(),
    prisma.click.count(),
    prisma.url.count({ where: { isActive: true, expiresAt: { gt: now } } }),
    prisma.url.count({ where: { flagged: true } }),
    prisma.user.count({ where: { suspendedAt: { not: null } } }),
 
    // Số liệu hôm nay
    prisma.user.count({ where: { createdAt: { gte: todayStart } } }),
    prisma.url.count({ where: { createdAt: { gte: todayStart } } }),
    prisma.click.count({ where: { clickedAt: { gte: todayStart } } }),
 
    // Số liệu hôm qua (để tính trend)
    prisma.user.count({
      where: {
        createdAt: { gte: yesterdayStart, lt: todayStart },
      },
    }),
    prisma.url.count({
      where: {
        createdAt: { gte: yesterdayStart, lt: todayStart },
      },
    }),
    prisma.click.count({
      where: {
        clickedAt: { gte: yesterdayStart, lt: todayStart },
      },
    }),
  ]);
 
  // Tính phần trăm thay đổi
  const calcChange = (today: number, yesterday: number): number => {
    if (yesterday === 0) return today > 0 ? 100 : 0;
    return Math.round(((today - yesterday) / yesterday) * 100);
  };
 
  return {
    overview: {
      totalUsers,
      totalUrls,
      totalClicks,
      activeUrls,
      flaggedUrls,
      suspendedUsers,
    },
    today: {
      newUsers: todayUsers,
      newUrls: todayUrls,
      clicks: todayClicks,
    },
    trends: {
      usersChange: calcChange(todayUsers, yesterdayUsers),
      urlsChange: calcChange(todayUrls, yesterdayUrls),
      clicksChange: calcChange(todayClicks, yesterdayClicks),
    },
  };
}

Tổng cộng 12 queries, nhưng Promise.all chạy chúng đồng thời. PostgreSQL xử lý concurrent reads rất tốt — toàn bộ sẽ execute trong dưới 100ms với hầu hết datasets.

Caching với Redis

Chẳng ai muốn chạy 12 queries mỗi lần admin refresh dashboard cả. Cache 5 phút là sweet spot — đủ fresh để có ích, đủ stale để tiết kiệm database:

// src/services/analytics.service.ts
export async function getDashboardMetrics(): Promise<DashboardMetrics> {
  const cacheKey = 'admin:dashboard:metrics';
 
  // Thử cache trước
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
 
  // Tính metrics mới
  const metrics = await computeDashboardMetrics();
 
  // Cache 5 phút
  await redis.setex(cacheKey, 300, JSON.stringify(metrics));
 
  return metrics;
}

Dashboard Controller

// src/controllers/admin/analytics.controller.ts
import { Request, Response } from 'express';
import { getDashboardMetrics } from '../../services/analytics.service';
 
export async function getDashboard(req: Request, res: Response) {
  const metrics = await getDashboardMetrics();
 
  res.json({
    success: true,
    data: metrics,
  });
}

Route Registration

// src/routes/admin/analytics.routes.ts
import { Router } from 'express';
import { authenticate } from '../../middleware/auth';
import { requireRole } from '../../middleware/rbac';
import {
  getDashboard,
  getClickAnalytics,
  getUrlAnalytics,
  getTopUrls,
  getTopUsers,
  getTopReferrers,
  getGeoAnalytics,
  getDeviceAnalytics,
} from '../../controllers/admin/analytics.controller';
 
const router = Router();
 
// Tất cả routes yêu cầu admin role
router.use(authenticate, requireRole('ADMIN'));
 
router.get('/dashboard', getDashboard);
router.get('/analytics/clicks', getClickAnalytics);
router.get('/analytics/urls', getUrlAnalytics);
router.get('/analytics/top-urls', getTopUrls);
router.get('/analytics/top-users', getTopUsers);
router.get('/analytics/top-referrers', getTopReferrers);
router.get('/analytics/geo', getGeoAnalytics);
router.get('/analytics/devices', getDeviceAnalytics);
 
export default router;

Đăng ký vào admin router chính:

// src/routes/admin/index.ts
import analyticsRoutes from './analytics.routes';
 
adminRouter.use('/admin', analyticsRoutes);

Time-Series Analytics

Dashboard cho cái nhìn tổng quan. Time-series data kể một câu chuyện — platform đang tăng trưởng không? Có link viral nào gây traffic spike không? Clicks có giảm vào cuối tuần không?

Để group theo thời gian, Prisma API thông thường không hỗ trợ GROUP BY DATE(). Cần raw SQL:

// src/services/analytics.service.ts
 
interface TimeSeriesPoint {
  date: string;
  count: number;
}
 
interface ClickTimeSeriesPoint extends TimeSeriesPoint {
  uniqueVisitors: number;
}
 
function getPeriodStartDate(period: string): Date {
  const now = new Date();
  switch (period) {
    case '7d':
      now.setDate(now.getDate() - 7);
      break;
    case '30d':
      now.setDate(now.getDate() - 30);
      break;
    case '90d':
      now.setDate(now.getDate() - 90);
      break;
    default:
      now.setDate(now.getDate() - 30);
  }
  return now;
}
 
export async function getClickTimeSeries(
  period: string
): Promise<ClickTimeSeriesPoint[]> {
  const startDate = getPeriodStartDate(period);
  const cacheKey = `admin:analytics:clicks:${period}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const result = await prisma.$queryRaw<ClickTimeSeriesPoint[]>`
    SELECT
      TO_CHAR(DATE("clickedAt"), 'YYYY-MM-DD') as date,
      COUNT(*)::int as count,
      COUNT(DISTINCT "ipAddress")::int as "uniqueVisitors"
    FROM "Click"
    WHERE "clickedAt" >= ${startDate}
    GROUP BY DATE("clickedAt")
    ORDER BY DATE("clickedAt") ASC
  `;
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}

Cùng pattern, khác table:

// src/services/analytics.service.ts
export async function getUrlCreationTrends(
  period: string
): Promise<TimeSeriesPoint[]> {
  const startDate = getPeriodStartDate(period);
  const cacheKey = `admin:analytics:urls:${period}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const result = await prisma.$queryRaw<TimeSeriesPoint[]>`
    SELECT
      TO_CHAR(DATE("createdAt"), 'YYYY-MM-DD') as date,
      COUNT(*)::int as count
    FROM "Url"
    WHERE "createdAt" >= ${startDate}
    GROUP BY DATE("createdAt")
    ORDER BY DATE("createdAt") ASC
  `;
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}
// src/services/analytics.service.ts
export async function getUserGrowthTrends(
  period: string
): Promise<TimeSeriesPoint[]> {
  const startDate = getPeriodStartDate(period);
  const cacheKey = `admin:analytics:users:${period}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const result = await prisma.$queryRaw<TimeSeriesPoint[]>`
    SELECT
      TO_CHAR(DATE("createdAt"), 'YYYY-MM-DD') as date,
      COUNT(*)::int as count
    FROM "User"
    WHERE "createdAt" >= ${startDate}
    GROUP BY DATE("createdAt")
    ORDER BY DATE("createdAt") ASC
  `;
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}

Time-Series Controllers

// src/controllers/admin/analytics.controller.ts
import {
  getClickTimeSeries,
  getUrlCreationTrends,
  getUserGrowthTrends,
} from '../../services/analytics.service';
 
export async function getClickAnalytics(req: Request, res: Response) {
  const period = (req.query.period as string) || '30d';
 
  if (!['7d', '30d', '90d'].includes(period)) {
    return res.status(400).json({
      success: false,
      error: 'Invalid period. Use 7d, 30d, or 90d.',
    });
  }
 
  const data = await getClickTimeSeries(period);
 
  res.json({
    success: true,
    data: {
      period,
      points: data,
      total: data.reduce((sum, p) => sum + p.count, 0),
    },
  });
}
 
export async function getUrlAnalytics(req: Request, res: Response) {
  const period = (req.query.period as string) || '30d';
 
  if (!['7d', '30d', '90d'].includes(period)) {
    return res.status(400).json({
      success: false,
      error: 'Invalid period. Use 7d, 30d, or 90d.',
    });
  }
 
  const [urlTrends, userTrends] = await Promise.all([
    getUrlCreationTrends(period),
    getUserGrowthTrends(period),
  ]);
 
  res.json({
    success: true,
    data: {
      period,
      urls: urlTrends,
      users: userTrends,
    },
  });
}

Top Performers

Admins muốn biết cái gì đang hot. Link nào đang drive traffic nhiều nhất? Ai là power users? Traffic đến từ đâu?

Top URLs theo Clicks

// src/services/analytics.service.ts
 
interface TopUrl {
  id: string;
  shortCode: string;
  originalUrl: string;
  clicks: number;
  uniqueVisitors: number;
  createdAt: Date;
  user: {
    id: string;
    email: string;
  } | null;
}
 
export async function getTopUrls(
  limit: number = 10,
  period: string = '7d'
): Promise<TopUrl[]> {
  const startDate = getPeriodStartDate(period);
  const cacheKey = `admin:analytics:top-urls:${period}:${limit}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const result = await prisma.$queryRaw<TopUrl[]>`
    SELECT
      u."id",
      u."shortCode",
      u."originalUrl",
      u."createdAt",
      COUNT(c."id")::int as clicks,
      COUNT(DISTINCT c."ipAddress")::int as "uniqueVisitors",
      json_build_object('id', usr."id", 'email', usr."email") as "user"
    FROM "Url" u
    LEFT JOIN "Click" c ON c."urlId" = u."id" AND c."clickedAt" >= ${startDate}
    LEFT JOIN "User" usr ON usr."id" = u."userId"
    GROUP BY u."id", u."shortCode", u."originalUrl", u."createdAt", usr."id", usr."email"
    HAVING COUNT(c."id") > 0
    ORDER BY clicks DESC
    LIMIT ${limit}
  `;
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}

Top Users theo Activity

// src/services/analytics.service.ts
 
interface TopUser {
  id: string;
  email: string;
  urlCount: number;
  totalClicks: number;
  createdAt: Date;
}
 
export async function getTopUsers(limit: number = 10): Promise<TopUser[]> {
  const cacheKey = `admin:analytics:top-users:${limit}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const result = await prisma.$queryRaw<TopUser[]>`
    SELECT
      u."id",
      u."email",
      u."createdAt",
      COUNT(DISTINCT url."id")::int as "urlCount",
      COALESCE(SUM(url."clickCount"), 0)::int as "totalClicks"
    FROM "User" u
    LEFT JOIN "Url" url ON url."userId" = u."id"
    GROUP BY u."id", u."email", u."createdAt"
    ORDER BY "totalClicks" DESC
    LIMIT ${limit}
  `;
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}

Top Referrers

// src/services/analytics.service.ts
 
interface TopReferrer {
  referrer: string;
  clicks: number;
  uniqueVisitors: number;
}
 
export async function getTopReferrers(
  limit: number = 10,
  period: string = '30d'
): Promise<TopReferrer[]> {
  const startDate = getPeriodStartDate(period);
  const cacheKey = `admin:analytics:top-referrers:${period}:${limit}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const result = await prisma.$queryRaw<TopReferrer[]>`
    SELECT
      COALESCE("referrer", 'Direct') as referrer,
      COUNT(*)::int as clicks,
      COUNT(DISTINCT "ipAddress")::int as "uniqueVisitors"
    FROM "Click"
    WHERE "clickedAt" >= ${startDate}
    GROUP BY "referrer"
    ORDER BY clicks DESC
    LIMIT ${limit}
  `;
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}

Top Performers Controllers

// src/controllers/admin/analytics.controller.ts
import {
  getTopUrls as fetchTopUrls,
  getTopUsers as fetchTopUsers,
  getTopReferrers as fetchTopReferrers,
} from '../../services/analytics.service';
 
export async function getTopUrls(req: Request, res: Response) {
  const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
  const period = (req.query.period as string) || '7d';
 
  const data = await fetchTopUrls(limit, period);
 
  res.json({
    success: true,
    data,
  });
}
 
export async function getTopUsers(req: Request, res: Response) {
  const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
 
  const data = await fetchTopUsers(limit);
 
  res.json({
    success: true,
    data,
  });
}
 
export async function getTopReferrers(req: Request, res: Response) {
  const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
  const period = (req.query.period as string) || '30d';
 
  const data = await fetchTopReferrers(limit, period);
 
  res.json({
    success: true,
    data,
  });
}

Geographic & Device Analytics

Chúng ta đã capture geo và device data trong Click model từ Phase 4. Giờ chỉ cần aggregate ở system level cho admin dashboard.

Clicks theo Country

// src/services/analytics.service.ts
 
interface GeoData {
  country: string;
  clicks: number;
  uniqueVisitors: number;
  percentage: number;
}
 
export async function getGeoAnalytics(period: string = '30d'): Promise<GeoData[]> {
  const startDate = getPeriodStartDate(period);
  const cacheKey = `admin:analytics:geo:${period}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const rawData = await prisma.$queryRaw<Omit<GeoData, 'percentage'>[]>`
    SELECT
      COALESCE("country", 'Unknown') as country,
      COUNT(*)::int as clicks,
      COUNT(DISTINCT "ipAddress")::int as "uniqueVisitors"
    FROM "Click"
    WHERE "clickedAt" >= ${startDate}
    GROUP BY "country"
    ORDER BY clicks DESC
  `;
 
  const totalClicks = rawData.reduce((sum, row) => sum + row.clicks, 0);
 
  const result: GeoData[] = rawData.map((row) => ({
    ...row,
    percentage: totalClicks > 0
      ? Math.round((row.clicks / totalClicks) * 10000) / 100
      : 0,
  }));
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}

Device Type Breakdown

// src/services/analytics.service.ts
 
interface DeviceData {
  deviceType: string;
  browser: string;
  os: string;
  clicks: number;
  percentage: number;
}
 
export async function getDeviceAnalytics(
  period: string = '30d'
): Promise<{ byDevice: DeviceData[]; byBrowser: DeviceData[]; byOS: DeviceData[] }> {
  const startDate = getPeriodStartDate(period);
  const cacheKey = `admin:analytics:devices:${period}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const [byDevice, byBrowser, byOS] = await Promise.all([
    prisma.$queryRaw<{ deviceType: string; clicks: number }[]>`
      SELECT
        COALESCE("deviceType", 'Unknown') as "deviceType",
        COUNT(*)::int as clicks
      FROM "Click"
      WHERE "clickedAt" >= ${startDate}
      GROUP BY "deviceType"
      ORDER BY clicks DESC
    `,
    prisma.$queryRaw<{ browser: string; clicks: number }[]>`
      SELECT
        COALESCE("browser", 'Unknown') as browser,
        COUNT(*)::int as clicks
      FROM "Click"
      WHERE "clickedAt" >= ${startDate}
      GROUP BY "browser"
      ORDER BY clicks DESC
      LIMIT 10
    `,
    prisma.$queryRaw<{ os: string; clicks: number }[]>`
      SELECT
        COALESCE("os", 'Unknown') as os,
        COUNT(*)::int as clicks
      FROM "Click"
      WHERE "clickedAt" >= ${startDate}
      GROUP BY "os"
      ORDER BY clicks DESC
      LIMIT 10
    `,
  ]);
 
  const addPercentage = (items: { clicks: number }[]) => {
    const total = items.reduce((sum, item) => sum + item.clicks, 0);
    return items.map((item) => ({
      ...item,
      percentage: total > 0
        ? Math.round((item.clicks / total) * 10000) / 100
        : 0,
    }));
  };
 
  const result = {
    byDevice: addPercentage(byDevice),
    byBrowser: addPercentage(byBrowser),
    byOS: addPercentage(byOS),
  };
 
  await redis.setex(cacheKey, 300, JSON.stringify(result));
  return result;
}

Geo & Device Controllers

// src/controllers/admin/analytics.controller.ts
import {
  getGeoAnalytics as fetchGeoAnalytics,
  getDeviceAnalytics as fetchDeviceAnalytics,
} from '../../services/analytics.service';
 
export async function getGeoAnalytics(req: Request, res: Response) {
  const period = (req.query.period as string) || '30d';
 
  const data = await fetchGeoAnalytics(period);
 
  res.json({
    success: true,
    data,
  });
}
 
export async function getDeviceAnalytics(req: Request, res: Response) {
  const period = (req.query.period as string) || '30d';
 
  const data = await fetchDeviceAnalytics(period);
 
  res.json({
    success: true,
    data,
  });
}

Audit Log System

Analytics cho bạn biết users đang làm gì. Audit logs cho bạn biết admins đang làm gì. Mọi admin action — suspend user, disable URL, thay đổi role, chạy bulk operation — đều phải được ghi lại với:

  • Ai đã làm (actor)
  • Làm gì (action)
  • Với cái gì (target)
  • Khi nào (timestamp)
  • Thay đổi gì (before/after snapshot)

Đây là thứ không thể thiếu cho bất kỳ production admin system nào. Khi user complain "account tôi bị suspend vô lý", bạn cần pull audit log lên và chỉ ra chính xác chuyện gì đã xảy ra.

Prisma Schema

Thêm AuditLog model vào Prisma schema:

// prisma/schema.prisma
 
model AuditLog {
  id          String   @id @default(uuid())
  actorId     String
  actor       User     @relation("AuditLogActor", fields: [actorId], references: [id])
  action      String   // ví dụ: "user.suspend", "url.disable", "role.update"
  targetType  String   // "user", "url", "blocklist"
  targetId    String
  details     Json?    // { before: {...}, after: {...}, reason: "..." }
  ipAddress   String
  createdAt   DateTime @default(now())
 
  @@index([actorId])
  @@index([action])
  @@index([targetType, targetId])
  @@index([createdAt])
 
  @@map("audit_logs")
}

Đừng quên thêm reverse relation trên User model:

model User {
  // ... các fields hiện có
  auditLogs   AuditLog[] @relation("AuditLogActor")
}

Chạy migration:

npx prisma migrate dev --name add-audit-logs

Action Naming Convention

Dùng pattern resource.action nhất quán cho audit log actions:

// src/constants/audit-actions.ts
export const AuditActions = {
  // User management
  USER_SUSPEND: 'user.suspend',
  USER_UNSUSPEND: 'user.unsuspend',
  USER_DELETE: 'user.delete',
  ROLE_UPDATE: 'user.role.update',
 
  // URL moderation
  URL_DISABLE: 'url.disable',
  URL_ENABLE: 'url.enable',
  URL_FLAG: 'url.flag',
  URL_UNFLAG: 'url.unflag',
  URL_DELETE: 'url.delete',
 
  // Bulk operations
  BULK_DISABLE_URLS: 'url.bulk.disable',
  BULK_DELETE_URLS: 'url.bulk.delete',
  BULK_SUSPEND_USERS: 'user.bulk.suspend',
 
  // Blocklist
  BLOCKLIST_ADD: 'blocklist.add',
  BLOCKLIST_REMOVE: 'blocklist.remove',
 
  // System
  SYSTEM_CONFIG_UPDATE: 'system.config.update',
} as const;
 
export type AuditAction = (typeof AuditActions)[keyof typeof AuditActions];

Audit Logger Service

Audit logger là một service đơn giản mà controllers gọi sau khi thực hiện action. Nó cố tình được giữ thin — không nên có logic phức tạp giữa admin action và audit record.

// src/services/audit.service.ts
import { prisma } from '../config/database';
 
interface AuditEntry {
  actorId: string;
  action: string;
  targetType: string;
  targetId: string;
  details?: Record<string, unknown>;
  ipAddress: string;
}
 
export async function logAuditEvent(entry: AuditEntry): Promise<void> {
  try {
    await prisma.auditLog.create({
      data: {
        actorId: entry.actorId,
        action: entry.action,
        targetType: entry.targetType,
        targetId: entry.targetId,
        details: entry.details ?? undefined,
        ipAddress: entry.ipAddress,
      },
    });
  } catch (error) {
    // Log nhưng không throw — audit logging không bao giờ được làm hỏng main action
    console.error('Failed to write audit log:', error);
  }
}

Chú ý try/catch — cái này cực kỳ quan trọng. Nếu audit log write fail (database hiccup, constraint violation), admin action thực tế vẫn phải succeed. Audit logging là observability concern, không phải business logic concern. Log error rồi đi tiếp.

Capture Before/After State

Với những actions thay đổi data, capture before và after state trong details field:

// src/services/audit.service.ts
 
export function buildChangeDetails(
  before: Record<string, unknown>,
  after: Record<string, unknown>,
  extra?: Record<string, unknown>
): Record<string, unknown> {
  return {
    before,
    after,
    ...extra,
  };
}
 
// Ví dụ sử dụng:
const user = await prisma.user.findUnique({ where: { id: userId } });
const beforeState = { role: user.role, suspendedAt: user.suspendedAt };
 
await prisma.user.update({
  where: { id: userId },
  data: { suspendedAt: new Date() },
});
 
await logAuditEvent({
  actorId: req.user!.id,
  action: AuditActions.USER_SUSPEND,
  targetType: 'user',
  targetId: userId,
  details: buildChangeDetails(
    beforeState,
    { role: user.role, suspendedAt: new Date() },
    { reason: 'Spamming short links' }
  ),
  ipAddress: req.ip!,
});

Tích Hợp Audit Logs với Admin Actions Hiện Có

Giờ chúng ta retrofit audit logging vào các admin controllers từ Phase 10 và Phase 11. Xem nó trông thế nào với từng loại action.

User Suspension (từ Phase 10)

// src/controllers/admin/user.controller.ts
import { logAuditEvent, buildChangeDetails } from '../../services/audit.service';
import { AuditActions } from '../../constants/audit-actions';
 
export async function suspendUser(req: Request, res: Response) {
  const { userId } = req.params;
  const { reason } = req.body;
 
  const user = await prisma.user.findUnique({ where: { id: userId } });
  if (!user) {
    return res.status(404).json({ success: false, error: 'User not found' });
  }
 
  if (user.suspendedAt) {
    return res.status(400).json({ success: false, error: 'User already suspended' });
  }
 
  const beforeState = { suspendedAt: user.suspendedAt };
 
  const updatedUser = await prisma.user.update({
    where: { id: userId },
    data: { suspendedAt: new Date() },
  });
 
  // Audit log
  await logAuditEvent({
    actorId: req.user!.id,
    action: AuditActions.USER_SUSPEND,
    targetType: 'user',
    targetId: userId,
    details: buildChangeDetails(
      beforeState,
      { suspendedAt: updatedUser.suspendedAt },
      { reason }
    ),
    ipAddress: req.ip!,
  });
 
  res.json({ success: true, data: updatedUser });
}

Role Change (từ Phase 10)

// src/controllers/admin/user.controller.ts
export async function updateUserRole(req: Request, res: Response) {
  const { userId } = req.params;
  const { role } = req.body;
 
  const user = await prisma.user.findUnique({ where: { id: userId } });
  if (!user) {
    return res.status(404).json({ success: false, error: 'User not found' });
  }
 
  const beforeRole = user.role;
 
  const updatedUser = await prisma.user.update({
    where: { id: userId },
    data: { role },
  });
 
  // Audit log
  await logAuditEvent({
    actorId: req.user!.id,
    action: AuditActions.ROLE_UPDATE,
    targetType: 'user',
    targetId: userId,
    details: buildChangeDetails(
      { role: beforeRole },
      { role: updatedUser.role }
    ),
    ipAddress: req.ip!,
  });
 
  res.json({ success: true, data: updatedUser });
}

URL Moderation (từ Phase 11)

// src/controllers/admin/moderation.controller.ts
import { logAuditEvent, buildChangeDetails } from '../../services/audit.service';
import { AuditActions } from '../../constants/audit-actions';
 
export async function disableUrl(req: Request, res: Response) {
  const { urlId } = req.params;
  const { reason } = req.body;
 
  const url = await prisma.url.findUnique({ where: { id: urlId } });
  if (!url) {
    return res.status(404).json({ success: false, error: 'URL not found' });
  }
 
  const beforeState = { isActive: url.isActive };
 
  const updatedUrl = await prisma.url.update({
    where: { id: urlId },
    data: { isActive: false },
  });
 
  // Audit log
  await logAuditEvent({
    actorId: req.user!.id,
    action: AuditActions.URL_DISABLE,
    targetType: 'url',
    targetId: urlId,
    details: buildChangeDetails(
      beforeState,
      { isActive: false },
      { reason, shortCode: url.shortCode, originalUrl: url.originalUrl }
    ),
    ipAddress: req.ip!,
  });
 
  res.json({ success: true, data: updatedUrl });
}

Bulk Operations (từ Phase 11)

Với bulk operations, log một audit event cho mỗi item — không phải một cái cho cả batch. Điều này giúp tìm kiếm actions ảnh hưởng đến một URL hay user cụ thể dễ dàng hơn:

// src/controllers/admin/moderation.controller.ts
export async function bulkDisableUrls(req: Request, res: Response) {
  const { urlIds, reason } = req.body;
 
  // Lấy state hiện tại của tất cả URLs
  const urls = await prisma.url.findMany({
    where: { id: { in: urlIds } },
  });
 
  // Thực hiện bulk update
  await prisma.url.updateMany({
    where: { id: { in: urlIds } },
    data: { isActive: false },
  });
 
  // Log audit events cho từng URL (song song)
  await Promise.all(
    urls.map((url) =>
      logAuditEvent({
        actorId: req.user!.id,
        action: AuditActions.BULK_DISABLE_URLS,
        targetType: 'url',
        targetId: url.id,
        details: buildChangeDetails(
          { isActive: url.isActive },
          { isActive: false },
          { reason, shortCode: url.shortCode, bulkOperation: true }
        ),
        ipAddress: req.ip!,
      })
    )
  );
 
  res.json({
    success: true,
    data: { disabledCount: urls.length },
  });
}

Audit Log Query API

Admins cần search và filter audit logs — "cho xem tất cả role changes tuần qua" hoặc "admin X làm gì ngày hôm qua?"

List Audit Logs với Filters

// src/services/audit.service.ts
 
interface AuditLogQuery {
  actorId?: string;
  action?: string;
  targetType?: string;
  targetId?: string;
  startDate?: Date;
  endDate?: Date;
  page: number;
  limit: number;
}
 
interface PaginatedAuditLogs {
  logs: AuditLogWithActor[];
  total: number;
  page: number;
  totalPages: number;
}
 
interface AuditLogWithActor {
  id: string;
  actorId: string;
  actor: { id: string; email: string };
  action: string;
  targetType: string;
  targetId: string;
  details: Record<string, unknown> | null;
  ipAddress: string;
  createdAt: Date;
}
 
export async function queryAuditLogs(
  query: AuditLogQuery
): Promise<PaginatedAuditLogs> {
  const where: Record<string, unknown> = {};
 
  if (query.actorId) where.actorId = query.actorId;
  if (query.action) where.action = query.action;
  if (query.targetType) where.targetType = query.targetType;
  if (query.targetId) where.targetId = query.targetId;
 
  if (query.startDate || query.endDate) {
    where.createdAt = {};
    if (query.startDate) (where.createdAt as Record<string, Date>).gte = query.startDate;
    if (query.endDate) (where.createdAt as Record<string, Date>).lte = query.endDate;
  }
 
  const [logs, total] = await Promise.all([
    prisma.auditLog.findMany({
      where,
      include: {
        actor: {
          select: { id: true, email: true },
        },
      },
      orderBy: { createdAt: 'desc' },
      skip: (query.page - 1) * query.limit,
      take: query.limit,
    }),
    prisma.auditLog.count({ where }),
  ]);
 
  return {
    logs,
    total,
    page: query.page,
    totalPages: Math.ceil(total / query.limit),
  };
}

Audit Log Controller

// src/controllers/admin/audit.controller.ts
import { Request, Response } from 'express';
import { queryAuditLogs } from '../../services/audit.service';
 
export async function getAuditLogs(req: Request, res: Response) {
  const page = Math.max(parseInt(req.query.page as string) || 1, 1);
  const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
 
  const query = {
    actorId: req.query.actorId as string | undefined,
    action: req.query.action as string | undefined,
    targetType: req.query.targetType as string | undefined,
    targetId: req.query.targetId as string | undefined,
    startDate: req.query.startDate
      ? new Date(req.query.startDate as string)
      : undefined,
    endDate: req.query.endDate
      ? new Date(req.query.endDate as string)
      : undefined,
    page,
    limit,
  };
 
  const result = await queryAuditLogs(query);
 
  res.json({
    success: true,
    data: result.logs,
    pagination: {
      page: result.page,
      limit,
      total: result.total,
      totalPages: result.totalPages,
    },
  });
}
 
export async function getAuditLogById(req: Request, res: Response) {
  const { id } = req.params;
 
  const log = await prisma.auditLog.findUnique({
    where: { id },
    include: {
      actor: {
        select: { id: true, email: true },
      },
    },
  });
 
  if (!log) {
    return res.status(404).json({
      success: false,
      error: 'Audit log entry not found',
    });
  }
 
  res.json({
    success: true,
    data: log,
  });
}

Audit Log Routes

// src/routes/admin/audit.routes.ts
import { Router } from 'express';
import { authenticate } from '../../middleware/auth';
import { requireRole } from '../../middleware/rbac';
import { getAuditLogs, getAuditLogById } from '../../controllers/admin/audit.controller';
 
const router = Router();
 
router.use(authenticate, requireRole('ADMIN'));
 
router.get('/audit-logs', getAuditLogs);
router.get('/audit-logs/:id', getAuditLogById);
 
export default router;

Ví dụ API calls:

# Tất cả audit logs (phân trang)
GET /api/admin/audit-logs?page=1&limit=20
 
# Filter theo action type
GET /api/admin/audit-logs?action=user.suspend
 
# Filter theo admin actor
GET /api/admin/audit-logs?actorId=admin-uuid-here
 
# Filter theo target
GET /api/admin/audit-logs?targetType=url&targetId=url-uuid-here
 
# Filter theo date range
GET /api/admin/audit-logs?startDate=2026-03-01&endDate=2026-03-22
 
# Kết hợp filters
GET /api/admin/audit-logs?action=user.suspend&startDate=2026-03-15&limit=50
 
# Một log entry với đầy đủ details
GET /api/admin/audit-logs/log-uuid-here

Performance Considerations

Analytics và audit logs tăng rất nhanh. Một URL shortener phổ biến có thể generate hàng triệu clicks và hàng ngàn audit entries mỗi tháng. Đây là cách giữ mọi thứ nhanh.

Index Strategy

Chúng ta đã define indexes trong Prisma schema, nhưng hãy rõ ràng về tại sao mỗi index quan trọng:

model AuditLog {
  // ...
 
  // Tìm tất cả actions của một admin cụ thể
  @@index([actorId])
 
  // Filter theo action type (ví dụ: "cho xem tất cả user suspensions")
  @@index([action])
 
  // Tìm tất cả actions ảnh hưởng đến một entity cụ thể
  @@index([targetType, targetId])
 
  // Time-based queries (date range filtering, mới nhất trước)
  @@index([createdAt])
}

Với analytics queries, đảm bảo Click model có các indexes sau:

model Click {
  // ...
 
  // Time-series grouping theo date
  @@index([clickedAt])
 
  // Geo analytics
  @@index([country])
 
  // Device analytics
  @@index([deviceType])
  @@index([browser])
}

Archive Audit Logs Cũ

Sau 90 ngày, audit logs hiếm khi được truy cập nhưng vẫn chiếm space và làm chậm queries. Di chuyển chúng sang archive table:

// src/services/audit.service.ts
export async function archiveOldAuditLogs(): Promise<number> {
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - 90);
 
  // Di chuyển logs cũ sang archive table
  const archived = await prisma.$executeRaw`
    INSERT INTO "audit_logs_archive"
    SELECT * FROM "audit_logs"
    WHERE "createdAt" < ${cutoffDate}
  `;
 
  // Xóa entries đã archive khỏi main table
  await prisma.$executeRaw`
    DELETE FROM "audit_logs"
    WHERE "createdAt" < ${cutoffDate}
  `;
 
  console.log(`Archived ${archived} audit log entries`);
  return archived;
}

Bạn có thể chạy cái này như monthly cron job:

// src/cron/archive-audit-logs.ts
import cron from 'node-cron';
import { archiveOldAuditLogs } from '../services/audit.service';
 
// Chạy vào ngày 1 hàng tháng lúc 3 AM
cron.schedule('0 3 1 * *', async () => {
  console.log('Starting audit log archival...');
  const count = await archiveOldAuditLogs();
  console.log(`Archived ${count} audit log entries`);
});

Cache Invalidation Strategy

Với analytics cache, chúng ta dùng time-based expiry (5 phút). Nhưng khi admin muốn xem data mới nhất sau khi thực hiện action thì sao? Thêm option manual cache-busting:

// src/services/analytics.service.ts
export async function invalidateAnalyticsCache(): Promise<void> {
  const keys = await redis.keys('admin:analytics:*');
  const dashboardKeys = await redis.keys('admin:dashboard:*');
  const allKeys = [...keys, ...dashboardKeys];
 
  if (allKeys.length > 0) {
    await redis.del(...allKeys);
  }
}
// Thêm refresh endpoint
// src/controllers/admin/analytics.controller.ts
export async function refreshDashboard(req: Request, res: Response) {
  await invalidateAnalyticsCache();
 
  const metrics = await getDashboardMetrics();
 
  res.json({
    success: true,
    data: metrics,
    message: 'Cache cleared. Showing fresh data.',
  });
}

Những Lỗi Hay Gặp

1. Chạy Expensive Aggregations Mỗi Lần Load Dashboard

Dashboard query tính 12 counts và chạy time-series aggregations. Không có caching, mỗi lần admin refresh page là đập database với full-table scans.

Giải pháp: Luôn luôn cache analytics results. TTL 5 phút là mức hợp lý. Với data ít time-sensitive hơn (geo breakdown, device stats), có thể dùng TTL 15 phút hoặc thậm chí 1 giờ.

2. Không Index Audit Logs Table

Audit logs tăng nhanh lắm. Không có indexes phù hợp, queries kiểu "cho xem tất cả suspensions tuần này" sẽ thành full-table scans. Khi có 100K audit entries, query mất vài giây thay vì milliseconds.

Giải pháp: Index mọi column bạn filter — actorId, action, targetType + targetId (composite), và createdAt. Chúng ta đã define trong Prisma schema ở trên.

3. Log Sensitive Data trong Audit Details

Rất dễ bị cám dỗ dump toàn bộ entity vào details field cho before/after snapshot. Nhưng nếu bao gồm password hashes, API keys, hay tokens, bất kỳ ai có audit log access đều có thể extract chúng.

Giải pháp: Build explicit allow-list các fields cần capture:

// SAI - capture mọi thứ kể cả password hash
const details = { before: user, after: updatedUser };
 
// ĐÚNG - chỉ capture các fields an toàn
const details = buildChangeDetails(
  { role: user.role, suspendedAt: user.suspendedAt },
  { role: updatedUser.role, suspendedAt: updatedUser.suspendedAt }
);

4. Quên Xử Lý Timezone trong Date Grouping

DATE("clickedAt") dùng timezone của database server. Nếu server ở UTC nhưng admins ở UTC+7, ranh giới "hôm nay" lệch 7 tiếng. Một click lúc 11 PM giờ Việt Nam hiển thị là "ngày mai" trên dashboard.

Giải pháp: Dùng AT TIME ZONE trong PostgreSQL cho timezone-aware grouping:

SELECT
  DATE("clickedAt" AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Ho_Chi_Minh') as date,
  COUNT(*)::int as clicks
FROM "Click"
GROUP BY date
ORDER BY date ASC

Hoặc chấp nhận UTC làm standard và document rõ ràng trong API response.


Testing

Testing Dashboard Metrics

// src/__tests__/admin/analytics.test.ts
import request from 'supertest';
import { app } from '../../app';
import { prisma } from '../../config/database';
import { createTestUser, getAdminToken } from '../helpers';
 
describe('GET /api/admin/dashboard', () => {
  let adminToken: string;
 
  beforeAll(async () => {
    const admin = await createTestUser({ role: 'ADMIN' });
    adminToken = await getAdminToken(admin);
  });
 
  beforeEach(async () => {
    // Clear Redis cache
    const redis = (await import('../../config/redis')).redis;
    await redis.flushall();
  });
 
  it('should return dashboard metrics', async () => {
    // Seed test data
    const user = await createTestUser({ role: 'USER' });
    const url = await prisma.url.create({
      data: {
        shortCode: 'test123',
        originalUrl: 'https://example.com',
        userId: user.id,
      },
    });
    await prisma.click.create({
      data: {
        urlId: url.id,
        ipAddress: '127.0.0.1',
        userAgent: 'test-agent',
      },
    });
 
    const res = await request(app)
      .get('/api/admin/dashboard')
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(res.body.data.overview).toHaveProperty('totalUsers');
    expect(res.body.data.overview).toHaveProperty('totalUrls');
    expect(res.body.data.overview).toHaveProperty('totalClicks');
    expect(res.body.data.overview.totalUrls).toBeGreaterThanOrEqual(1);
    expect(res.body.data.overview.totalClicks).toBeGreaterThanOrEqual(1);
    expect(res.body.data.today).toHaveProperty('newUsers');
    expect(res.body.data.trends).toHaveProperty('clicksChange');
  });
 
  it('should require admin role', async () => {
    const user = await createTestUser({ role: 'USER' });
    const userToken = await getAdminToken(user);
 
    const res = await request(app)
      .get('/api/admin/dashboard')
      .set('Authorization', `Bearer ${userToken}`);
 
    expect(res.status).toBe(403);
  });
});

Testing Click Analytics

// src/__tests__/admin/analytics.test.ts
describe('GET /api/admin/analytics/clicks', () => {
  let adminToken: string;
 
  beforeAll(async () => {
    const admin = await createTestUser({ role: 'ADMIN' });
    adminToken = await getAdminToken(admin);
  });
 
  it('should return click time series for 7d period', async () => {
    const res = await request(app)
      .get('/api/admin/analytics/clicks?period=7d')
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(200);
    expect(res.body.data.period).toBe('7d');
    expect(Array.isArray(res.body.data.points)).toBe(true);
    expect(res.body.data).toHaveProperty('total');
  });
 
  it('should reject invalid period', async () => {
    const res = await request(app)
      .get('/api/admin/analytics/clicks?period=999d')
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(400);
  });
});

Testing Audit Log Recording

// src/__tests__/admin/audit.test.ts
import { logAuditEvent, queryAuditLogs } from '../../services/audit.service';
import { AuditActions } from '../../constants/audit-actions';
import { prisma } from '../../config/database';
import { createTestUser } from '../helpers';
 
describe('Audit Log Service', () => {
  it('should record an audit event', async () => {
    const admin = await createTestUser({ role: 'ADMIN' });
    const user = await createTestUser({ role: 'USER' });
 
    await logAuditEvent({
      actorId: admin.id,
      action: AuditActions.USER_SUSPEND,
      targetType: 'user',
      targetId: user.id,
      details: {
        before: { suspendedAt: null },
        after: { suspendedAt: new Date().toISOString() },
        reason: 'Spam activity',
      },
      ipAddress: '192.168.1.1',
    });
 
    const log = await prisma.auditLog.findFirst({
      where: {
        actorId: admin.id,
        action: AuditActions.USER_SUSPEND,
      },
    });
 
    expect(log).toBeDefined();
    expect(log!.targetType).toBe('user');
    expect(log!.targetId).toBe(user.id);
    expect(log!.details).toHaveProperty('reason', 'Spam activity');
  });
 
  it('should query audit logs with filters', async () => {
    const admin = await createTestUser({ role: 'ADMIN' });
 
    // Tạo nhiều audit log entries
    await Promise.all([
      logAuditEvent({
        actorId: admin.id,
        action: AuditActions.USER_SUSPEND,
        targetType: 'user',
        targetId: 'user-1',
        ipAddress: '192.168.1.1',
      }),
      logAuditEvent({
        actorId: admin.id,
        action: AuditActions.URL_DISABLE,
        targetType: 'url',
        targetId: 'url-1',
        ipAddress: '192.168.1.1',
      }),
      logAuditEvent({
        actorId: admin.id,
        action: AuditActions.USER_SUSPEND,
        targetType: 'user',
        targetId: 'user-2',
        ipAddress: '192.168.1.1',
      }),
    ]);
 
    // Filter theo action
    const suspensions = await queryAuditLogs({
      action: AuditActions.USER_SUSPEND,
      page: 1,
      limit: 10,
    });
 
    expect(suspensions.logs.length).toBeGreaterThanOrEqual(2);
    suspensions.logs.forEach((log) => {
      expect(log.action).toBe(AuditActions.USER_SUSPEND);
    });
  });
});

Testing Audit Log API

// src/__tests__/admin/audit.test.ts
describe('GET /api/admin/audit-logs', () => {
  let adminToken: string;
 
  beforeAll(async () => {
    const admin = await createTestUser({ role: 'ADMIN' });
    adminToken = await getAdminToken(admin);
  });
 
  it('should list audit logs with pagination', async () => {
    const res = await request(app)
      .get('/api/admin/audit-logs?page=1&limit=10')
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(Array.isArray(res.body.data)).toBe(true);
    expect(res.body.pagination).toHaveProperty('page', 1);
    expect(res.body.pagination).toHaveProperty('totalPages');
  });
 
  it('should filter by action type', async () => {
    const res = await request(app)
      .get(`/api/admin/audit-logs?action=${AuditActions.USER_SUSPEND}`)
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(200);
    res.body.data.forEach((log: { action: string }) => {
      expect(log.action).toBe(AuditActions.USER_SUSPEND);
    });
  });
 
  it('should return single audit log entry', async () => {
    // Tạo log entry trước
    const admin = await createTestUser({ role: 'ADMIN' });
    await logAuditEvent({
      actorId: admin.id,
      action: AuditActions.URL_DISABLE,
      targetType: 'url',
      targetId: 'test-url-id',
      details: { reason: 'Malicious content' },
      ipAddress: '10.0.0.1',
    });
 
    const entry = await prisma.auditLog.findFirst({
      where: { actorId: admin.id },
    });
 
    const res = await request(app)
      .get(`/api/admin/audit-logs/${entry!.id}`)
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(200);
    expect(res.body.data.id).toBe(entry!.id);
    expect(res.body.data.details).toHaveProperty('reason');
  });
});

Tiếp Theo

Giờ chúng ta đã có analytics và audit system hoàn chỉnh:

  • Dashboard metrics cho admins cái nhìn tổng quan tức thì về system health
  • Time-series analytics hiển thị growth trends và traffic patterns
  • Top performers highlight content và users có impact cao nhất
  • Audit logs tạo accountability trail cho mọi admin action

Tất cả đều là backend-only — JSON APIs mà dashboard có thể consume. Trong Phase 13, chúng ta sẽ build React Admin UI để biến những APIs này thành charts, tables, và interactive filters trực quan. Sẽ dùng React với charting library để visualize click trends, hiển thị analytics dashboard, và tạo searchable audit log viewer.


Series: Xây Dựng URL Shortener
Trước đó: Phase 11: URL Moderation & Bulk Operations
Tiếp theo: Phase 13: React Admin UI

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.