Commit be29c7a3 authored by ThinhNC's avatar ThinhNC

fix: resolve 20 audit findings and stabilize BFF proxy integration

parent 8d925115
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js ."
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
......
...@@ -13,17 +13,14 @@ import { Button } from "@/components/ui/button"; ...@@ -13,17 +13,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { getSafeRedirectUrl } from "@/lib/utils";
function LoginForm() { function LoginForm() {
const { t } = useLanguage(); const { t } = useLanguage();
const { login } = useAuth(); const { login } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const rawRedirect = searchParams.get("redirect") || "/"; const redirectUrl = getSafeRedirectUrl(searchParams.get("redirect"), "/");
// Never redirect back to auth pages to prevent loops
const redirectUrl =
rawRedirect.startsWith("/login") || rawRedirect.startsWith("/register")
? "/"
: rawRedirect;
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null); const [authError, setAuthError] = useState<string | null>(null);
...@@ -45,8 +42,8 @@ function LoginForm() { ...@@ -45,8 +42,8 @@ function LoginForm() {
setAuthError(null); setAuthError(null);
try { try {
await login(data, data.rememberMe ?? true); await login(data, data.rememberMe ?? true);
// Force hard reload navigation so Next.js server components and middleware receive fresh HttpOnly cookies router.replace(redirectUrl);
window.location.href = redirectUrl; router.refresh();
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
setAuthError(err.message); setAuthError(err.message);
......
...@@ -26,12 +26,13 @@ function VerifyEmailContent() { ...@@ -26,12 +26,13 @@ function VerifyEmailContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tokenParam = searchParams.get("token"); const tokenParam = searchParams.get("token");
const [status, setStatus] = useState<VerifyStatus>("idle"); const [status, setStatus] = useState<VerifyStatus>(tokenParam ? "verifying" : "idle");
const [errorMessage, setErrorMessage] = useState<string>(""); const [errorMessage, setErrorMessage] = useState<string>("");
const [manualToken, setManualToken] = useState(""); const [manualToken, setManualToken] = useState("");
const [resendEmail, setResendEmail] = useState(""); const [resendEmail, setResendEmail] = useState("");
const [isResending, setIsResending] = useState(false); const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false); const [resendSuccess, setResendSuccess] = useState(false);
const verifiedRef = React.useRef(false);
const handleVerify = useCallback(async (tokenToVerify: string) => { const handleVerify = useCallback(async (tokenToVerify: string) => {
if (!tokenToVerify) return; if (!tokenToVerify) return;
...@@ -52,10 +53,25 @@ function VerifyEmailContent() { ...@@ -52,10 +53,25 @@ function VerifyEmailContent() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (tokenParam) { if (!tokenParam || verifiedRef.current) return;
handleVerify(tokenParam); verifiedRef.current = true;
}
}, [tokenParam, handleVerify]); let isMounted = true;
authService.verifyEmail(tokenParam)
.then(() => {
if (isMounted) setStatus("success");
})
.catch((err: unknown) => {
if (isMounted) {
setStatus("error");
setErrorMessage(err instanceof Error ? err.message : "Mã kích hoạt không hợp lệ hoặc đã hết hạn.");
}
});
return () => {
isMounted = false;
};
}, [tokenParam]);
const handleManualSubmit = (e: React.FormEvent) => { const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
......
...@@ -41,7 +41,7 @@ async function handleProxy( ...@@ -41,7 +41,7 @@ async function handleProxy(
} }
let body: BodyInit | undefined = undefined; let body: BodyInit | undefined = undefined;
if (["POST", "PUT", "PATCH"].includes(request.method)) { if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) {
const rawBody = await request.arrayBuffer(); const rawBody = await request.arrayBuffer();
if (rawBody.byteLength > 0) { if (rawBody.byteLength > 0) {
body = Buffer.from(rawBody); body = Buffer.from(rawBody);
......
...@@ -114,6 +114,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -114,6 +114,11 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
const wasRunningRef = useRef<boolean>(false); const wasRunningRef = useRef<boolean>(false);
const hasNotifiedRef = useRef<boolean>(false); const hasNotifiedRef = useRef<boolean>(false);
const isJobInitialActive = !initialJob || initialJob.status === "RUNNING" || initialJob.status === "PROCESSING_EXPORT" || initialJob.status === "PENDING";
const isJobActive = liveProgressJob
? liveProgressJob.status === "RUNNING" || liveProgressJob.status === "PROCESSING_EXPORT" || liveProgressJob.status === "PENDING"
: isJobInitialActive;
const { const {
status: sseStatus, status: sseStatus,
retryCount: sseRetryCount, retryCount: sseRetryCount,
...@@ -122,7 +127,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -122,7 +127,7 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
} = useEventSource<CrawlJob>( } = useEventSource<CrawlJob>(
jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null, jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null,
{ {
enabled: Boolean(jobId), enabled: Boolean(jobId) && isJobActive,
onInitial: (data) => setLiveProgressJob(data), onInitial: (data) => setLiveProgressJob(data),
onProgress: (data) => { onProgress: (data) => {
setLiveProgressJob(data); setLiveProgressJob(data);
...@@ -179,18 +184,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -179,18 +184,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
} }
}, [job?.status, job?.id, job?.successPages, job?.totalPages, job?.domain, t.notifications]); }, [job?.status, job?.id, job?.successPages, job?.totalPages, job?.domain, t.notifications]);
// Query Logs (active when on logs tab) // Query Logs (active when on logs tab and job is active)
const { data: logsData, isLoading: isLogsLoading, refetch: refetchLogs } = useCrawlJobLogs( const { data: logsData, isLoading: isLogsLoading, refetch: refetchLogs } = useCrawlJobLogs(
jobId, jobId,
{ limit: 100 }, { limit: 100 },
{ refetchInterval: activeTab === "logs" ? 4000 : false } { refetchInterval: activeTab === "logs" && isJobActive ? 4000 : false }
); );
// Query Crawled Pages (active when on pages tab, auto-poll while running) // Query Crawled Pages (active when on pages tab, auto-poll while running)
const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages( const { data: pagesData, isLoading: isPagesLoading, refetch: refetchPages } = useCrawlJobPages(
jobId, jobId,
{ limit: 50 }, { limit: 50 },
{ refetchInterval: activeTab === "pages" && (liveProgressJob?.status === "RUNNING" || initialJob?.status === "RUNNING") ? 3000 : false } { refetchInterval: activeTab === "pages" && isJobActive ? 3000 : false }
); );
// Query Diff Report (active when on diff tab) // Query Diff Report (active when on diff tab)
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
import { ApiKey } from "@/types/developer"; import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal"; import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CopyButton } from "@/components/common/copy-button"; import { CopyButton } from "@/components/common/copy-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function ApiKeysPage() { export default function ApiKeysPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
...@@ -19,6 +20,7 @@ export default function ApiKeysPage() { ...@@ -19,6 +20,7 @@ export default function ApiKeysPage() {
const toggleApiKeyMutation = useToggleApiKey(); const toggleApiKeyMutation = useToggleApiKey();
const revokeApiKeyMutation = useRevokeApiKey(); const revokeApiKeyMutation = useRevokeApiKey();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\ const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\ -H "X-API-Key: dc_live_your_secret_key_here" \\
...@@ -51,8 +53,13 @@ console.log('Job Created:', response.data);`; ...@@ -51,8 +53,13 @@ console.log('Job Created:', response.data);`;
}; };
const handleRevokeKey = (key: ApiKey) => { const handleRevokeKey = (key: ApiKey) => {
if (window.confirm(`${t.developer.apiKeys.table.revokeConfirm}\n("${key.name}")`)) { setKeyToRevoke(key);
revokeApiKeyMutation.mutate(key.id); };
const confirmRevokeKey = () => {
if (keyToRevoke) {
revokeApiKeyMutation.mutate(keyToRevoke.id);
setKeyToRevoke(null);
} }
}; };
...@@ -257,6 +264,25 @@ console.log('Job Created:', response.data);`; ...@@ -257,6 +264,25 @@ console.log('Job Created:', response.data);`;
isOpen={isKeyModalOpen} isOpen={isKeyModalOpen}
onClose={() => setIsKeyModalOpen(false)} onClose={() => setIsKeyModalOpen(false)}
/> />
{/* Revoke Confirmation Dialog */}
<ConfirmDialog
open={!!keyToRevoke}
onOpenChange={(open) => !open && setKeyToRevoke(null)}
title={t.developer.apiKeys.table.revoke}
description={
keyToRevoke ? (
<span>
{t.developer.apiKeys.table.revokeConfirm}:{" "}
<strong className="text-foreground">{keyToRevoke.name}</strong>
</span>
) : undefined
}
confirmText={t.developer.apiKeys.table.revoke}
variant="destructive"
isLoading={revokeApiKeyMutation.isPending}
onConfirm={confirmRevokeKey}
/>
</div> </div>
); );
} }
...@@ -29,6 +29,7 @@ import { WebhookConfig, WebhookDelivery } from "@/types/developer"; ...@@ -29,6 +29,7 @@ import { WebhookConfig, WebhookDelivery } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal"; import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
import { CopyButton } from "@/components/common/copy-button"; import { CopyButton } from "@/components/common/copy-button";
import { JsonViewer } from "@/components/common/json-viewer"; import { JsonViewer } from "@/components/common/json-viewer";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export default function WebhooksPage() { export default function WebhooksPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
...@@ -41,6 +42,7 @@ export default function WebhooksPage() { ...@@ -41,6 +42,7 @@ export default function WebhooksPage() {
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false); const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null); const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null); const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null);
const [webhookToDelete, setWebhookToDelete] = useState<WebhookConfig | null>(null);
const deliveries = deliveriesData?.items || []; const deliveries = deliveriesData?.items || [];
...@@ -49,8 +51,13 @@ export default function WebhooksPage() { ...@@ -49,8 +51,13 @@ export default function WebhooksPage() {
}; };
const handleDeleteWebhook = (wh: WebhookConfig) => { const handleDeleteWebhook = (wh: WebhookConfig) => {
if (window.confirm(`${t.developer.webhooks.table.deleteConfirm}\n("${wh.url}")`)) { setWebhookToDelete(wh);
deleteWebhookMutation.mutate(wh.id); };
const confirmDeleteWebhook = () => {
if (webhookToDelete) {
deleteWebhookMutation.mutate(webhookToDelete.id);
setWebhookToDelete(null);
} }
}; };
......
...@@ -53,8 +53,12 @@ export default function ProfileSettingsPage() { ...@@ -53,8 +53,12 @@ export default function ProfileSettingsPage() {
const revokeSessionsMutation = useRevokeAllSessions(); const revokeSessionsMutation = useRevokeAllSessions();
// Form states // Form states
const [fullName, setFullName] = useState(user?.fullName || ""); const [userEnteredFullName, setUserEnteredFullName] = useState<string | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatarUrl || null); const fullName = userEnteredFullName ?? (profile?.fullName ?? user?.fullName ?? "");
const [localAvatarPreview, setLocalAvatarPreview] = useState<string | null>(null);
const avatarPreview = localAvatarPreview ?? (profile?.avatarUrl ?? user?.avatarUrl ?? null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Copy ID state // Copy ID state
...@@ -73,21 +77,6 @@ export default function ProfileSettingsPage() { ...@@ -73,21 +77,6 @@ export default function ProfileSettingsPage() {
const [deactivatePassword, setDeactivatePassword] = useState(""); const [deactivatePassword, setDeactivatePassword] = useState("");
const [showDeactivatePassword, setShowDeactivatePassword] = useState(false); const [showDeactivatePassword, setShowDeactivatePassword] = useState(false);
// Keep state synced with user profile
React.useEffect(() => {
if (profile) {
if (profile.fullName !== undefined && profile.fullName !== null) {
setFullName(profile.fullName);
}
if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl);
} else if (user) {
if (user.fullName !== undefined && user.fullName !== null) {
setFullName(user.fullName);
}
if (user.avatarUrl) setAvatarPreview(user.avatarUrl);
}
}, [profile, user]);
// Compute if personal profile has changes // Compute if personal profile has changes
const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim(); const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim();
const isProfileChanged = const isProfileChanged =
...@@ -112,7 +101,7 @@ export default function ProfileSettingsPage() { ...@@ -112,7 +101,7 @@ export default function ProfileSettingsPage() {
// Instant local preview // Instant local preview
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
setAvatarPreview(reader.result as string); setLocalAvatarPreview(reader.result as string);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
...@@ -124,12 +113,19 @@ export default function ProfileSettingsPage() { ...@@ -124,12 +113,19 @@ export default function ProfileSettingsPage() {
const handleUpdateProfile = (e: React.FormEvent) => { const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isProfileChanged || updateProfileMutation.isPending) return; if (!isProfileChanged || updateProfileMutation.isPending) return;
updateProfileMutation.mutate({ fullName: fullName.trim() }); updateProfileMutation.mutate(
{ fullName: fullName.trim() },
{
onSuccess: () => {
setUserEnteredFullName(null);
},
}
);
}; };
// Revert Profile Name Handler // Revert Profile Name Handler
const handleCancelUpdateProfile = () => { const handleCancelUpdateProfile = () => {
setFullName(profile?.fullName ?? user?.fullName ?? ""); setUserEnteredFullName(null);
}; };
// Change Password Handler // Change Password Handler
...@@ -341,7 +337,7 @@ export default function ProfileSettingsPage() { ...@@ -341,7 +337,7 @@ export default function ProfileSettingsPage() {
<Input <Input
id="prof-name" id="prof-name"
value={fullName} value={fullName}
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setUserEnteredFullName(e.target.value)}
placeholder={t.profile.personal.namePlaceholder} placeholder={t.profile.personal.namePlaceholder}
className="rounded-2xl text-xs" className="rounded-2xl text-xs"
/> />
......
...@@ -9,6 +9,7 @@ import { AuditLogItem, AuditLogQuery } from "@/types/audit-log"; ...@@ -9,6 +9,7 @@ import { AuditLogItem, AuditLogQuery } from "@/types/audit-log";
import { AuditLogsFilters, DatePreset } from "./audit-logs-filters"; import { AuditLogsFilters, DatePreset } from "./audit-logs-filters";
import { AuditLogsTable } from "./audit-logs-table"; import { AuditLogsTable } from "./audit-logs-table";
import { AuditLogDetailDrawer } from "./audit-log-detail-drawer"; import { AuditLogDetailDrawer } from "./audit-log-detail-drawer";
import { getLocalDateRangeISO } from "@/lib/utils";
interface AuditLogsViewProps { interface AuditLogsViewProps {
showHeader?: boolean; showHeader?: boolean;
...@@ -59,10 +60,11 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) { ...@@ -59,10 +60,11 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
const now = new Date(); const now = new Date();
if (datePreset === "today") { if (datePreset === "today") {
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
return { return {
queryStartDate: startOfDay.toISOString(), queryStartDate: startOfDay.toISOString(),
queryEndDate: now.toISOString(), queryEndDate: endOfDay.toISOString(),
}; };
} }
if (datePreset === "7d") { if (datePreset === "7d") {
...@@ -80,9 +82,10 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) { ...@@ -80,9 +82,10 @@ export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
}; };
} }
if (datePreset === "custom") { if (datePreset === "custom") {
const { startDate: qStart, endDate: qEnd } = getLocalDateRangeISO(startDate, endDate);
return { return {
queryStartDate: startDate ? new Date(startDate).toISOString() : undefined, queryStartDate: qStart,
queryEndDate: endDate ? new Date(`${endDate}T23:59:59.999Z`).toISOString() : undefined, queryEndDate: qEnd,
}; };
} }
......
...@@ -6,14 +6,12 @@ import { usePathname } from "next/navigation"; ...@@ -6,14 +6,12 @@ import { usePathname } from "next/navigation";
import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react"; import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
const emptySubscribe = () => () => {};
export function BottomNav() { export function BottomNav() {
const { t } = useLanguage(); const { t } = useLanguage();
const pathname = usePathname(); const pathname = usePathname();
const [mounted, setMounted] = useState(false); const mounted = React.useSyncExternalStore(emptySubscribe, () => true, () => false);
useEffect(() => {
setMounted(true);
}, []);
const navItems = [ const navItems = [
{ {
......
...@@ -99,13 +99,13 @@ export function SafeHtmlPreview({ ...@@ -99,13 +99,13 @@ export function SafeHtmlPreview({
<ShieldCheck className="h-3.5 w-3.5 shrink-0" /> <ShieldCheck className="h-3.5 w-3.5 shrink-0" />
<span>Cô lập bảo mật Sandbox &amp; DOMPurify (Chống XSS)</span> <span>Cô lập bảo mật Sandbox &amp; DOMPurify (Chống XSS)</span>
</div> </div>
<span className="text-[10px] text-muted-foreground font-mono">sandbox=&quot;allow-same-origin&quot;</span> <span className="text-[10px] text-muted-foreground font-mono">sandbox=&quot;&quot; (Strict Isolation)</span>
</div> </div>
<iframe <iframe
title={title} title={title}
srcDoc={srcDoc} srcDoc={srcDoc}
sandbox="allow-same-origin" sandbox=""
className="w-full border-0 transition-all bg-background" className="w-full border-0 transition-all bg-background"
style={{ height: maxHeight }} style={{ height: maxHeight }}
/> />
......
...@@ -121,6 +121,9 @@ export function UserMenu() { ...@@ -121,6 +121,9 @@ export function UserMenu() {
{/* Trigger Button */} {/* Trigger Button */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label={user.fullName || user.email}
className="flex items-center gap-2 rounded-2xl border border-border/80 bg-card/60 p-1.5 pl-2 pr-2.5 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/30" className="flex items-center gap-2 rounded-2xl border border-border/80 bg-card/60 p-1.5 pl-2 pr-2.5 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
> >
{/* Avatar */} {/* Avatar */}
...@@ -146,7 +149,11 @@ export function UserMenu() { ...@@ -146,7 +149,11 @@ export function UserMenu() {
{/* Dropdown Menu */} {/* Dropdown Menu */}
{isOpen && ( {isOpen && (
<div className="absolute right-0 mt-2 w-64 rounded-3xl border border-emerald-500/15 bg-card/95 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150"> <div
role="menu"
aria-orientation="vertical"
className="absolute right-0 mt-2 w-64 rounded-3xl border border-emerald-500/15 bg-card/95 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150"
>
{/* User Header */} {/* User Header */}
<div className="rounded-2xl border border-border/60 bg-muted/40 p-3 mb-2"> <div className="rounded-2xl border border-border/60 bg-muted/40 p-3 mb-2">
<p className="font-semibold text-xs text-foreground truncate"> <p className="font-semibold text-xs text-foreground truncate">
...@@ -170,6 +177,7 @@ export function UserMenu() { ...@@ -170,6 +177,7 @@ export function UserMenu() {
<div className="space-y-1"> <div className="space-y-1">
<Link <Link
href="/settings/profile" href="/settings/profile"
role="menuitem"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
...@@ -180,6 +188,7 @@ export function UserMenu() { ...@@ -180,6 +188,7 @@ export function UserMenu() {
{canAccessDeveloper && ( {canAccessDeveloper && (
<Link <Link
href={developerHref} href={developerHref}
role="menuitem"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
...@@ -194,6 +203,7 @@ export function UserMenu() { ...@@ -194,6 +203,7 @@ export function UserMenu() {
{/* Logout Action */} {/* Logout Action */}
<button <button
role="menuitem"
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
logout(); logout();
......
...@@ -158,15 +158,8 @@ export function CrawlerTaskTable() { ...@@ -158,15 +158,8 @@ export function CrawlerTaskTable() {
}; };
const jobs = jobsData?.items || []; const jobs = jobsData?.items || [];
const rawTotal = const total = jobsData?.total !== undefined && jobsData.total > 0 ? jobsData.total : jobs.length;
jobsData?.total ?? const totalPages = Math.max(1, jobsData?.totalPages ?? Math.ceil(total / limit));
(jobsData as unknown as { meta?: { total?: number } })?.meta?.total;
const total =
rawTotal !== undefined && rawTotal > 0 ? rawTotal : jobs.length;
const rawTotalPages =
jobsData?.totalPages ??
(jobsData as unknown as { meta?: { totalPages?: number } })?.meta?.totalPages;
const totalPages = Math.max(1, rawTotalPages ?? Math.ceil(total / limit));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
......
...@@ -2,12 +2,18 @@ ...@@ -2,12 +2,18 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import { import {
X,
KeyRound, KeyRound,
Sparkles, Sparkles,
ChevronDown, ChevronDown,
Check, Check,
} from "lucide-react"; } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -66,8 +72,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -66,8 +72,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
...@@ -90,34 +94,26 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -90,34 +94,26 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200"> <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<div className="relative w-full max-w-lg flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden"> <DialogContent className="max-w-lg rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60"> <DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<div className="flex items-center gap-3"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"> <KeyRound className="h-5 w-5" />
<KeyRound className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.developer.apiKeys.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.modal.desc}
</p>
</div>
</div> </div>
<button <div>
onClick={handleClose} <DialogTitle className="text-base font-bold tracking-tight text-foreground">
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer" {t.developer.apiKeys.modal.title}
> </DialogTitle>
<X className="h-4 w-4" /> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
</button> {t.developer.apiKeys.modal.desc}
</div> </DialogDescription>
</div>
</DialogHeader>
{/* Content: Form or Secret Key View */} {/* Content: Form or Secret Key View */}
{createdKeyData ? ( {createdKeyData ? (
<div className="py-4 space-y-4"> <div className="py-2 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2"> <div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs"> <div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
...@@ -157,7 +153,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -157,7 +153,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div> </div>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4"> <form onSubmit={handleSubmit} className="py-2 space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-xs font-semibold"> <Label htmlFor="api-key-name" className="text-xs font-semibold">
{t.developer.apiKeys.modal.nameLabel} {t.developer.apiKeys.modal.nameLabel}
...@@ -227,7 +223,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -227,7 +223,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onClose} onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer" className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
> >
{t.developer.apiKeys.modal.cancel} {t.developer.apiKeys.modal.cancel}
...@@ -244,7 +240,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -244,7 +240,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div> </div>
</form> </form>
)} )}
</div> </DialogContent>
</div> </Dialog>
); );
} }
...@@ -2,12 +2,18 @@ ...@@ -2,12 +2,18 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { import {
X,
Webhook, Webhook,
Check, Check,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -82,8 +88,6 @@ export function CreateWebhookModal({ ...@@ -82,8 +88,6 @@ export function CreateWebhookModal({
onClose(); onClose();
}; };
if (!isOpen) return null;
const toggleEvent = (eventVal: string) => { const toggleEvent = (eventVal: string) => {
setSelectedEvents((prev) => setSelectedEvents((prev) =>
prev.includes(eventVal) prev.includes(eventVal)
...@@ -123,40 +127,32 @@ export function CreateWebhookModal({ ...@@ -123,40 +127,32 @@ export function CreateWebhookModal({
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200"> <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden"> <DialogContent className="max-w-xl rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 max-h-[90vh] overflow-y-auto">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60"> <DialogHeader className="flex flex-row items-center gap-3 space-y-0 text-left pb-4 border-b border-border/60">
<div className="flex items-center gap-3"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"> <Webhook className="h-5 w-5" />
<Webhook className="h-5 w-5" /> </div>
</div> <div>
<div> <DialogTitle className="text-base font-bold tracking-tight text-foreground">
<h2 className="text-base font-bold tracking-tight text-foreground"> {createdSecretData
{createdSecretData ? t.developer.webhooks.modal.newSecretNotice.title
? t.developer.webhooks.modal.newSecretNotice.title : isEditing
: isEditing ? t.developer.webhooks.modal.editTitle
? t.developer.webhooks.modal.editTitle : t.developer.webhooks.modal.title}
: t.developer.webhooks.modal.title} </DialogTitle>
</h2> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
<p className="text-xs text-muted-foreground"> {createdSecretData
{createdSecretData ? t.developer.webhooks.modal.newSecretNotice.desc
? t.developer.webhooks.modal.newSecretNotice.desc : t.developer.webhooks.modal.desc}
: t.developer.webhooks.modal.desc} </DialogDescription>
</p>
</div>
</div> </div>
<button </DialogHeader>
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content: Form or 1-Time Secret View */} {/* Content: Form or 1-Time Secret View */}
{createdSecretData ? ( {createdSecretData ? (
<div className="py-4 space-y-4"> <div className="py-2 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2"> <div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs"> <div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
...@@ -198,106 +194,106 @@ export function CreateWebhookModal({ ...@@ -198,106 +194,106 @@ export function CreateWebhookModal({
</div> </div>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4"> <form onSubmit={handleSubmit} className="py-2 space-y-4">
{/* Endpoint URL */} {/* Endpoint URL */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.urlLabel}
</Label>
<Input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t.developer.webhooks.modal.urlPlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
{/* HMAC Secret */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> <Label className="text-xs font-semibold">
{t.developer.webhooks.modal.secretLabel} {t.developer.webhooks.modal.urlLabel}
</Label> </Label>
<button <Input
type="button" type="url"
onClick={generateRandomSecret} value={url}
className="inline-flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer" onChange={(e) => setUrl(e.target.value)}
> placeholder={t.developer.webhooks.modal.urlPlaceholder}
<RefreshCw className="h-3 w-3" /> className="rounded-2xl text-xs"
<span>{t.developer.webhooks.modal.generateSecret}</span> required
</button> />
</div> </div>
<Input
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={isEditing ? "Giữ nguyên nếu không muốn đổi khóa..." : t.developer.webhooks.modal.secretPlaceholder}
className="rounded-2xl font-mono text-xs"
minLength={isEditing ? 0 : 16}
/>
</div>
{/* Events Multi-select */} {/* HMAC Secret */}
<div className="space-y-2 pt-2 border-t border-border/60"> <div className="space-y-1.5">
<Label className="text-xs font-semibold"> <div className="flex items-center justify-between">
{t.developer.webhooks.modal.eventsLabel} <Label className="text-xs font-semibold">
</Label> {t.developer.webhooks.modal.secretLabel}
<div className="space-y-2"> </Label>
{AVAILABLE_EVENTS.map((ev) => { <button
const isChecked = selectedEvents.includes(ev.value); type="button"
return ( onClick={generateRandomSecret}
<label className="inline-flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer"
key={ev.value} >
onClick={() => toggleEvent(ev.value)} <RefreshCw className="h-3 w-3" />
className={`flex items-center justify-between p-2.5 rounded-2xl border transition-colors cursor-pointer select-none ${ <span>{t.developer.webhooks.modal.generateSecret}</span>
isChecked </button>
? "border-emerald-500/40 bg-emerald-500/10 text-foreground" </div>
: "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground" <Input
}`} value={secret}
> onChange={(e) => setSecret(e.target.value)}
<div> placeholder={isEditing ? "Giữ nguyên nếu không muốn đổi khóa..." : t.developer.webhooks.modal.secretPlaceholder}
<p className="text-xs font-semibold text-foreground font-mono">{ev.value}</p> className="rounded-2xl font-mono text-xs"
<p className="text-[11px] text-muted-foreground">{ev[locale === "vi" ? "labelVi" : "labelEn"]}</p> minLength={isEditing ? 0 : 16}
</div> />
<div </div>
className={`h-4 w-4 rounded-md border flex items-center justify-center transition-colors ${
{/* Events Multi-select */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.eventsLabel}
</Label>
<div className="space-y-2">
{AVAILABLE_EVENTS.map((ev) => {
const isChecked = selectedEvents.includes(ev.value);
return (
<label
key={ev.value}
onClick={() => toggleEvent(ev.value)}
className={`flex items-center justify-between p-2.5 rounded-2xl border transition-colors cursor-pointer select-none ${
isChecked isChecked
? "bg-emerald-600 border-emerald-600 text-white" ? "border-emerald-500/40 bg-emerald-500/10 text-foreground"
: "border-border" : "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground"
}`} }`}
> >
{isChecked && <Check className="h-3 w-3" />} <div>
</div> <p className="text-xs font-semibold text-foreground font-mono">{ev.value}</p>
</label> <p className="text-[11px] text-muted-foreground">{ev[locale === "vi" ? "labelVi" : "labelEn"]}</p>
); </div>
})} <div
className={`h-4 w-4 rounded-md border flex items-center justify-center transition-colors ${
isChecked
? "bg-emerald-600 border-emerald-600 text-white"
: "border-border"
}`}
>
{isChecked && <Check className="h-3 w-3" />}
</div>
</label>
);
})}
</div>
</div> </div>
</div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60"> <div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={handleClose} onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer" className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
> >
{t.developer.webhooks.modal.cancel} {t.developer.webhooks.modal.cancel}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSubmitting || !url.trim() || selectedEvents.length === 0} disabled={isSubmitting || !url.trim() || selectedEvents.length === 0}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer" className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
> >
{isSubmitting {isSubmitting
? t.developer.webhooks.modal.submitting ? t.developer.webhooks.modal.submitting
: t.developer.webhooks.modal.submit} : t.developer.webhooks.modal.submit}
</Button> </Button>
</div> </div>
</form> </form>
)} )}
</div> </DialogContent>
</div> </Dialog>
); );
} }
...@@ -82,16 +82,12 @@ export function CreateExportModal({ ...@@ -82,16 +82,12 @@ export function CreateExportModal({
const { data: jobsData } = useCrawlJobs({ limit: 10 }); const { data: jobsData } = useCrawlJobs({ limit: 10 });
const createExportMutation = useCreateCrawlExport(); const createExportMutation = useCreateCrawlExport();
const [selectedJobId, setSelectedJobId] = useState(defaultJobId); const [userSelectedJobId, setUserSelectedJobId] = useState<string | null>(null);
const selectedJobId = userSelectedJobId ?? defaultJobId;
const setSelectedJobId = (id: string) => setUserSelectedJobId(id);
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV"); const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState(""); const [customFileName, setCustomFileName] = useState("");
useEffect(() => {
if (defaultJobId) {
setSelectedJobId(defaultJobId);
}
}, [defaultJobId, isOpen]);
const jobs = jobsData?.items || []; const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED"); const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId); const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId);
...@@ -166,7 +162,7 @@ export function CreateExportModal({ ...@@ -166,7 +162,7 @@ export function CreateExportModal({
<button <button
key={job.id} key={job.id}
type="button" type="button"
onClick={() => setSelectedJobId(job.id)} onClick={() => setUserSelectedJobId(job.id)}
className={`w-full flex items-center justify-between p-2.5 rounded-2xl border text-left text-xs transition-colors cursor-pointer ${ className={`w-full flex items-center justify-between p-2.5 rounded-2xl border text-left text-xs transition-colors cursor-pointer ${
selectedJobId === job.id selectedJobId === job.id
? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm" ? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm"
......
...@@ -129,24 +129,26 @@ export function PermissionMatrixDialog({ ...@@ -129,24 +129,26 @@ export function PermissionMatrixDialog({
// Mutation to save matrix: PUT /roles/:id/permissions // Mutation to save matrix: PUT /roles/:id/permissions
const setPermissionsMutation = useSetRolePermissions(); const setPermissionsMutation = useSetRolePermissions();
// Local state of selected permission IDs // User modified permission IDs (or null if using server assigned permissions)
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [userSelectedIds, setUserSelectedIds] = useState<string[] | null>(null);
const [lastRoleId, setLastRoleId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// Sync initial permissions into local state with equality check if (role?.id !== lastRoleId) {
useEffect(() => { setLastRoleId(role?.id || null);
if (!isOpen || !rolePermissions) return; setUserSelectedIds(null);
const initialIds = rolePermissions.map((p) => p.id); }
setSelectedIds((prev) => {
if ( const rolePermIds = useMemo(() => rolePermissions?.map((p) => p.id) || [], [rolePermissions]);
prev.length === initialIds.length && const selectedIds = userSelectedIds ?? rolePermIds;
prev.every((id) => initialIds.includes(id))
) { const setSelectedIds = (action: string[] | ((prev: string[]) => string[])) => {
return prev; if (typeof action === "function") {
} setUserSelectedIds(action(selectedIds));
return initialIds; } else {
}); setUserSelectedIds(action);
}, [isOpen, rolePermissions]); }
};
// Handle escape key // Handle escape key
useEffect(() => { useEffect(() => {
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { X, ShieldPlus, Edit3, Sparkles, Gauge, Sliders, ChevronDown } from "lucide-react"; import { X, ShieldPlus, Edit3, Sparkles, Gauge, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -11,8 +11,8 @@ import { useLanguage } from "@/providers/language-provider"; ...@@ -11,8 +11,8 @@ import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role"; import { RoleItem } from "@/types/role";
import { useCreateRole, useUpdateRole } from "@/hooks/use-roles"; import { useCreateRole, useUpdateRole } from "@/hooks/use-roles";
import { import {
createRoleSchema, roleFormSchema,
CreateRoleInput, RoleFormInput,
} from "@/schemas/role.schema"; } from "@/schemas/role.schema";
interface RoleFormModalProps { interface RoleFormModalProps {
...@@ -21,7 +21,7 @@ interface RoleFormModalProps { ...@@ -21,7 +21,7 @@ interface RoleFormModalProps {
roleToEdit?: RoleItem | null; roleToEdit?: RoleItem | null;
} }
type RoleFormData = CreateRoleInput & { syncUsersQuota?: boolean }; type RoleFormData = RoleFormInput;
export function RoleFormModal({ export function RoleFormModal({
isOpen, isOpen,
...@@ -43,7 +43,7 @@ export function RoleFormModal({ ...@@ -43,7 +43,7 @@ export function RoleFormModal({
reset, reset,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<RoleFormData>({ } = useForm<RoleFormData>({
resolver: zodResolver(createRoleSchema) as any, resolver: zodResolver(roleFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",
slug: "", slug: "",
...@@ -118,7 +118,7 @@ export function RoleFormModal({ ...@@ -118,7 +118,7 @@ export function RoleFormModal({
if (!isOpen) return null; if (!isOpen) return null;
const onSubmit = async (data: RoleFormData) => { const onSubmit = async (data: RoleFormData) => {
const parseOptionalNumber = (val: any) => { const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) { if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null; return null;
} }
......
...@@ -22,21 +22,20 @@ export function RoleResetQuotaDialog({ ...@@ -22,21 +22,20 @@ export function RoleResetQuotaDialog({
const resetMutation = useResetRoleQuota(); const resetMutation = useResetRoleQuota();
const [syncLimits, setSyncLimits] = useState(false); const [syncLimits, setSyncLimits] = useState(false);
useEffect(() => {
if (isOpen) {
setSyncLimits(false);
}
}, [isOpen]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) { if (e.key === "Escape" && isOpen) {
onClose(); handleClose();
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]); }, [isOpen]);
const handleClose = () => {
setSyncLimits(false);
onClose();
};
if (!isOpen || !role) return null; if (!isOpen || !role) return null;
...@@ -45,7 +44,7 @@ export function RoleResetQuotaDialog({ ...@@ -45,7 +44,7 @@ export function RoleResetQuotaDialog({
id: role.id, id: role.id,
payload: { syncLimits }, payload: { syncLimits },
}); });
onClose(); handleClose();
}; };
return ( return (
......
...@@ -77,9 +77,12 @@ export function RolesManagementView({ ...@@ -77,9 +77,12 @@ export function RolesManagementView({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [search]); }, [search]);
useEffect(() => { const [prevFilterKey, setPrevFilterKey] = useState("");
const currentFilterKey = `${debouncedSearch}|${selectedStatus}|${selectedType}`;
if (currentFilterKey !== prevFilterKey) {
setPrevFilterKey(currentFilterKey);
setPage(1); setPage(1);
}, [debouncedSearch, selectedStatus, selectedType]); }
// Click outside dropdowns // Click outside dropdowns
useEffect(() => { useEffect(() => {
......
...@@ -72,7 +72,11 @@ export function SystemConfigFormModal({ ...@@ -72,7 +72,11 @@ export function SystemConfigFormModal({
const categoryRef = useRef<HTMLDivElement>(null); const categoryRef = useRef<HTMLDivElement>(null);
// Detect and initialize values when modal opens or config changes // Detect and initialize values when modal opens or config changes
useEffect(() => { const [lastConfigKey, setLastConfigKey] = useState<string | null>(null);
const currentKey = isOpen ? (config?.key || "__new__") : "__closed__";
if (currentKey !== lastConfigKey) {
setLastConfigKey(currentKey);
if (config) { if (config) {
setKey(config.key); setKey(config.key);
setDescription(config.description || ""); setDescription(config.description || "");
...@@ -106,7 +110,7 @@ export function SystemConfigFormModal({ ...@@ -106,7 +110,7 @@ export function SystemConfigFormModal({
setIsPublic(false); setIsPublic(false);
} }
setValidationError(null); setValidationError(null);
}, [config, isOpen]); }
// Click outside category dropdown // Click outside category dropdown
useEffect(() => { useEffect(() => {
......
...@@ -114,13 +114,13 @@ export function SystemConfigsManagementView({ ...@@ -114,13 +114,13 @@ export function SystemConfigsManagementView({
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
if (!data?.items) return []; if (!data?.items) return [];
return [...data.items].sort((a, b) => { return [...data.items].sort((a, b) => {
let aVal: any = a[sortBy as keyof SystemConfigItem]; const aVal = a[sortBy as keyof SystemConfigItem];
let bVal: any = b[sortBy as keyof SystemConfigItem]; const bVal = b[sortBy as keyof SystemConfigItem];
if (typeof aVal === "string") { if (typeof aVal === "string") {
return sortOrder === "asc" return sortOrder === "asc"
? aVal.localeCompare(String(bVal)) ? aVal.localeCompare(String(bVal ?? ""))
: String(bVal).localeCompare(aVal); : String(bVal ?? "").localeCompare(aVal);
} }
if (typeof aVal === "boolean") { if (typeof aVal === "boolean") {
return sortOrder === "asc" return sortOrder === "asc"
...@@ -259,19 +259,21 @@ export function SystemConfigsManagementView({ ...@@ -259,19 +259,21 @@ export function SystemConfigsManagementView({
{/* Category Pills */} {/* Category Pills */}
<div className="flex items-center gap-1 overflow-x-auto pb-1 md:pb-0"> <div className="flex items-center gap-1 overflow-x-auto pb-1 md:pb-0">
{[ {(
{ key: "ALL", label: t.systemConfigs.tabs.all }, [
{ key: "GENERAL", label: t.systemConfigs.tabs.general }, { key: "ALL", label: t.systemConfigs.tabs.all },
{ key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag }, { key: "GENERAL", label: t.systemConfigs.tabs.general },
{ key: "INTEGRATION", label: t.systemConfigs.tabs.integration }, { key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag },
{ key: "SECURITY", label: t.systemConfigs.tabs.security }, { key: "INTEGRATION", label: t.systemConfigs.tabs.integration },
].map((tab) => { { key: "SECURITY", label: t.systemConfigs.tabs.security },
] as const
).map((tab) => {
const isSelected = selectedCategory === tab.key; const isSelected = selectedCategory === tab.key;
return ( return (
<button <button
key={tab.key} key={tab.key}
onClick={() => { onClick={() => {
setSelectedCategory(tab.key as any); setSelectedCategory(tab.key);
setPage(1); setPage(1);
}} }}
className={`rounded-2xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all cursor-pointer ${ className={`rounded-2xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all cursor-pointer ${
......
"use client";
import React from "react";
import { AlertTriangle, AlertCircle, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
confirmText?: string;
cancelText?: string;
variant?: "destructive" | "default";
isLoading?: boolean;
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmText,
cancelText,
variant = "destructive",
isLoading = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { locale } = useLanguage();
const defaultConfirmText =
confirmText ??
(variant === "destructive"
? locale === "vi"
? "Xác nhận xóa"
: "Confirm Delete"
: locale === "vi"
? "Xác nhận"
: "Confirm");
const defaultCancelText =
cancelText ?? (locale === "vi" ? "Hủy bỏ" : "Cancel");
const handleClose = () => {
if (isLoading) return;
onOpenChange(false);
onCancel?.();
};
const handleConfirm = async () => {
await onConfirm();
};
const isDestructive = variant === "destructive";
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className={`sm:max-w-[440px] rounded-3xl border bg-card/95 backdrop-blur-xl p-6 shadow-2xl ${
isDestructive
? "border-rose-500/20 shadow-rose-950/20"
: "border-emerald-500/20 shadow-emerald-950/20"
}`}
>
<DialogHeader className="space-y-3">
<div
className={`mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border ${
isDestructive
? "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
}`}
>
{isDestructive ? (
<AlertTriangle className="h-6 w-6" />
) : (
<AlertCircle className="h-6 w-6" />
)}
</div>
<DialogTitle className="text-center text-lg font-bold text-foreground">
{title}
</DialogTitle>
{description && (
<DialogDescription className="text-center text-xs text-muted-foreground leading-relaxed">
{description}
</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="flex flex-col sm:flex-row gap-2 mt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="w-full sm:w-1/2 rounded-2xl border-border/80 text-xs font-semibold hover:bg-muted/70 cursor-pointer"
>
{defaultCancelText}
</Button>
<Button
type="button"
variant={isDestructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={isLoading}
className={`w-full sm:w-1/2 rounded-2xl text-xs font-bold shadow-md cursor-pointer ${
isDestructive
? "bg-rose-600 hover:bg-rose-700 text-white shadow-rose-600/20"
: "bg-emerald-600 hover:bg-emerald-700 text-white shadow-emerald-600/20"
}`}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
defaultConfirmText
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
...@@ -141,7 +141,7 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...@@ -141,7 +141,7 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
const onSubmit = async (data: CreateUserInput) => { const onSubmit = async (data: CreateUserInput) => {
const parseOptionalNumber = (val: any) => { const parseOptionalNumber = (val: unknown) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) { if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null; return null;
} }
......
...@@ -22,21 +22,20 @@ export function UserResetQuotaDialog({ ...@@ -22,21 +22,20 @@ export function UserResetQuotaDialog({
const resetMutation = useResetUserQuota(); const resetMutation = useResetUserQuota();
const [resetLimitsToRole, setResetLimitsToRole] = useState(false); const [resetLimitsToRole, setResetLimitsToRole] = useState(false);
useEffect(() => {
if (isOpen) {
setResetLimitsToRole(false);
}
}, [isOpen]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) { if (e.key === "Escape" && isOpen) {
onClose(); handleClose();
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]); }, [isOpen]);
const handleClose = () => {
setResetLimitsToRole(false);
onClose();
};
if (!isOpen || !user) return null; if (!isOpen || !user) return null;
...@@ -45,7 +44,7 @@ export function UserResetQuotaDialog({ ...@@ -45,7 +44,7 @@ export function UserResetQuotaDialog({
id: user.id, id: user.id,
payload: { resetLimitsToRole }, payload: { resetLimitsToRole },
}); });
onClose(); handleClose();
}; };
return ( return (
......
...@@ -70,8 +70,6 @@ export function useCreateCrawlJob() { ...@@ -70,8 +70,6 @@ export function useCreateCrawlJob() {
}); });
} }
const rerunInFlight = new Set<string>();
/** /**
* Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun) * Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun)
*/ */
...@@ -79,18 +77,8 @@ export function useRerunCrawlJob() { ...@@ -79,18 +77,8 @@ export function useRerunCrawlJob() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (id: string) => { mutationFn: (id: string) => crawlJobService.rerunJob(id),
if (rerunInFlight.has(id)) { onSuccess: (job: CrawlJob) => {
return null as unknown as CrawlJob;
}
rerunInFlight.add(id);
try {
return await crawlJobService.rerunJob(id);
} finally {
setTimeout(() => rerunInFlight.delete(id), 2500);
}
},
onSuccess: (job) => {
if (!job || !job.id) return; if (!job || !job.id) return;
toast.success("Đã kích hoạt chạy lại tác vụ", { toast.success("Đã kích hoạt chạy lại tác vụ", {
description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`, description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`,
...@@ -170,7 +158,7 @@ export function useCrawlJobLogs( ...@@ -170,7 +158,7 @@ export function useCrawlJobLogs(
queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query), queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query),
queryFn: () => crawlJobService.getJobLogs(id, query), queryFn: () => crawlJobService.getJobLogs(id, query),
enabled: Boolean(id), enabled: Boolean(id),
refetchInterval: options?.refetchInterval ?? 5000, refetchInterval: options?.refetchInterval ?? false,
}); });
} }
......
...@@ -7,7 +7,7 @@ import { Locale, translations } from "./i18n/translations"; ...@@ -7,7 +7,7 @@ import { Locale, translations } from "./i18n/translations";
export function isConnectionLossError( export function isConnectionLossError(
rawError: string | null | undefined rawError: string | null | undefined
): boolean { ): boolean {
if (!rawError) return true; if (!rawError) return false;
const err = rawError.toLowerCase(); const err = rawError.toLowerCase();
...@@ -37,12 +37,8 @@ export function isConnectionLossError( ...@@ -37,12 +37,8 @@ export function isConnectionLossError(
err.includes("getaddrinfo") || err.includes("getaddrinfo") ||
err.includes("enotfound") || err.includes("enotfound") ||
// Provider & gateway / credit errors (Firecrawl 402, 500, 502, 503, 504) // Provider & gateway / credit errors (Firecrawl 402, 500, 502, 503, 504)
err.includes("402") || /\b(402|500|502|503|504)\b/.test(err) ||
err.includes("credit") || err.includes("credit") ||
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") || err.includes("bad gateway") ||
err.includes("gateway") || err.includes("gateway") ||
err.includes("service unavailable") || err.includes("service unavailable") ||
...@@ -90,7 +86,7 @@ export function getLocalizedCrawlErrorInfo( ...@@ -90,7 +86,7 @@ export function getLocalizedCrawlErrorInfo(
const err = raw.toLowerCase(); const err = raw.toLowerCase();
if ( if (
err.includes("402") || /\b(402)\b/.test(err) ||
err.includes("credit") || err.includes("credit") ||
err.includes("xác thực") || err.includes("xác thực") ||
err.includes("api key") || err.includes("api key") ||
...@@ -169,10 +165,7 @@ export function getLocalizedCrawlErrorInfo( ...@@ -169,10 +165,7 @@ export function getLocalizedCrawlErrorInfo(
} }
if ( if (
err.includes("500") || /\b(500|502|503|504)\b/.test(err) ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") || err.includes("bad gateway") ||
err.includes("service unavailable") err.includes("service unavailable")
) { ) {
......
...@@ -67,7 +67,7 @@ export function downloadBlob( ...@@ -67,7 +67,7 @@ export function downloadBlob(
type: mimeType || "text/plain;charset=utf-8", type: mimeType || "text/plain;charset=utf-8",
}); });
} else { } else {
blob = new Blob([content as unknown as BlobPart], { blob = new Blob([content as BlobPart], {
type: mimeType || "application/octet-stream", type: mimeType || "application/octet-stream",
}); });
} }
......
...@@ -5,16 +5,92 @@ export function cn(...inputs: ClassValue[]) { ...@@ -5,16 +5,92 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function formatDate(date: string | Date | number, locale: string = "vi"): string { export function formatDate(
date: string | Date | number | null | undefined,
locale: string = "vi",
options?: Intl.DateTimeFormatOptions
): string {
if (!date) return "-";
const d = new Date(date);
if (isNaN(d.getTime())) return "-";
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", { return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", {
timeZone: "Asia/Ho_Chi_Minh",
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}).format(new Date(date)); ...options,
}).format(d);
}
/**
* Validates and returns a safe relative redirect URL, preventing Open Redirect vulnerabilities.
*/
export function getSafeRedirectUrl(
url?: string | null,
defaultUrl: string = "/"
): string {
if (!url || typeof url !== "string") return defaultUrl;
const trimmed = url.trim();
// Must start with '/' and must NOT start with '//' (protocol-relative URL)
// or contain backslashes / protocol schemes (http:, https:, javascript:)
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
return defaultUrl;
}
// Avoid redirecting back to auth pages to prevent redirect loops
if (
trimmed.startsWith("/login") ||
trimmed.startsWith("/register") ||
trimmed.startsWith("/forgot-password") ||
trimmed.startsWith("/reset-password") ||
trimmed.startsWith("/verify-email")
) {
return defaultUrl;
}
// Safe relative URL path
return trimmed;
}
const VN_OFFSET_MS = 7 * 60 * 60 * 1000;
/**
* Converts YYYY-MM-DD local dates (Asia/Ho_Chi_Minh, UTC+7) into ISO UTC string range for start and end of day.
*/
export function getLocalDateRangeISO(
startDateStr?: string | null,
endDateStr?: string | null
): { startDate?: string; endDate?: string } {
let startDate: string | undefined = undefined;
let endDate: string | undefined = undefined;
if (startDateStr) {
const parts = startDateStr.split("-").map(Number);
if (parts.length === 3 && !parts.some(isNaN)) {
const [year, month, day] = parts;
const utcMs = Date.UTC(year, month - 1, day, 0, 0, 0, 0) - VN_OFFSET_MS;
startDate = new Date(utcMs).toISOString();
}
}
if (endDateStr) {
const parts = endDateStr.split("-").map(Number);
if (parts.length === 3 && !parts.some(isNaN)) {
const [year, month, day] = parts;
const utcMs = Date.UTC(year, month - 1, day, 23, 59, 59, 999) - VN_OFFSET_MS;
endDate = new Date(utcMs).toISOString();
}
}
return { startDate, endDate };
} }
export function formatBytes(bytes: number, decimals = 2): string { export function formatBytes(bytes: number, decimals = 2): string {
if (!+bytes) return "0 Bytes"; if (!+bytes) return "0 Bytes";
const k = 1024; const k = 1024;
......
...@@ -10,20 +10,8 @@ const AUTH_ROUTES = [ ...@@ -10,20 +10,8 @@ const AUTH_ROUTES = [
"/verify-email", "/verify-email",
]; ];
// Routes strictly requiring ADMIN role (RBAC) // Strict ADMIN-only root routes
const ADMIN_ROUTES = [ const STRICT_ADMIN_ROUTES = ["/admin", "/settings/developer"];
"/admin",
"/users",
"/audit-logs",
"/roles",
"/system-configs",
"/settings/developer/users",
"/settings/developer/roles",
"/settings/developer/audit-logs",
"/settings/developer/system-configs",
"/settings/developer/cron",
"/cron",
];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found) // Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
const OPEN_ROUTES = [ const OPEN_ROUTES = [
...@@ -48,23 +36,35 @@ function decodeJwtPayload(token: string): { role?: string; exp?: number } | null ...@@ -48,23 +36,35 @@ function decodeJwtPayload(token: string): { role?: string; exp?: number } | null
} }
} }
/**
* Validates that redirect URL is a safe local relative path
*/
function getSafeRedirectUrl(url?: string | null, fallback = "/"): string {
if (!url || typeof url !== "string") return fallback;
const trimmed = url.trim();
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
return fallback;
}
if (AUTH_ROUTES.some((route) => trimmed.startsWith(route))) {
return fallback;
}
return trimmed;
}
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl; const { pathname, search } = request.nextUrl;
const authToken = request.cookies.get("auth_token")?.value; const authToken = request.cookies.get("auth_token")?.value;
const refreshToken = request.cookies.get("refresh_token")?.value; const refreshToken = request.cookies.get("refresh_token")?.value;
const userRoleCookie = request.cookies.get("user_role")?.value;
// Determine user role and token expiration // Determine user role and token expiration strictly from signed JWT
let role = userRoleCookie; let role: string | undefined = undefined;
let isTokenExpired = false; let isTokenExpired = false;
if (authToken) { if (authToken) {
const payload = decodeJwtPayload(authToken); const payload = decodeJwtPayload(authToken);
if (payload) { if (payload) {
if (payload.role && !role) { role = payload.role;
role = payload.role;
}
if (payload.exp && payload.exp * 1000 < Date.now()) { if (payload.exp && payload.exp * 1000 < Date.now()) {
isTokenExpired = true; isTokenExpired = true;
} }
...@@ -77,17 +77,11 @@ export function middleware(request: NextRequest) { ...@@ -77,17 +77,11 @@ export function middleware(request: NextRequest) {
const isAuthRoute = AUTH_ROUTES.some((route) => pathname.startsWith(route)); const isAuthRoute = AUTH_ROUTES.some((route) => pathname.startsWith(route));
const isOpenRoute = OPEN_ROUTES.some((route) => pathname.startsWith(route)); const isOpenRoute = OPEN_ROUTES.some((route) => pathname.startsWith(route));
const isAdminRoute = ADMIN_ROUTES.some((route) => pathname.startsWith(route)); const isStrictAdminRoute = STRICT_ADMIN_ROUTES.some((route) => pathname.startsWith(route));
// 1. If user is already authenticated and visits /login, /register, etc. -> redirect to home or returnUrl // 1. If user is already authenticated and visits /login, /register, etc. -> redirect to home or returnUrl
if (isAuthRoute && isAuthenticated) { if (isAuthRoute && isAuthenticated) {
let redirectUrl = request.nextUrl.searchParams.get("redirect") || "/"; const redirectUrl = getSafeRedirectUrl(request.nextUrl.searchParams.get("redirect"), "/");
if (
redirectUrl.startsWith("/login") ||
redirectUrl.startsWith("/register")
) {
redirectUrl = "/";
}
return NextResponse.redirect(new URL(redirectUrl, request.url)); return NextResponse.redirect(new URL(redirectUrl, request.url));
} }
...@@ -110,9 +104,8 @@ export function middleware(request: NextRequest) { ...@@ -110,9 +104,8 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
// 4. Role-Based Access Control (RBAC): restrict ADMIN routes // 4. Role-Based Access Control (RBAC): restrict strict ADMIN root routes
if (isAdminRoute && role !== "ADMIN") { if (isStrictAdminRoute && role?.toUpperCase() !== "ADMIN") {
// Redirect unauthorized role to /forbidden
return NextResponse.redirect(new URL("/forbidden", request.url)); return NextResponse.redirect(new URL("/forbidden", request.url));
} }
......
...@@ -5,6 +5,7 @@ import React, { ...@@ -5,6 +5,7 @@ import React, {
useContext, useContext,
useEffect, useEffect,
useCallback, useCallback,
useMemo,
ReactNode, ReactNode,
} from "react"; } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
...@@ -59,9 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -59,9 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const isAuthenticated = !!user && !isError; const isAuthenticated = !!user && !isError;
const role: UserRole | null = (user?.role as UserRole) || null; const role: UserRole | null = (user?.role as UserRole) || null;
const roles = user?.roles || (role ? [role.toLowerCase()] : []); const roles = useMemo(
const permissions = user?.permissions || []; () => (user?.roles || (role ? [role] : [])).map((r) => r.toLowerCase()).filter(Boolean),
const isAdmin = role === "ADMIN" || roles.includes("admin") || roles.includes("super_admin"); [user?.roles, role]
);
const permissions = useMemo(() => user?.permissions || [], [user?.permissions]);
const isAdmin = (role && role.toUpperCase() === "ADMIN") || roles.includes("admin") || roles.includes("super_admin");
const hasPermission = useCallback( const hasPermission = useCallback(
(permission: string): boolean => { (permission: string): boolean => {
...@@ -104,41 +108,47 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -104,41 +108,47 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[role, roles] [role, roles]
); );
const login = async ( const login = useCallback(
input: LoginInput, async (
rememberMe: boolean = true input: LoginInput,
): Promise<BffLoginResponse> => { rememberMe: boolean = true
try { ): Promise<BffLoginResponse> => {
const response = await authService.login({ ...input, rememberMe }); try {
const response = await authService.login({ ...input, rememberMe });
// Proactively update query cache with logged in user data
if (response.user) { // Proactively update query cache with logged in user data
queryClient.setQueryData(AUTH_QUERY_KEYS.me, response.user); if (response.user) {
queryClient.setQueryData(AUTH_QUERY_KEYS.me, response.user);
}
await queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me });
toast.success(t.auth.login.success);
return response;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng nhập không thành công";
toast.error(message);
throw error;
} }
await queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me }); },
[queryClient, t]
toast.success(t.auth.login.success); );
return response;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng nhập không thành công";
toast.error(message);
throw error;
}
};
const register = async (input: RegisterInput): Promise<User> => { const register = useCallback(
try { async (input: RegisterInput): Promise<User> => {
const newUser = await authService.register(input); try {
toast.success(t.auth.register.success); const newUser = await authService.register(input);
return newUser; toast.success(t.auth.register.success);
} catch (error: unknown) { return newUser;
const message = } catch (error: unknown) {
error instanceof Error ? error.message : "Đăng ký không thành công"; const message =
toast.error(message); error instanceof Error ? error.message : "Đăng ký không thành công";
throw error; toast.error(message);
} throw error;
}; }
},
[t]
);
const logout = useCallback(async (): Promise<void> => { const logout = useCallback(async (): Promise<void> => {
try { try {
...@@ -152,9 +162,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -152,9 +162,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, [queryClient, router, t]); }, [queryClient, router, t]);
const refreshProfile = async (): Promise<void> => { const refreshProfile = useCallback(async (): Promise<void> => {
await refetch(); await refetch();
}; }, [refetch]);
// Listen for unauthorized 401 events emitted by apiClient // Listen for unauthorized 401 events emitted by apiClient
useEffect(() => { useEffect(() => {
...@@ -169,23 +179,42 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -169,23 +179,42 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
}, [queryClient, router]); }, [queryClient, router]);
const contextValue: AuthContextType = { const contextValue: AuthContextType = useMemo(
user: user || null, () => ({
role, user: user || null,
roles, role,
permissions, roles,
isAuthenticated, permissions,
isLoading, isAuthenticated,
isAdmin, isLoading,
hasRole, isAdmin,
hasPermission, hasRole,
hasAnyPermission, hasPermission,
hasAllPermissions, hasAnyPermission,
login, hasAllPermissions,
register, login,
logout, register,
refreshProfile, logout,
}; refreshProfile,
}),
[
user,
role,
roles,
permissions,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
login,
register,
logout,
refreshProfile,
]
);
return ( return (
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider> <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
......
...@@ -13,22 +13,29 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined ...@@ -13,22 +13,29 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang"; const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
function getInitialLocale(): Locale {
if (typeof window === "undefined") return "vi";
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
return saved;
}
const browserLang = navigator.language?.toLowerCase() || "";
return browserLang.startsWith("vi") ? "vi" : "en";
} catch {
return "vi";
}
}
export function LanguageProvider({ children }: { children: ReactNode }) { export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale); const [locale, setLocaleState] = useState<Locale>("vi");
React.useEffect(() => {
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
let targetLocale: Locale = "vi";
if (saved && (saved === "vi" || saved === "en")) {
targetLocale = saved;
} else {
const browserLang = navigator.language?.toLowerCase() || "";
targetLocale = browserLang.startsWith("vi") ? "vi" : "en";
}
if (targetLocale !== "vi") {
queueMicrotask(() => {
setLocaleState(targetLocale);
});
}
document.documentElement.lang = targetLocale;
} catch {
// Ignore storage or navigator errors
}
}, []);
React.useEffect(() => { React.useEffect(() => {
try { try {
......
...@@ -31,7 +31,9 @@ export function Providers({ children }: ProvidersProps) { ...@@ -31,7 +31,9 @@ export function Providers({ children }: ProvidersProps) {
<NetworkStatusBanner /> <NetworkStatusBanner />
{children} {children}
<Toaster position="top-right" richColors closeButton /> <Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} /> {process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</LanguageProvider> </LanguageProvider>
......
...@@ -12,7 +12,21 @@ export function isValidCronExpression(cron: string | null | undefined): boolean ...@@ -12,7 +12,21 @@ export function isValidCronExpression(cron: string | null | undefined): boolean
if (!cron || typeof cron !== "string") return false; if (!cron || typeof cron !== "string") return false;
const parts = cron.trim().split(/\s+/); const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false; if (parts.length !== 5) return false;
return parts.every((p) => p.length > 0);
const patterns = [
// Minute: 0-59
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Hour: 0-23
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Day of month: 1-31
/^(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?(,(\*|\d{1,2})(-\d{1,2})?(\/\d{1,2})?)*$/,
// Month: 1-12 or JAN-DEC
/^(\*|\d{1,2}|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-\w+)?(\/\d{1,2})?(,(\*|\d{1,2}|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-\w+)?(\/\d{1,2})?)*$/i,
// Day of week: 0-7 or SUN-SAT
/^(\*|\d{1,2}|SUN|MON|TUE|WED|THU|FRI|SAT)(-\w+)?(\/\d{1,2})?(,(\*|\d{1,2}|SUN|MON|TUE|WED|THU|FRI|SAT)(-\w+)?(\/\d{1,2})?)*$/i,
];
return parts.every((part, i) => patterns[i].test(part));
} }
export const createCrawlScheduleSchema = z export const createCrawlScheduleSchema = z
......
...@@ -55,6 +55,11 @@ export const createRoleSchema = z.object({ ...@@ -55,6 +55,11 @@ export const createRoleSchema = z.object({
export type CreateRoleInput = z.infer<typeof createRoleSchema>; export type CreateRoleInput = z.infer<typeof createRoleSchema>;
export const roleFormSchema = createRoleSchema.extend({
syncUsersQuota: z.boolean().optional(),
});
export type RoleFormInput = z.infer<typeof roleFormSchema>;
export const updateRoleSchema = z.object({ export const updateRoleSchema = z.object({
name: z name: z
.string() .string()
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
DiffReportEnvelope, DiffReportEnvelope,
} from "@/types/crawl-job"; } from "@/types/crawl-job";
import { ExtractionTemplate } from "@/types/extraction-template"; import { ExtractionTemplate } from "@/types/extraction-template";
import { DashboardStats } from "@/types/dashboard";
// Initial realistic mock data for local fallback resilience // Initial realistic mock data for local fallback resilience
const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [ const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
...@@ -58,166 +59,20 @@ const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [ ...@@ -58,166 +59,20 @@ const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
}, },
]; ];
let localMockJobs: CrawlJob[] = [
{
id: "088f635c-9c3a-4467-93bb-e58f001bf001",
userId: "usr-01",
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
mode: "CRAWL",
status: "RUNNING",
maxPages: 100,
maxDepth: 3,
urls: [],
totalPages: 52,
successPages: 49,
failedPages: 3,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 12,
modifiedPagesCount: 7,
deletedPagesCount: 2,
unchangedPagesCount: 33,
changeRate: 0.4,
},
startedAt: new Date(Date.now() - 3600000 * 1.5).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "199a746d-ad4b-5578-84cc-f69a112cf002",
userId: "usr-01",
startUrl: "https://tiki.vn/laptop/c8095",
domain: "tiki.vn",
mode: "SCRAPE",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 50,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0 (+https://datacrawler.internal)",
delayMs: 1500,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: "/storage/diffs/diff_report_199a746d.json",
diffSummary: {
totalCurrentPages: 50,
totalPreviousPages: 48,
newPagesCount: 5,
modifiedPagesCount: 18,
deletedPagesCount: 3,
unchangedPagesCount: 27,
changeRate: 0.54,
},
startedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 13).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 11).toISOString(),
},
{
id: "2aab857e-be5c-6689-95dd-07ab223df003",
userId: "usr-01",
startUrl: "https://yellowpages.vn/danh-ba-doanh-nghiep",
domain: "yellowpages.vn",
mode: "CRAWL",
status: "CANCELED",
maxPages: 200,
maxDepth: 2,
urls: [],
totalPages: 84,
successPages: 82,
failedPages: 2,
timeoutMs: 30000,
retryCount: 2,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1200,
errorMessage: "Tác vụ bị hủy bỏ theo yêu cầu của người dùng",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 24).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 25).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 23.5).toISOString(),
},
{
id: "3bbc968f-cf6d-7790-06ee-18bc334ef004",
userId: "usr-01",
startUrl: "https://cafef.vn/tai-chinh-quoc-te",
domain: "cafef.vn",
mode: "SCRAPE",
status: "FAILED",
maxPages: 40,
maxDepth: 1,
urls: [],
totalPages: 12,
successPages: 6,
failedPages: 6,
timeoutMs: 15000,
retryCount: 3,
respectRobotsTxt: false,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
delayMs: 500,
errorMessage: "Mục tiêu phản hồi HTTP 403 Forbidden (Cloudflare bot protection triggered)",
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 3600000 * 48).toISOString(),
finishedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 49).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 47.9).toISOString(),
},
{
id: "4ccd0790-d07e-8801-17ff-29cd445ff005",
userId: "usr-01",
startUrl: "https://genk.vn/tin-ict.chn",
domain: "genk.vn",
mode: "CRAWL",
status: "PENDING",
maxPages: 80,
maxDepth: 2,
urls: [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: null,
finishedAt: null,
createdAt: new Date(Date.now() - 600000).toISOString(),
updatedAt: new Date(Date.now() - 600000).toISOString(),
},
];
export const crawlJobService = { export const crawlJobService = {
/**
* Lấy thống kê tổng quan thời gian thực (GET /dashboard/stats)
*/
async getStats(): Promise<DashboardStats> {
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid dashboard stats response");
},
/** /**
* Danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Multi-criteria Filtering * Danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Multi-criteria Filtering
*/ */
...@@ -225,262 +80,102 @@ export const crawlJobService = { ...@@ -225,262 +80,102 @@ export const crawlJobService = {
const page = Number(query?.page) || 1; const page = Number(query?.page) || 1;
const limit = Number(query?.limit) || 20; const limit = Number(query?.limit) || 20;
try { const params = new URLSearchParams();
const params = new URLSearchParams(); if (query?.status) params.append("status", query.status);
if (query?.status) params.append("status", query.status); if (query?.mode) params.append("mode", query.mode);
if (query?.mode) params.append("mode", query.mode); if (query?.search) params.append("search", query.search);
if (query?.search) params.append("search", query.search); if (query?.sortBy) params.append("sortBy", query.sortBy);
if (query?.sortBy) params.append("sortBy", query.sortBy); if (query?.order) params.append("order", query.order);
if (query?.order) params.append("order", query.order); params.append("page", String(page));
params.append("page", String(page)); params.append("limit", String(limit));
params.append("limit", String(limit));
const response = await apiClient.get< const response = await apiClient.get<
ApiResponse<{ ApiResponse<{
items: CrawlJob[]; items: CrawlJob[];
total?: number;
page?: number;
pageSize?: number;
totalPages?: number;
meta?: {
total?: number; total?: number;
page?: number; page?: number;
pageSize?: number; limit?: number;
totalPages?: number; totalPages?: number;
meta?: {
total?: number;
page?: number;
limit?: number;
totalPages?: number;
};
}>
>(`/crawl-jobs?${params.toString()}`);
const raw = response.data?.data;
if (raw && Array.isArray(raw.items)) {
const total = raw.total ?? raw.meta?.total ?? raw.items.length;
const totalPages =
raw.totalPages ??
raw.meta?.totalPages ??
Math.max(1, Math.ceil(total / limit));
const pageNum = raw.page ?? raw.meta?.page ?? page;
const pageSize = raw.pageSize ?? raw.meta?.limit ?? limit;
return {
items: raw.items,
total,
totalPages,
page: pageNum,
pageSize,
}; };
} }>
throw new Error("Invalid response format"); >(`/crawl-jobs?${params.toString()}`);
} catch {
// Fallback local memory filtering and pagination
let filtered = [...localMockJobs];
if (query?.status) {
filtered = filtered.filter((j) => j.status === query.status);
}
if (query?.mode) {
filtered = filtered.filter((j) => j.mode === query.mode);
}
if (query?.search) {
const term = query.search.toLowerCase();
filtered = filtered.filter(
(j) =>
j.startUrl.toLowerCase().includes(term) ||
(j.domain && j.domain.toLowerCase().includes(term)) ||
j.id.toLowerCase().includes(term)
);
}
if (query?.sortBy) {
filtered.sort((a, b) => {
const valA = a[query.sortBy as keyof CrawlJob];
const valB = b[query.sortBy as keyof CrawlJob];
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
const result = valA > valB ? 1 : -1;
return query.order === "asc" ? result : -result;
});
} else {
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
const total = filtered.length; const raw = response.data?.data;
const totalPages = Math.max(1, Math.ceil(total / limit)); if (raw && Array.isArray(raw.items)) {
const startIndex = (page - 1) * limit; const total = raw.total ?? raw.meta?.total ?? raw.items.length;
const items = filtered.slice(startIndex, startIndex + limit); const totalPages =
raw.totalPages ??
raw.meta?.totalPages ??
Math.max(1, Math.ceil(total / limit));
const pageNum = raw.page ?? raw.meta?.page ?? page;
const pageSize = raw.pageSize ?? raw.meta?.limit ?? limit;
return { return {
items, items: raw.items,
total, total,
page,
pageSize: limit,
totalPages, totalPages,
page: pageNum,
pageSize,
}; };
} }
throw new Error("Invalid response format");
}, },
/** /**
* Lấy chi tiết một Crawl Job theo ID * Lấy chi tiết một Crawl Job theo ID
*/ */
async getJobById(id: string): Promise<CrawlJob> { async getJobById(id: string): Promise<CrawlJob> {
try { const response = await apiClient.get<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}`);
const response = await apiClient.get<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}`); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
throw new Error("Job not found");
} catch {
const found = localMockJobs.find((j) => j.id === id);
if (found) return found;
// Tạo mock job nếu không tìm thấy
const fallbackJob: CrawlJob = {
id,
userId: "usr-current",
startUrl: "https://example.com/data",
domain: "example.com",
mode: "CRAWL",
status: "RUNNING",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 18,
successPages: 17,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: "DataCrawler-Bot/2.0",
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 300000).toISOString(),
finishedAt: null,
createdAt: new Date(Date.now() - 300000).toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [fallbackJob, ...localMockJobs];
return fallbackJob;
} }
throw new Error("Job not found");
}, },
/** /**
* Tạo Crawl Job mới toàn diện * Tạo Crawl Job mới toàn diện
*/ */
async createJob(dto: CreateCrawlJobDto): Promise<CrawlJob> { async createJob(dto: CreateCrawlJobDto): Promise<CrawlJob> {
try { const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", dto);
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", dto); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
throw new Error("Failed to create crawl job");
} catch {
let domain: string | null = null;
try {
if (dto.startUrl) {
domain = new URL(dto.startUrl).hostname;
}
} catch {
domain = "custom-target.com";
}
const newJob: CrawlJob = {
id: `job-${Date.now()}`,
userId: "usr-current",
startUrl: dto.startUrl || "https://example.com",
domain,
mode: dto.mode || "SCRAPE",
status: "RUNNING",
maxPages: dto.maxPages || 20,
maxDepth: dto.maxDepth || 1,
urls: dto.urls || [],
totalPages: 0,
successPages: 0,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: dto.respectRobotsTxt ?? true,
userAgent: dto.userAgent || null,
delayMs: dto.delayMs || 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId: dto.scheduleId || null,
diffReportPath: null,
diffSummary: null,
startedAt: new Date().toISOString(),
finishedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localMockJobs = [newJob, ...localMockJobs];
return newJob;
} }
throw new Error("Failed to create crawl job");
}, },
/** /**
* Chạy lại Job (POST /crawl-jobs/:id/rerun) * Chạy lại Job (POST /crawl-jobs/:id/rerun)
*/ */
async rerunJob(id: string): Promise<CrawlJob> { async rerunJob(id: string): Promise<CrawlJob> {
try { const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/rerun`);
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/rerun`); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
throw new Error("Failed to rerun job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "RUNNING";
existing.startedAt = new Date().toISOString();
existing.finishedAt = null;
existing.totalPages = 0;
existing.successPages = 0;
existing.failedPages = 0;
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
} }
throw new Error("Failed to rerun job");
}, },
/** /**
* Hủy bỏ Job (POST /crawl-jobs/:id/cancel) * Hủy bỏ Job (POST /crawl-jobs/:id/cancel)
*/ */
async cancelJob(id: string): Promise<CrawlJob> { async cancelJob(id: string): Promise<CrawlJob> {
try { const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
throw new Error("Failed to cancel job");
} catch {
const existing = localMockJobs.find((j) => j.id === id);
if (existing) {
existing.status = "CANCELED";
existing.finishedAt = new Date().toISOString();
existing.errorMessage = "Tác vụ đã được hủy bởi người dùng.";
existing.updatedAt = new Date().toISOString();
return existing;
}
throw new Error("Tác vụ không tồn tại");
} }
throw new Error("Failed to cancel job");
}, },
/** /**
* Xóa Job (DELETE /crawl-jobs/:id) * Xóa Job (DELETE /crawl-jobs/:id)
*/ */
async deleteJob(id: string): Promise<boolean> { async deleteJob(id: string): Promise<boolean> {
try { await apiClient.delete(`/crawl-jobs/${id}`);
await apiClient.delete(`/crawl-jobs/${id}`); return true;
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true;
} catch {
localMockJobs = localMockJobs.filter((j) => j.id !== id);
return true;
}
}, },
/** /**
...@@ -493,105 +188,17 @@ export const crawlJobService = { ...@@ -493,105 +188,17 @@ export const crawlJobService = {
const page = query?.page || 1; const page = query?.page || 1;
const limit = query?.limit || 50; const limit = query?.limit || 50;
try { const response = await apiClient.get<
const response = await apiClient.get< ApiResponse<{
ApiResponse<{ items: CrawlJobLog[];
items: CrawlJobLog[]; meta: { total: number; page: number; limit: number; totalPages: number };
meta: { total: number; page: number; limit: number; totalPages: number }; }>
}> >(`/crawl-jobs/${id}/logs?page=${page}&limit=${limit}`);
>(`/crawl-jobs/${id}/logs?page=${page}&limit=${limit}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid logs response");
} catch {
// Fallback realistic crawler logs
const mockLogs: CrawlJobLog[] = [
{
id: `log-${id}-01`,
jobId: id,
level: "INFO",
step: "INIT",
message: `Khởi tạo tiến trình cào dữ liệu cho tác vụ [${id}]. Đã nạp cấu hình bộ thu thập.`,
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `log-${id}-02`,
jobId: id,
level: "INFO",
step: "ROBOTS_TXT",
message: "Tải và phân tích robots.txt: Tuân thủ quy tắc Crawl-Delay 1.0s, Allow: /*.",
createdAt: new Date(Date.now() - 3550000).toISOString(),
},
{
id: `log-${id}-03`,
jobId: id,
level: "INFO",
step: "DISPATCH",
message: "Phân phối URL gốc vào hàng đợi Redis/BullMQ: https://vnexpress.net/so-hoa/cong-nghe",
createdAt: new Date(Date.now() - 3500000).toISOString(),
},
{
id: `log-${id}-04`,
jobId: id,
level: "INFO",
step: "SCRAPING",
message: "HTTP 200 OK: Đã tải về thành công DOM HTML (kích thước: 142.4 KB, thời gian: 238ms).",
createdAt: new Date(Date.now() - 3400000).toISOString(),
},
{
id: `log-${id}-05`,
jobId: id,
level: "INFO",
step: "EXTRACT",
message: "Trích xuất 14 liên kết con hợp lệ thuộc domain chính theo giới hạn depth = 2.",
createdAt: new Date(Date.now() - 3350000).toISOString(),
},
{
id: `log-${id}-06`,
jobId: id,
level: "WARNING",
step: "CONTENT_CLEAN",
message: "Phát hiện mã theo dõi quảng cáo bên thứ ba (Google Ads/Taboola). Đã loại bỏ thành công.",
createdAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `log-${id}-07`,
jobId: id,
level: "INFO",
step: "DATABASE",
message: "Lưu bản ghi trang #42 vào PostgreSQL: 1,842 từ, DataQualityScore = 96/100.",
createdAt: new Date(Date.now() - 3100000).toISOString(),
},
{
id: `log-${id}-08`,
jobId: id,
level: "ERROR",
step: "NETWORK",
message: "HTTP 404 Not Found: Bỏ qua đường dẫn /tin-cu/bai-viet-da-xoa.html sau 3 lần thử.",
createdAt: new Date(Date.now() - 2900000).toISOString(),
},
{
id: `log-${id}-09`,
jobId: id,
level: "INFO",
step: "STREAM",
message: "Đồng bộ tiến độ qua SSE Event: 52/100 trang đã xử lý (52% hoàn tất).",
createdAt: new Date(Date.now() - 2500000).toISOString(),
},
];
return { if (response.data?.data) {
items: mockLogs, return response.data.data;
meta: {
total: mockLogs.length,
page: 1,
limit: 50,
totalPages: 1,
},
};
} }
throw new Error("Invalid logs response");
}, },
/** /**
...@@ -604,264 +211,39 @@ export const crawlJobService = { ...@@ -604,264 +211,39 @@ export const crawlJobService = {
const page = query?.page || 1; const page = query?.page || 1;
const limit = query?.limit || 20; const limit = query?.limit || 20;
try { const params = new URLSearchParams();
const params = new URLSearchParams(); params.append("page", String(page));
params.append("page", String(page)); params.append("limit", String(limit));
params.append("limit", String(limit)); if (query?.search) params.append("search", query.search);
if (query?.search) params.append("search", query.search);
const response = await apiClient.get< const response = await apiClient.get<
ApiResponse<{ ApiResponse<{
items: CrawlPagePreview[]; items: CrawlPagePreview[];
total: number; total: number;
page: number; page: number;
limit: number; limit: number;
totalPages: number; totalPages: number;
}> }>
>(`/crawl-jobs/${id}/pages/preview?${params.toString()}`); >(`/crawl-jobs/${id}/pages/preview?${params.toString()}`);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Invalid pages preview response");
} catch {
// Fallback realistic crawled pages
const mockPages: CrawlPagePreview[] = [
{
id: `page-${id}-01`,
jobId: id,
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Tin tức công nghệ mới nhất hôm nay",
description: "Cập nhật nhanh tin tức công nghệ, thiết bị mới, trí tuệ nhân tạo AI và viễn thông.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1420,
dataQualityScore: 98,
contentHash: "sha256-a1b2c3d4e5f67890",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "CollectionPage",
headline: "Tin tức Công nghệ",
},
markdownContent: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM) và giải pháp điện toán đám mây sinh thái...\n\n## 1. Trí tuệ nhân tạo thế hệ mới\nCác giải pháp AI đang được tối ưu hóa nhằm giảm thiểu lượng khí thải carbon và điện năng tiêu thụ tại các trung tâm dữ liệu.\n\n## 2. Thiết bị phần cứng tiết kiệm năng lượng\nChip xử lý tiến trình 3nm mang lại hiệu suất vượt trội mà vẫn giữ được nhiệt độ vận hành lý tưởng.",
rawMarkdown: "# Tin tức Công nghệ Mới Nhất\n\nThị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
mainContent: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
cleanText: "Tin tức Công nghệ Mới Nhất. Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về mô hình ngôn ngữ lớn (LLM)...",
htmlContentPath: `/storage/crawled/${id}/page_01.html`,
content: "Thị trường công nghệ toàn cầu ghi nhận làn sóng đột phá về AI...",
createdAt: new Date(Date.now() - 3600000).toISOString(),
updatedAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: `page-${id}-02`,
jobId: id,
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
description: "Các kỹ sư phát triển thuật toán điều phối thông minh giúp trung tâm dữ liệu xanh hóa quy trình xử lý.",
status: "SUCCESS",
statusCode: 200,
wordCount: 2150,
dataQualityScore: 95,
contentHash: "sha256-b2c3d4e5f6a78901",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
warnings: [],
structuredData: {
"@context": "https://schema.org",
"@type": "NewsArticle",
headline: "Giải pháp AI xanh giúp giảm 40% điện năng",
datePublished: new Date(Date.now() - 3600000).toISOString(),
},
markdownContent: "# Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu\n\nNghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa và caching thông minh đã cắt giảm mạnh mức tiêu thụ điện của các cụm máy chủ GPU.\n\n> Đổi mới sáng tạo cần đi đôi với bảo vệ môi trường và phát triển bền vững.\n\n### Kết quả thử nghiệm thực tế\n- Mức tải tiêu thụ điện giảm từ 1.2MW xuống còn 720kW.\n- Tốc độ phản hồi (Latency) duy trì dưới 45ms.",
rawMarkdown: "# Giải pháp AI xanh...",
mainContent: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
cleanText: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận lượng tử hóa...",
htmlContentPath: `/storage/crawled/${id}/page_02.html`,
content: "Nghiên cứu mới công bố cho thấy việc áp dụng cơ chế suy luận...",
createdAt: new Date(Date.now() - 3200000).toISOString(),
updatedAt: new Date(Date.now() - 3200000).toISOString(),
},
{
id: `page-${id}-03`,
jobId: id,
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
description: "Các nhà máy đúc chip hàng đầu thế giới công bố tiến độ thương mại hóa chip 2nm vào cuối năm.",
status: "SUCCESS",
statusCode: 200,
wordCount: 1890,
dataQualityScore: 92,
contentHash: "sha256-c3d4e5f6a7b89012",
errorMessage: null,
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
warnings: [],
structuredData: null,
markdownContent: "# Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa\n\nTiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA (Gate-All-Around) hứa hẹn tăng 15% hiệu năng và tiết kiệm 30% năng lượng so với thế hệ trước.",
rawMarkdown: "# Thế hệ vi xử lý bán dẫn 2nm...",
mainContent: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
cleanText: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
htmlContentPath: `/storage/crawled/${id}/page_03.html`,
content: "Tiến trình 2nm sử dụng cấu trúc bóng bán dẫn GAA...",
createdAt: new Date(Date.now() - 2700000).toISOString(),
updatedAt: new Date(Date.now() - 2700000).toISOString(),
},
{
id: `page-${id}-04`,
jobId: id,
url: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
normalizedUrl: "https://vnexpress.net/so-hoa/khong-tim-thay-trang-cu.html",
title: "Trang không tồn tại (404 Not Found)",
description: null,
status: "FAILED",
statusCode: 404,
wordCount: 45,
dataQualityScore: 0,
contentHash: null,
errorMessage: "Mã trạng thái phản hồi HTTP 404 Not Found",
hasSensitiveData: false,
crawledAt: new Date(Date.now() - 2100000).toISOString(),
warnings: ["URL trả về lỗi 404"],
structuredData: null,
markdownContent: "# 404 Not Found\n\nTrang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
rawMarkdown: "# 404 Not Found",
mainContent: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
cleanText: "Trang bạn tìm kiếm không tồn tại hoặc đã bị gỡ bỏ.",
htmlContentPath: null,
content: "404 Not Found",
createdAt: new Date(Date.now() - 2100000).toISOString(),
updatedAt: new Date(Date.now() - 2100000).toISOString(),
},
];
return { if (response.data?.data) {
items: mockPages, return response.data.data;
total: mockPages.length,
page: 1,
limit: 20,
totalPages: 1,
};
} }
throw new Error("Invalid pages preview response");
}, },
/** /**
* Lấy báo cáo so sánh biến động dữ liệu Diff (GET /crawl-jobs/:id/diff) * Lấy báo cáo so sánh biến động dữ liệu Diff (GET /crawl-jobs/:id/diff)
*/ */
async getDiff(id: string, compareWithJobId?: string): Promise<DiffReportEnvelope> { async getDiff(id: string, compareWithJobId?: string): Promise<DiffReportEnvelope> {
try { const url = compareWithJobId
const url = compareWithJobId ? `/crawl-jobs/${id}/diff?compareWithJobId=${compareWithJobId}`
? `/crawl-jobs/${id}/diff?compareWithJobId=${compareWithJobId}` : `/crawl-jobs/${id}/diff`;
: `/crawl-jobs/${id}/diff`; const response = await apiClient.get<ApiResponse<DiffReportEnvelope>>(url);
const response = await apiClient.get<ApiResponse<DiffReportEnvelope>>(url); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
throw new Error("Invalid diff response");
} catch {
// Fallback realistic Diff Report
return {
schemaVersion: "1.0.0",
generatedAt: new Date().toISOString(),
jobId: id,
previousJobId: compareWithJobId || "199a746d-ad4b-5578-84cc-f69a112cf002",
scheduleId: null,
startUrl: "https://vnexpress.net/so-hoa/cong-nghe",
domain: "vnexpress.net",
summary: {
totalCurrentPages: 52,
totalPreviousPages: 45,
newPagesCount: 8,
modifiedPagesCount: 14,
deletedPagesCount: 3,
unchangedPagesCount: 30,
changeRate: 0.48,
},
newPages: [
{
url: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ai-tiet-kiem-nang-luong-4712345.html",
title: "Giải pháp AI xanh giúp giảm 40% điện năng trung tâm dữ liệu",
contentHash: "sha256-b2c3d4e5f6a78901",
wordCount: 2150,
statusCode: 200,
crawledAt: new Date(Date.now() - 3200000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
normalizedUrl: "https://vnexpress.net/so-hoa/vi-xu-ly-the-he-moi-4712399.html",
title: "Thế hệ vi xử lý bán dẫn 2nm đầu tiên chuẩn bị thương mại hóa",
contentHash: "sha256-c3d4e5f6a7b89012",
wordCount: 1890,
statusCode: 200,
crawledAt: new Date(Date.now() - 2700000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
normalizedUrl: "https://vnexpress.net/so-hoa/ve-tinh-bang-thong-rong-4712410.html",
title: "Mạng lưới vệ tinh quỹ đạo thấp phủ sóng internet vùng sâu vùng xa",
contentHash: "sha256-d4e5f6a7b8c90123",
wordCount: 1450,
statusCode: 200,
crawledAt: new Date(Date.now() - 2200000).toISOString(),
},
],
modifiedPages: [
{
url: "https://vnexpress.net/so-hoa/cong-nghe",
normalizedUrl: "https://vnexpress.net/so-hoa/cong-nghe",
title: "Công nghệ - Cập nhật thông tin công nghệ nóng nhất",
oldTitle: "Công nghệ - Tin tức công nghệ trong ngày",
oldContentHash: "sha256-old-998877665544",
newContentHash: "sha256-a1b2c3d4e5f67890",
oldWordCount: 1200,
newWordCount: 1420,
wordCountDiff: 220,
statusCode: 200,
crawledAt: new Date(Date.now() - 3600000).toISOString(),
},
{
url: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
normalizedUrl: "https://vnexpress.net/so-hoa/gia-smartphone-bien-dong-4711800.html",
title: "Giá smartphone cao cấp điều chỉnh giảm kích cầu quý 3",
oldTitle: "Giá smartphone cao cấp duy trì ổn định",
oldContentHash: "sha256-old-112233445566",
newContentHash: "sha256-mod-556677889900",
oldWordCount: 1650,
newWordCount: 1510,
wordCountDiff: -140,
statusCode: 200,
crawledAt: new Date(Date.now() - 3100000).toISOString(),
},
],
deletedPages: [
{
url: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
normalizedUrl: "https://vnexpress.net/so-hoa/su-kien-ra-mat-da-ket-thuc.html",
title: "Sự kiện tường thuật trực tiếp công nghệ mùa xuân (Đã đóng)",
previousContentHash: "sha256-del-443322110099",
previousWordCount: 3200,
lastCrawledAt: new Date(Date.now() - 86400000 * 3).toISOString(),
},
],
unchangedPages: [
{
url: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
normalizedUrl: "https://vnexpress.net/so-hoa/dieu-khoan-su-dung.html",
title: "Điều khoản sử dụng và chính sách nội dung công nghệ",
contentHash: "sha256-static-unchanged-01",
wordCount: 850,
},
],
};
} }
throw new Error("Invalid diff response");
}, },
/** /**
......
...@@ -8,319 +8,67 @@ import { ...@@ -8,319 +8,67 @@ import {
UpdateCrawlScheduleDto, UpdateCrawlScheduleDto,
} from "@/types/crawl-schedule"; } from "@/types/crawl-schedule";
const MOCK_CRAWL_SCHEDULES: CrawlSchedule[] = [
{
id: "sch-01",
userId: "usr-01",
name: "Quét tin tức VnExpress sáng",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
frequency: "DAILY",
cronExpression: null,
hour: 6,
minute: 30,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 50,
maxDepth: 2,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000).toISOString(),
nextRunAt: new Date(Date.now() + 3600000 * 6).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
},
{
id: "sch-02",
userId: "usr-01",
name: "Thu thập giá thị trường Tiki cuối tuần",
startUrl: "https://tiki.vn/dien-thoai-may-tinh-bang/c1789",
domain: "tiki.vn",
mode: "CRAWL",
frequency: "WEEKLY",
cronExpression: null,
hour: 9,
minute: 0,
dayOfWeek: 0, // Chủ nhật
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 100,
maxDepth: 3,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000 * 6).toISOString(),
nextRunAt: new Date(Date.now() + 86400000 * 1).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 6).toISOString(),
},
{
id: "sch-03",
userId: "usr-01",
name: "Giám sát thông số kỹ thuật (Custom Cron)",
startUrl: "https://dantri.com.vn/suc-manh-so.htm",
domain: "dantri.com.vn",
mode: "SCRAPE",
frequency: "CUSTOM",
cronExpression: "0 */3 * * *",
hour: 0,
minute: 0,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 20,
maxDepth: 1,
urls: [],
isActive: false,
autoDiff: false,
lastRunAt: new Date(Date.now() - 86400000 * 2).toISOString(),
nextRunAt: null,
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
},
];
let localSchedules = [...MOCK_CRAWL_SCHEDULES];
export class CrawlScheduleService { export class CrawlScheduleService {
async getSchedules(params?: CrawlScheduleQueryDto): Promise<PaginatedResponse<CrawlSchedule>> { async getSchedules(params?: CrawlScheduleQueryDto): Promise<PaginatedResponse<CrawlSchedule>> {
try { const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlSchedule>>>("/crawl-schedules", {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlSchedule>>>("/crawl-schedules", { params,
params, });
}); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
return this.getLocalFilteredSchedules(params);
} catch {
return this.getLocalFilteredSchedules(params);
} }
} throw new Error("Không thể tải danh sách lịch cào");
private getLocalFilteredSchedules(params?: CrawlScheduleQueryDto): PaginatedResponse<CrawlSchedule> {
let list = [...localSchedules];
if (params?.search) {
const s = params.search.toLowerCase();
list = list.filter((item) => item.name.toLowerCase().includes(s) || item.startUrl.toLowerCase().includes(s));
}
if (params?.frequency) {
list = list.filter((item) => item.frequency === params.frequency);
}
if (params?.isActive !== undefined) {
list = list.filter((item) => item.isActive === params.isActive);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const paginatedItems = list.slice(start, start + limit);
return {
items: paginatedItems,
total,
page,
pageSize: limit,
totalPages,
};
} }
async getScheduleById(id: string): Promise<CrawlSchedule> { async getScheduleById(id: string): Promise<CrawlSchedule> {
try { const response = await apiClient.get<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`);
const response = await apiClient.get<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào");
} catch {
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào");
} }
throw new Error("Không tìm thấy lịch cào");
} }
async createSchedule(dto: CreateCrawlScheduleDto): Promise<CrawlSchedule> { async createSchedule(dto: CreateCrawlScheduleDto): Promise<CrawlSchedule> {
try { const response = await apiClient.post<ApiResponse<CrawlSchedule>>("/crawl-schedules", dto);
const response = await apiClient.post<ApiResponse<CrawlSchedule>>("/crawl-schedules", dto); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
const created = response.data.data;
localSchedules = [created, ...localSchedules];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
let domain = "";
try {
domain = new URL(dto.startUrl).hostname;
} catch {
domain = "example.com";
}
const newSchedule: CrawlSchedule = {
id: `sch-${Date.now()}`,
userId: "current-user",
name: dto.name,
startUrl: dto.startUrl,
domain,
mode: dto.mode || "SCRAPE",
frequency: dto.frequency || "DAILY",
cronExpression: dto.cronExpression || null,
hour: dto.hour ?? 0,
minute: dto.minute ?? 0,
dayOfWeek: dto.dayOfWeek ?? null,
dayOfMonth: dto.dayOfMonth ?? null,
timezone: dto.timezone || "Asia/Ho_Chi_Minh",
maxPages: dto.maxPages ?? 20,
maxDepth: dto.maxDepth ?? 1,
urls: dto.urls || [],
isActive: dto.isActive ?? true,
autoDiff: dto.autoDiff ?? true,
lastRunAt: null,
nextRunAt: new Date(Date.now() + 3600000 * 24).toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localSchedules = [newSchedule, ...localSchedules];
return newSchedule;
} }
throw new Error("Không thể tạo lịch cào");
} }
async updateSchedule(id: string, dto: UpdateCrawlScheduleDto): Promise<CrawlSchedule> { async updateSchedule(id: string, dto: UpdateCrawlScheduleDto): Promise<CrawlSchedule> {
try { const response = await apiClient.patch<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`, dto);
const response = await apiClient.patch<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`, dto); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
const updated = response.data.data;
localSchedules = localSchedules.map((s) => (s.id === id ? updated : s));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localSchedules.findIndex((s) => s.id === id);
if (index === -1) throw new Error("Không tìm thấy lịch cào");
const existing = localSchedules[index];
const updated: CrawlSchedule = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localSchedules[index] = updated;
return updated;
} }
throw new Error("Không thể cập nhật lịch cào");
} }
async deleteSchedule(id: string): Promise<void> { async deleteSchedule(id: string): Promise<void> {
try { await apiClient.delete(`/crawl-schedules/${id}`);
await apiClient.delete(`/crawl-schedules/${id}`);
localSchedules = localSchedules.filter((s) => s.id !== id);
} catch {
localSchedules = localSchedules.filter((s) => s.id !== id);
}
} }
async triggerRun(id: string): Promise<{ jobId: string; message: string }> { async triggerRun(id: string): Promise<{ jobId: string; message: string }> {
try { const response = await apiClient.post<ApiResponse<{ jobId: string; message: string }>>(
const response = await apiClient.post<ApiResponse<{ jobId: string; message: string }>>( `/crawl-schedules/${id}/run`
`/crawl-schedules/${id}/run` );
); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công!" };
} catch {
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công (mô phỏng)!" };
} }
throw new Error("Không thể kích hoạt tác vụ cào");
} }
async getHistory( async getHistory(
id: string, id: string,
params?: { page?: number; limit?: number } params?: { page?: number; limit?: number }
): Promise<PaginatedResponse<CrawlJob>> { ): Promise<PaginatedResponse<CrawlJob>> {
try { const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>(
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>( `/crawl-schedules/${id}/history`,
`/crawl-schedules/${id}/history`, { params }
{ params } );
); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
return this.getMockHistory(id, params);
} catch {
return this.getMockHistory(id, params);
} }
} throw new Error("Không thể tải lịch sử chạy");
private getMockHistory(scheduleId: string, params?: { page?: number; limit?: number }): PaginatedResponse<CrawlJob> {
const mockJobs: CrawlJob[] = [
{
id: `job-sch-${scheduleId}-01`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 48,
successPages: 48,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000).toISOString(),
finishedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
},
{
id: `job-sch-${scheduleId}-02`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 49,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
finishedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
},
];
return {
items: mockJobs,
total: mockJobs.length,
page: params?.page || 1,
pageSize: params?.limit || 20,
totalPages: 1,
};
} }
} }
......
...@@ -5,290 +5,109 @@ import { CrawlerTask } from "@/types/crawler"; ...@@ -5,290 +5,109 @@ import { CrawlerTask } from "@/types/crawler";
import { DashboardStats } from "@/types/dashboard"; import { DashboardStats } from "@/types/dashboard";
import { CrawlJob } from "@/types/crawl-job"; import { CrawlJob } from "@/types/crawl-job";
// Dữ liệu mẫu khởi đầu khi Backend chưa kết nối
const INITIAL_MOCK_TASKS: CrawlerTask[] = [
{
id: "task-01",
name: "Cào tin tức Công nghệ & AI",
targetUrl: "https://vnexpress.net/so-hoa/cong-nghe",
status: "RUNNING",
maxDepth: 3,
maxPages: 100,
pagesCrawled: 42,
itemsExtracted: 318,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date(Date.now() - 60000).toISOString(),
},
{
id: "task-02",
name: "Trích xuất giá sản phẩm Laptop",
targetUrl: "https://tiki.vn/laptop/c8095",
status: "COMPLETED",
maxDepth: 2,
maxPages: 50,
pagesCrawled: 50,
itemsExtracted: 620,
createdAt: new Date(Date.now() - 3600000 * 24).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 5).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "task-03",
name: "Thu thập danh bạ doanh nghiệp",
targetUrl: "https://yellowpages.vn/danh-ba",
status: "PAUSED",
maxDepth: 2,
maxPages: 200,
pagesCrawled: 75,
itemsExtracted: 180,
createdAt: new Date(Date.now() - 3600000 * 48).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 12).toISOString(),
},
];
let localTasksState = [...INITIAL_MOCK_TASKS];
const DEFAULT_FALLBACK_STATS: DashboardStats = {
jobs: {
total: 3,
completed: 1,
failed: 0,
running: 1,
pending: 1,
},
pages: {
total: 167,
successful: 162,
failed: 5,
},
schedules: {
total: 2,
active: 1,
},
exports: {
total: 4,
},
quotaAndUsage: {
quota: {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
usage: {
jobsUsedToday: 3,
jobsRemainingToday: 47,
concurrentJobsRunning: 1,
concurrentJobsAvailable: 4,
totalPagesCrawled: 167,
pagesCrawledToday: 24,
pagesRemainingToday: 976,
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
export const crawlerService = { export const crawlerService = {
/** /**
* Lấy thống kê tổng quan thời gian thực từ endpoint chuẩn: * Lấy thống kê tổng quan thời gian thực từ endpoint chuẩn:
* GET /api/v1/dashboard/stats (qua Next.js BFF Proxy) * GET /api/v1/dashboard/stats (qua Next.js BFF Proxy)
*/ */
async getStats(): Promise<DashboardStats> { async getStats(): Promise<DashboardStats> {
try { const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats");
const response = await apiClient.get<ApiResponse<DashboardStats>>("/dashboard/stats"); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
return DEFAULT_FALLBACK_STATS;
} catch {
// Fallback tính toán từ local memory state hoặc default
const totalPages = localTasksState.reduce((acc, t) => acc + t.pagesCrawled, 0);
const totalItems = localTasksState.reduce((acc, t) => acc + t.itemsExtracted, 0);
const runningCount = localTasksState.filter((t) => t.status === "RUNNING").length;
const completedCount = localTasksState.filter((t) => t.status === "COMPLETED").length;
const failedCount = localTasksState.filter((t) => t.status === "FAILED").length;
return {
jobs: {
total: localTasksState.length,
completed: completedCount,
failed: failedCount,
running: runningCount,
pending: Math.max(0, localTasksState.length - completedCount - failedCount - runningCount),
},
pages: {
total: totalPages || 167,
successful: totalItems || 162,
failed: 5,
},
schedules: {
total: 2,
active: 1,
},
exports: {
total: 3,
},
quotaAndUsage: {
quota: {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
},
usage: {
jobsUsedToday: localTasksState.length,
jobsRemainingToday: Math.max(0, 50 - localTasksState.length),
concurrentJobsRunning: runningCount,
concurrentJobsAvailable: Math.max(0, 5 - runningCount),
totalPagesCrawled: totalPages || 167,
pagesCrawledToday: totalPages || 0,
pagesRemainingToday: Math.max(0, 1000 - (totalPages || 0)),
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
};
} }
throw new Error("Invalid dashboard stats response");
}, },
/** /**
* Lấy danh sách task cào dữ liệu (kết nối endpoint thực /crawl-jobs hoặc fallback) * Lấy danh sách task cào dữ liệu từ endpoint chuẩn /crawl-jobs
*/ */
async getTasks(): Promise<PaginatedResponse<CrawlerTask>> { async getTasks(): Promise<PaginatedResponse<CrawlerTask>> {
try { const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>("/crawl-jobs?limit=20");
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>("/crawl-jobs?limit=20"); const data = response.data?.data;
const data = response.data?.data; if (data && Array.isArray(data.items)) {
if (data && Array.isArray(data.items) && data.items.length > 0) { const mappedItems: CrawlerTask[] = data.items.map((job) => ({
const mappedItems: CrawlerTask[] = data.items.map((job) => ({ id: job.id,
id: job.id, name: job.domain || job.startUrl || `Job ${job.id.slice(0, 8)}`,
name: job.domain || job.startUrl || `Job ${job.id.slice(0, 8)}`, targetUrl: job.startUrl,
targetUrl: job.startUrl, status: (job.status === "PROCESSING_EXPORT" ? "RUNNING" : job.status === "CANCELED" ? "PAUSED" : job.status) as CrawlerTask["status"],
status: (job.status === "PROCESSING_EXPORT" ? "RUNNING" : job.status === "CANCELED" ? "PAUSED" : job.status) as CrawlerTask["status"], maxDepth: job.maxDepth ?? 1,
maxDepth: job.maxDepth ?? 1, maxPages: job.maxPages ?? 20,
maxPages: job.maxPages ?? 20, pagesCrawled: job.totalPages || job.successPages || 0,
pagesCrawled: job.totalPages || job.successPages || 0, itemsExtracted: job.successPages || 0,
itemsExtracted: job.successPages || 0, createdAt: job.createdAt,
createdAt: job.createdAt, updatedAt: job.updatedAt,
updatedAt: job.updatedAt, lastRunAt: job.startedAt || job.createdAt,
lastRunAt: job.startedAt || job.createdAt, errorMessage: job.errorMessage || undefined,
errorMessage: job.errorMessage || undefined, }));
}));
return {
items: mappedItems,
total: data.total ?? mappedItems.length,
page: data.page ?? 1,
pageSize: data.pageSize ?? 20,
totalPages: data.totalPages ?? 1,
};
}
return {
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
};
} catch {
// Fallback local memory state
return { return {
items: localTasksState, items: mappedItems,
total: localTasksState.length, total: data.total ?? mappedItems.length,
page: 1, page: data.page ?? 1,
pageSize: 10, pageSize: data.pageSize ?? 20,
totalPages: 1, totalPages: data.totalPages ?? 1,
}; };
} }
throw new Error("Failed to fetch crawler tasks");
}, },
/** /**
* Tạo mới một task * Tạo mới một task
*/ */
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> { async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try { const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", {
const response = await apiClient.post<ApiResponse<CrawlJob>>("/crawl-jobs", { startUrl: input.targetUrl,
startUrl: input.targetUrl, maxDepth: input.maxDepth,
maxDepth: input.maxDepth, maxPages: input.maxPages,
maxPages: input.maxPages, mode: "SCRAPE",
mode: "SCRAPE", });
}); const job = response.data?.data;
const job = response.data?.data; if (job) {
if (job) { return {
return { id: job.id,
id: job.id, name: input.name || job.domain || job.startUrl,
name: input.name || job.domain || job.startUrl, targetUrl: job.startUrl,
targetUrl: job.startUrl,
status: "RUNNING",
maxDepth: job.maxDepth ?? input.maxDepth,
maxPages: job.maxPages ?? input.maxPages,
pagesCrawled: 0,
itemsExtracted: 0,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
};
}
throw new Error("Invalid response");
} catch {
const newTask: CrawlerTask = {
id: `task-${Date.now()}`,
name: input.name,
targetUrl: input.targetUrl,
status: "RUNNING", status: "RUNNING",
maxDepth: input.maxDepth, maxDepth: job.maxDepth ?? input.maxDepth,
maxPages: input.maxPages, maxPages: job.maxPages ?? input.maxPages,
pagesCrawled: 0, pagesCrawled: 0,
itemsExtracted: 0, itemsExtracted: 0,
createdAt: new Date().toISOString(), createdAt: job.createdAt,
updatedAt: new Date().toISOString(), updatedAt: job.updatedAt,
lastRunAt: new Date().toISOString(), lastRunAt: job.startedAt || job.createdAt,
}; };
localTasksState = [newTask, ...localTasksState];
return newTask;
} }
throw new Error("Failed to create crawler task");
}, },
/** /**
* Dừng task * Dừng task
*/ */
async toggleTaskStatus(id: string): Promise<CrawlerTask> { async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try { const response = await apiClient.post<ApiResponse<CrawlJob>>(`/crawl-jobs/${id}/cancel`);
await apiClient.post(`/crawl-jobs/${id}/cancel`); const job = response.data?.data;
} catch { if (job) {
// Ignored for local fallback return {
} id: job.id,
name: job.domain || job.startUrl,
const task = localTasksState.find((t) => t.id === id); targetUrl: job.startUrl,
if (!task) {
// Tạo mock task nếu là job ID từ server
const fallbackTask: CrawlerTask = {
id,
name: `Tác vụ #${id.slice(0, 8)}`,
targetUrl: "https://example.com",
status: "PAUSED", status: "PAUSED",
maxDepth: 1, maxDepth: job.maxDepth ?? 1,
maxPages: 20, maxPages: job.maxPages ?? 20,
pagesCrawled: 1, pagesCrawled: job.totalPages ?? 0,
itemsExtracted: 1, itemsExtracted: job.successPages ?? 0,
createdAt: new Date().toISOString(), createdAt: job.createdAt,
updatedAt: new Date().toISOString(), updatedAt: job.updatedAt,
lastRunAt: job.startedAt || job.createdAt,
}; };
return fallbackTask;
} }
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING"; throw new Error("Failed to toggle task status");
task.updatedAt = new Date().toISOString();
return task;
}, },
/** /**
* Xóa task * Xóa task
*/ */
async deleteTask(id: string): Promise<boolean> { async deleteTask(id: string): Promise<boolean> {
try { await apiClient.delete(`/crawl-jobs/${id}`);
await apiClient.delete(`/crawl-jobs/${id}`); return true;
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
} catch {
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
}
}, },
}; };
...@@ -11,308 +11,88 @@ import { ...@@ -11,308 +11,88 @@ import {
WebhookDeliveryQueryDto, WebhookDeliveryQueryDto,
} from "@/types/developer"; } from "@/types/developer";
const MOCK_API_KEYS: ApiKey[] = [
{
id: "key-01",
name: "Production Backend Integration",
keyPrefix: "dc_live_9f8a",
isActive: true,
expiresAt: new Date(Date.now() + 86400000 * 90).toISOString(),
lastUsedAt: new Date(Date.now() - 3600000 * 2).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
},
{
id: "key-02",
name: "Zapier Automated Workflows",
keyPrefix: "dc_live_12bc",
isActive: true,
expiresAt: null,
lastUsedAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 30).toISOString(),
},
{
id: "key-03",
name: "Dev Test Script (Staging)",
keyPrefix: "dc_live_77ef",
isActive: false,
expiresAt: new Date(Date.now() - 86400000 * 5).toISOString(),
lastUsedAt: new Date(Date.now() - 86400000 * 7).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 45).toISOString(),
},
];
const MOCK_WEBHOOK_CONFIGS: WebhookConfig[] = [
{
id: "wh-01",
url: "https://api.mycompany.com/v1/webhooks/crawler-events",
events: ["crawl.job.completed", "crawl.job.failed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
},
{
id: "wh-02",
url: "https://hooks.slack.com/services/T00/B00/XXXXX",
events: ["crawl.job.failed", "export.completed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
},
];
const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [
{
id: "del-01",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-vnexpress-01",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"received": true}',
deliveredAt: new Date(Date.now() - 3600000).toISOString(),
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: "del-02",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-tiki-02",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"status": "ok"}',
deliveredAt: new Date(Date.now() - 3600000 * 5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "del-03",
webhookConfigId: "wh-02",
crawlJobId: "job-failed-test-03",
event: "crawl.job.failed",
status: "FAILED",
statusCode: 504,
attempt: 3,
errorMessage: "Gateway Timeout: Destination endpoint did not respond in 10000ms",
deliveredAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
let localApiKeys = [...MOCK_API_KEYS];
let localWebhooks = [...MOCK_WEBHOOK_CONFIGS];
const localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
export class DeveloperService { export class DeveloperService {
// API Keys // API Keys
async listKeys(): Promise<ApiKey[]> { async listKeys(): Promise<ApiKey[]> {
try { const response = await apiClient.get<ApiResponse<ApiKey[]>>("/api-keys");
const response = await apiClient.get<ApiResponse<ApiKey[]>>("/api-keys"); if (response.data?.data && Array.isArray(response.data.data)) {
if (response.data?.data && Array.isArray(response.data.data)) { return response.data.data;
return response.data.data;
}
return localApiKeys;
} catch {
return localApiKeys;
} }
throw new Error("Không thể tải danh sách khóa API");
} }
async createKey(dto: CreateApiKeyDto): Promise<CreateApiKeyResponse> { async createKey(dto: CreateApiKeyDto): Promise<CreateApiKeyResponse> {
try { const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>("/api-keys", dto);
const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>("/api-keys", dto); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
const created = response.data.data;
localApiKeys = [
{
id: created.id,
name: created.name,
keyPrefix: created.keyPrefix,
key: created.key,
isActive: true,
expiresAt: created.expiresAt || null,
lastUsedAt: null,
createdAt: created.createdAt || new Date().toISOString(),
},
...localApiKeys,
];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const fullKey = `dc_live_${Math.random().toString(36).substring(2, 10)}${Math.random().toString(36).substring(2, 18)}`;
const prefix = fullKey.substring(0, 12);
const newKey: CreateApiKeyResponse = {
id: `key-${Date.now()}`,
name: dto.name,
key: fullKey,
keyPrefix: prefix,
expiresAt: dto.expiresAt || null,
createdAt: new Date().toISOString(),
};
localApiKeys = [
{
id: newKey.id,
name: newKey.name,
keyPrefix: prefix,
key: fullKey,
isActive: true,
expiresAt: newKey.expiresAt ?? null,
lastUsedAt: null,
createdAt: newKey.createdAt,
},
...localApiKeys,
];
return newKey;
} }
throw new Error("Không thể khởi tạo khóa API");
} }
async toggleActive(id: string, isActive: boolean): Promise<ApiKey> { async toggleActive(id: string, isActive: boolean): Promise<ApiKey> {
try { const response = await apiClient.patch<ApiResponse<ApiKey>>(`/api-keys/${id}`, { isActive });
const response = await apiClient.patch<ApiResponse<ApiKey>>(`/api-keys/${id}`, { isActive }); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
const updated = response.data.data;
localApiKeys = localApiKeys.map((k) => (k.id === id ? updated : k));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const key = localApiKeys.find((k) => k.id === id);
if (!key) throw new Error("Không tìm thấy khóa API");
key.isActive = isActive;
return { ...key };
} }
throw new Error("Không thể cập nhật trạng thái khóa API");
} }
async revokeKey(id: string): Promise<void> { async revokeKey(id: string): Promise<void> {
try { await apiClient.delete(`/api-keys/${id}`);
await apiClient.delete(`/api-keys/${id}`);
localApiKeys = localApiKeys.filter((k) => k.id !== id);
} catch {
localApiKeys = localApiKeys.filter((k) => k.id !== id);
}
} }
// Webhook Configs // Webhook Configs
async listWebhookConfigs(): Promise<WebhookConfig[]> { async listWebhookConfigs(): Promise<WebhookConfig[]> {
try { const response = await apiClient.get<ApiResponse<WebhookConfig[]>>("/webhooks/configs");
const response = await apiClient.get<ApiResponse<WebhookConfig[]>>("/webhooks/configs"); if (response.data?.data && Array.isArray(response.data.data)) {
if (response.data?.data && Array.isArray(response.data.data)) { return response.data.data;
return response.data.data;
}
return localWebhooks;
} catch {
return localWebhooks;
} }
throw new Error("Không thể tải danh sách cấu hình Webhook");
} }
async createWebhookConfig(dto: CreateWebhookConfigDto): Promise<WebhookConfig> { async createWebhookConfig(dto: CreateWebhookConfigDto): Promise<WebhookConfig> {
try { const response = await apiClient.post<ApiResponse<WebhookConfig>>("/webhooks/configs", dto);
const response = await apiClient.post<ApiResponse<WebhookConfig>>("/webhooks/configs", dto); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
const created = response.data.data;
localWebhooks = [created, ...localWebhooks];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const newWh: WebhookConfig = {
id: `wh-${Date.now()}`,
url: dto.url,
events: dto.events,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localWebhooks = [newWh, ...localWebhooks];
return newWh;
} }
throw new Error("Không thể tạo cấu hình Webhook");
} }
async updateWebhookConfig(id: string, dto: UpdateWebhookConfigDto): Promise<WebhookConfig> { async updateWebhookConfig(id: string, dto: UpdateWebhookConfigDto): Promise<WebhookConfig> {
try { const response = await apiClient.patch<ApiResponse<WebhookConfig>>(`/webhooks/configs/${id}`, dto);
const response = await apiClient.patch<ApiResponse<WebhookConfig>>(`/webhooks/configs/${id}`, dto); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
const updated = response.data.data;
localWebhooks = localWebhooks.map((w) => (w.id === id ? updated : w));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localWebhooks.findIndex((w) => w.id === id);
if (index === -1) throw new Error("Không tìm thấy Webhook");
const existing = localWebhooks[index];
const updated: WebhookConfig = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localWebhooks[index] = updated;
return updated;
} }
throw new Error("Không thể cập nhật cấu hình Webhook");
} }
async deleteWebhookConfig(id: string): Promise<void> { async deleteWebhookConfig(id: string): Promise<void> {
try { await apiClient.delete(`/webhooks/configs/${id}`);
await apiClient.delete(`/webhooks/configs/${id}`);
localWebhooks = localWebhooks.filter((w) => w.id !== id);
} catch {
localWebhooks = localWebhooks.filter((w) => w.id !== id);
}
} }
async testWebhookConfig(id: string): Promise<{ success: boolean; statusCode: number; message: string }> { async testWebhookConfig(id: string): Promise<{ success: boolean; statusCode: number; message: string }> {
try { const response = await apiClient.post<ApiResponse<{ success: boolean; statusCode: number; message: string }>>(
const response = await apiClient.post<ApiResponse<{ success: boolean; statusCode: number; message: string }>>( `/webhooks/configs/${id}/test`
`/webhooks/configs/${id}/test` );
); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
return { success: true, statusCode: 200, message: "Webhook Test Ping sent successfully!" };
} catch {
return { success: true, statusCode: 200, message: "Webhook Test Ping succeeded (simulated)!" };
} }
throw new Error("Gửi kiểm tra Webhook thất bại");
} }
// Webhook Deliveries // Webhook Deliveries
async listWebhookDeliveries(params?: WebhookDeliveryQueryDto): Promise<PaginatedResponse<WebhookDelivery>> { async listWebhookDeliveries(params?: WebhookDeliveryQueryDto): Promise<PaginatedResponse<WebhookDelivery>> {
try { const response = await apiClient.get<ApiResponse<PaginatedResponse<WebhookDelivery>>>("/webhooks/deliveries", {
const response = await apiClient.get<ApiResponse<PaginatedResponse<WebhookDelivery>>>("/webhooks/deliveries", { params,
params, });
}); if (response.data?.data) {
if (response.data?.data) { return response.data.data;
return response.data.data;
}
return this.getLocalDeliveries(params);
} catch {
return this.getLocalDeliveries(params);
} }
} throw new Error("Không thể tải nhật ký gửi Webhook");
private getLocalDeliveries(params?: WebhookDeliveryQueryDto): PaginatedResponse<WebhookDelivery> {
let list = [...localDeliveries];
if (params?.status) {
list = list.filter((d) => d.status === params.status);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
return {
items: list.slice(start, start + limit),
total,
page,
pageSize: limit,
totalPages,
};
} }
async redeliverWebhook(id: string): Promise<void> { async redeliverWebhook(id: string): Promise<void> {
try { await apiClient.post(`/webhooks/deliveries/${id}/redeliver`);
await apiClient.post(`/webhooks/deliveries/${id}/redeliver`);
} catch {
// simulated success
}
} }
} }
......
...@@ -22,11 +22,7 @@ export class ProfileService { ...@@ -22,11 +22,7 @@ export class ProfileService {
const formData = new FormData(); const formData = new FormData();
formData.append("avatar", file); formData.append("avatar", file);
const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData, { const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData);
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data.data; return response.data.data;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment