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?
Click Trends
Để 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;
}URL Creation Trends
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;
}User Growth Trends
// 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-logsAction 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-herePerformance 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 ASCHoặ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.