"use server"; import { revalidatePath } from "next/cache"; import { crawlCreateSchema, mapCreateSchema } from "@fetcha/core"; import { getWorkspace } from "@/lib/session"; import { internalApi, InternalApiError, type CrawlJob, type MapResult } from "@/lib/api"; export interface CrawlActionError { code: string; message: string; requestId: string | null; details?: Record; } export type CrawlActionResult = { ok: true; data: T } | { ok: false; error: CrawlActionError }; function invalid(issues: Array<{ path: string; message: string }>): CrawlActionError { const first = issues[0]; return { code: "INVALID_REQUEST", message: first ? `${first.path ? `${first.path}: ` : ""}${first.message}` : "The request is invalid.", requestId: null, details: { issues }, }; } function fromError(e: unknown, fallback: string): CrawlActionError { if (e instanceof InternalApiError) return { code: e.code, message: e.message, requestId: e.requestId, details: e.details }; console.error("[crawls] unexpected error", e); return { code: "INTERNAL_ERROR", message: fallback, requestId: null }; } /** Strip empty strings / undefined so optional fields do not trip the strict schema. */ function clean(input: unknown): Record { if (!input || typeof input !== "object") return {}; return Object.fromEntries(Object.entries(input as Record).filter(([, v]) => v !== undefined && v !== "" && v !== null)); } /** * Start a crawl job for the current project. Validated with the shared `crawlCreateSchema`; the API * enforces plan limits (pages per job, concurrent jobs). Never throws to the client. */ export async function createCrawl(input: unknown): Promise> { const ws = await getWorkspace(); try { const parsed = crawlCreateSchema.safeParse(clean(input)); if (!parsed.success) return { ok: false, error: invalid(parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))) }; const job = await internalApi.createCrawl(ws.project.id, ws.user.id, parsed.data); if (!job || typeof job !== "object" || typeof job.id !== "string") { return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } }; } revalidatePath("/dashboard/crawls"); return { ok: true, data: job }; } catch (e) { return { ok: false, error: fromError(e, "Could not start the crawl. Please try again.") }; } } /** Cancel a queued or running crawl job. */ export async function cancelCrawl(id: string): Promise> { const ws = await getWorkspace(); if (!/^crawl_[A-Za-z0-9]{4,64}$/.test(id)) return { ok: false, error: { code: "INVALID_REQUEST", message: "Invalid crawl id.", requestId: null } }; try { const res = await internalApi.cancelCrawl(ws.project.id, ws.user.id, id); revalidatePath("/dashboard/crawls"); revalidatePath(`/dashboard/crawls/${id}`); return { ok: true, data: res }; } catch (e) { return { ok: false, error: fromError(e, "Could not cancel the crawl. Please try again.") }; } } /** Discover the URLs of a site (sitemap + links), synchronously. */ export async function runMap(input: unknown): Promise> { const ws = await getWorkspace(); try { const parsed = mapCreateSchema.safeParse(clean(input)); if (!parsed.success) return { ok: false, error: invalid(parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))) }; const result = await internalApi.mapSite(ws.project.id, ws.user.id, parsed.data); if (!result || typeof result !== "object" || !Array.isArray(result.urls)) { return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } }; } return { ok: true, data: result }; } catch (e) { return { ok: false, error: fromError(e, "Could not map the site. Please try again.") }; } }