Files
goodgo-platform/apps/web/lib/admin-api.ts
Ho Ngoc Hai 4f19c97fd0 feat(web): admin flagged-listings moderation dashboard (GOO-160)
Add admin dashboard pages for reviewing and acting on user-flagged listings,
backed by the GOO-159 admin-moderation API surface (list/detail/moderate).

Pages:
- /admin/moderation/flagged — paginated, URL-synced filterable table with
  sortable columns (flag count, latest flag, created at), reason/status/date
  filters, bulk select + sticky action bar (dismiss / suspend / warn).
- /admin/moderation/flagged/[id] — listing summary, photo grid, seller card,
  full reporter list, per-listing action buttons mirroring bulk actions.

All actions go through a confirmation Dialog with optional moderator note.
Vietnamese UI throughout. Loading / empty / error / optimistic states
covered. Imports the existing admin layout via the (admin) route group.

Adds typed API surface in lib/admin-api.ts:
  getFlaggedListings, getFlaggedListingDetail, moderateFlaggedListings
plus FlagReason / FlagStatus / FlaggedAction / FlaggedSortBy and
request/response interfaces.

E2E (Playwright) at e2e/web/admin-moderation-flagged.spec.ts mocks the three
endpoints and covers: list filter + bulk dismiss happy path, detail view
with reporters + suspend action, and the empty state.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 12:46:54 +07:00

370 lines
9.7 KiB
TypeScript

import { apiClient } from './api-client';
// ── Types ──
export interface DashboardStats {
totalUsers: number;
totalListings: number;
activeListings: number;
pendingModerationCount: number;
totalAgents: number;
verifiedAgents: number;
totalTransactions: number;
newUsersLast30Days: number;
newListingsLast30Days: number;
}
export interface RevenueStatsItem {
period: string;
totalRevenue: number;
subscriptionRevenue: number;
listingFeeRevenue: number;
featuredListingRevenue: number;
transactionCount: number;
}
export interface ModerationQueueItem {
listingId: string;
propertyTitle: string;
propertyType: string;
transactionType: string;
priceVND: number;
sellerName: string;
sellerId: string;
moderationScore: number | null;
createdAt: string;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface UserListItem {
id: string;
email: string | null;
phone: string;
fullName: string;
role: string;
kycStatus: string;
isActive: boolean;
createdAt: string;
}
export interface UserDetail {
id: string;
email: string | null;
phone: string;
fullName: string;
avatarUrl: string | null;
role: string;
kycStatus: string;
kycData: unknown;
isActive: boolean;
createdAt: string;
updatedAt: string;
listingsCount: number;
activeListingsCount: number;
transactionsCount: number;
subscription: {
planTier: string;
status: string;
currentPeriodEnd: string;
} | null;
recentActivity: Array<{
type: string;
description: string;
createdAt: string;
}>;
}
export interface AiSettings {
apiUrl: string;
apiKeyMasked: string | null;
model: string;
hasApiKey: boolean;
updatedAt: string | null;
}
export interface UpdateAiSettingsPayload {
apiUrl?: string;
apiKey?: string;
model?: string;
}
export interface KycQueueItem {
userId: string;
fullName: string;
email: string | null;
phone: string;
role: string;
kycStatus: string;
kycData: unknown;
createdAt: string;
}
export interface AuditLogItem {
id: string;
actorId: string;
actorName: string;
actorRole: string;
action: string;
module: string;
targetId: string | null;
targetType: string | null;
severity: 'info' | 'warning' | 'critical';
before: unknown;
after: unknown;
ipAddress: string | null;
createdAt: string;
}
// ── API ──
export const adminApi = {
// Dashboard
getDashboardStats: () =>
apiClient.get<DashboardStats>('/admin/dashboard'),
getRevenueStats: (startDate: string, endDate: string, groupBy: 'day' | 'month' = 'month') =>
apiClient.get<RevenueStatsItem[]>(
`/admin/revenue?startDate=${startDate}&endDate=${endDate}&groupBy=${groupBy}`,
),
// Moderation
getModerationQueue: (page = 1, limit = 20) =>
apiClient.get<PaginatedResult<ModerationQueueItem>>(
`/admin/moderation?page=${page}&limit=${limit}`,
),
approveListing: (listingId: string, moderationNotes?: string) =>
apiClient.post<{ success: boolean }>('/admin/moderation/approve', {
listingId,
moderationNotes,
}),
rejectListing: (listingId: string, reason: string) =>
apiClient.post<{ success: boolean }>('/admin/moderation/reject', {
listingId,
reason,
}),
bulkModerate: (listingIds: string[], action: 'approve' | 'reject', reason?: string) =>
apiClient.post<{ success: boolean }>('/admin/moderation/bulk', {
listingIds,
action,
reason,
}),
// Users
getUsers: (params: { page?: number; limit?: number; role?: string; isActive?: boolean; search?: string } = {}) => {
const query = new URLSearchParams();
if (params.page) query.set('page', String(params.page));
if (params.limit) query.set('limit', String(params.limit));
if (params.role) query.set('role', params.role);
if (params.isActive !== undefined) query.set('isActive', String(params.isActive));
if (params.search) query.set('search', params.search);
return apiClient.get<PaginatedResult<UserListItem>>(
`/admin/users?${query.toString()}`,
);
},
getUserDetail: (userId: string) =>
apiClient.get<UserDetail>(`/admin/users/${userId}`),
updateUserStatus: (userId: string, isActive: boolean, reason?: string) =>
apiClient.post<{ success: boolean }>('/admin/users/status', {
userId,
isActive,
reason,
}),
banUser: (userId: string, reason: string, unban = false) =>
apiClient.post<{ success: boolean }>('/admin/users/ban', {
userId,
reason,
unban,
}),
// KYC
getKycQueue: (page = 1, limit = 20) =>
apiClient.get<PaginatedResult<KycQueueItem>>(
`/admin/kyc?page=${page}&limit=${limit}`,
),
approveKyc: (userId: string, notes?: string) =>
apiClient.post<{ success: boolean }>('/admin/kyc/approve', {
userId,
notes,
}),
rejectKyc: (userId: string, reason: string) =>
apiClient.post<{ success: boolean }>('/admin/kyc/reject', {
userId,
reason,
}),
// Audit logs
getAuditLogs: (params: {
page?: number;
limit?: number;
module?: string;
actorId?: string;
severity?: string;
from?: string;
to?: string;
} = {}) => {
const query = new URLSearchParams();
if (params.page) query.set('page', String(params.page));
if (params.limit) query.set('limit', String(params.limit));
if (params.module) query.set('module', params.module);
if (params.actorId) query.set('actorId', params.actorId);
if (params.severity) query.set('severity', params.severity);
if (params.from) query.set('from', params.from);
if (params.to) query.set('to', params.to);
return apiClient.get<PaginatedResult<AuditLogItem>>(
`/admin/audit-logs?${query.toString()}`,
);
},
// AI Settings
getAiSettings: () => apiClient.get<AiSettings>('/admin/settings/ai'),
updateAiSettings: (body: UpdateAiSettingsPayload) =>
apiClient.patch<AiSettings>('/admin/settings/ai', body),
// B2B account provisioning
provisionDeveloper: (body: ProvisionDeveloperPayload) =>
apiClient.post<ProvisionAccountResult>('/admin/accounts/developers', body),
provisionParkOperator: (body: ProvisionParkOperatorPayload) =>
apiClient.post<ProvisionAccountResult>('/admin/accounts/park-operators', body),
// ── Flagged listings (user reports) ──
getFlaggedListings: (params: FlaggedListingsQueryParams = {}) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));
if (params.limit) q.set('limit', String(params.limit));
if (params.reason) q.set('reason', params.reason);
if (params.dateFrom) q.set('dateFrom', params.dateFrom);
if (params.dateTo) q.set('dateTo', params.dateTo);
if (params.listingStatus) q.set('listingStatus', params.listingStatus);
if (params.flagCountMin !== undefined) q.set('flagCountMin', String(params.flagCountMin));
if (params.sortBy) q.set('sortBy', params.sortBy);
if (params.sortOrder) q.set('sortOrder', params.sortOrder);
const qs = q.toString();
return apiClient.get<PaginatedFlaggedListingsResult>(
`/admin/listings/flagged${qs ? `?${qs}` : ''}`,
);
},
getFlaggedListingDetail: (listingId: string) =>
apiClient.get<FlaggedListingDetail>(`/admin/listings/${listingId}/flags`),
moderateFlaggedListings: (body: ModerateFlaggedListingsPayload) =>
apiClient.post<ModerateFlaggedListingsResult>('/admin/listings/moderate', body),
};
// ── Flagged listings types ──
export type FlagReason = 'SCAM' | 'DUPLICATE' | 'WRONG_INFO' | 'ALREADY_SOLD' | 'INAPPROPRIATE';
export type FlagStatus = 'PENDING' | 'REVIEWED' | 'DISMISSED';
export type FlaggedAction = 'dismiss_flags' | 'suspend_listing' | 'warn_seller';
export type FlaggedSortBy = 'flagCount' | 'latestFlagAt' | 'createdAt';
export interface FlaggedListingsQueryParams {
page?: number;
limit?: number;
reason?: FlagReason;
dateFrom?: string;
dateTo?: string;
listingStatus?: string;
flagCountMin?: number;
sortBy?: FlaggedSortBy;
sortOrder?: 'asc' | 'desc';
}
export interface FlaggedListingItem {
listingId: string;
propertyTitle: string;
sellerName: string;
status: string;
totalReports: number;
reasons: FlagReason[];
latestReportAt: string;
}
export interface PaginatedFlaggedListingsResult {
items: FlaggedListingItem[];
total: number;
page: number;
limit: number;
}
export interface FlaggedListingDetailFlag {
id: string;
reason: FlagReason;
description: string | null;
status: FlagStatus;
reporter: { id: string; fullName: string };
createdAt: string;
}
export interface FlaggedListingDetail {
listing: {
id: string;
title: string;
status: string;
priceVND: number;
propertyType: string;
transactionType: string;
photos: string[];
createdAt: string;
seller: { id: string; fullName: string; email: string | null; phone: string };
};
flags: FlaggedListingDetailFlag[];
totalReports: number;
distinctReasons: FlagReason[];
}
export interface ModerateFlaggedListingsPayload {
listingIds: string[];
action: FlaggedAction;
note?: string;
}
export interface ModerateFlaggedListingsResult {
processed: number;
skipped: number;
succeeded?: string[];
failed?: Array<{ listingId: string; reason: string }>;
}
export interface ProvisionDeveloperPayload {
phone: string;
password: string;
fullName: string;
email?: string;
projectIds?: string[];
}
export interface ProvisionParkOperatorPayload {
phone: string;
password: string;
fullName: string;
email?: string;
parkIds?: string[];
}
export interface ProvisionAccountResult {
userId: string;
phone: string;
email: string | null;
fullName: string;
linkedProjectIds?: string[];
linkedParkIds?: string[];
}