Merge lane/import in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { getImportFile } from "@/server/services/imports/queries";
|
||||
|
||||
/**
|
||||
* GET /imports/[id]/file — original document of an import for the review mask preview.
|
||||
* Authorisation: session + DB-authoritative permission import:write + module + document
|
||||
* visibility (getImportFile). Unknown/foreign ids → 404. `?download=1` forces a download.
|
||||
* Inline delivery only for the allowlisted types stored by the import (PDF/JPEG/PNG) and
|
||||
* only framable by the own origin.
|
||||
*/
|
||||
const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
let file: { storageKey: string; mimeType: string; fileName: string };
|
||||
try {
|
||||
const ctx = ctxFromGuard(await moduleGuard("imports")("import:write"));
|
||||
const { id } = await params;
|
||||
file = await getImportFile(ctx, id);
|
||||
} catch {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
const content = await storage.get(file.storageKey);
|
||||
if (!content) return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||
|
||||
const download = new URL(req.url).searchParams.get("download") === "1";
|
||||
const inline = !download && INLINE.has(file.mimeType);
|
||||
const asciiName = file.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
||||
const headers = new Headers({
|
||||
"Content-Type": INLINE.has(file.mimeType) ? file.mimeType : "application/octet-stream",
|
||||
"Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
});
|
||||
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||
return new Response(content.stream, { headers });
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, CheckCircle2, Download, ExternalLink, Info, Loader2, XCircle } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { getImportDetail } from "@/server/services/imports/queries";
|
||||
import { confirmImportAction, discardImportAction, retryImportAction, searchCustomersAction } from "@/server/actions/imports/imports";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ImportStatusPill } from "@/components/imports/status-pill";
|
||||
import { AutoRefresh } from "@/components/imports/auto-refresh";
|
||||
import { DiscardImportButton, RetryImportButton } from "@/components/imports/job-actions";
|
||||
import { ImportReviewForm } from "@/components/imports/review-form";
|
||||
import { extractionToForm, formFieldMeta } from "@/lib/imports/review";
|
||||
import { IMPORT_IN_PROGRESS } from "@/lib/imports/status";
|
||||
|
||||
/** /imports/[id] — review mask: original document left, form right (spec §9.6, US-002/003). */
|
||||
export default async function ImportDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "import:write")) redirect("/imports");
|
||||
const { id } = await params;
|
||||
const t = await getTranslations("imports");
|
||||
const locale = await getLocale();
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions),
|
||||
};
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getImportDetail(ctx, id);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError) notFound();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||
const fileUrl = `/imports/${detail.id}/file`;
|
||||
const isPdf = detail.document?.mimeType === "application/pdf";
|
||||
const fields = detail.extraction.fields;
|
||||
const top = detail.customerCandidates[0];
|
||||
const topSite = top ? detail.siteCandidates.find((s) => s.customerId === top.customerId) : undefined;
|
||||
const inProgress = IMPORT_IN_PROGRESS.includes(detail.status);
|
||||
const errorKey = detail.errorMessage && t.has(`errors.${detail.errorMessage}`) ? `errors.${detail.errorMessage}` : null;
|
||||
const correctionCount =
|
||||
detail.corrections && typeof detail.corrections === "object" && "fields" in detail.corrections
|
||||
? Object.keys((detail.corrections as { fields?: Record<string, unknown> }).fields ?? {}).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<AutoRefresh active={inProgress} />
|
||||
<Link href="/imports" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||
</Link>
|
||||
<PageHead
|
||||
crumb={t("title")}
|
||||
title={detail.document?.fileName ?? t("title")}
|
||||
sub={t("detail.meta", { file: detail.document?.fileName ?? "—", date: fmt.format(detail.createdAt), user: detail.importedByName ?? "—" })}
|
||||
actions={<ImportStatusPill status={detail.status} label={t(`status.${detail.status}`)} />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
|
||||
{/* Original document */}
|
||||
<section aria-labelledby="imp-preview" className="lg:sticky lg:top-20 lg:self-start">
|
||||
<div className="shadow-card rounded-xl border bg-card p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<h2 id="imp-preview" className="font-heading text-[15px] font-semibold">{t("detail.preview")}</h2>
|
||||
<div className="flex gap-3 text-[12.5px] font-semibold">
|
||||
<a href={fileUrl} target="_blank" rel="noreferrer" className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
|
||||
<ExternalLink className="size-3.5" aria-hidden /> {t("detail.openNewTab")}
|
||||
</a>
|
||||
<a href={`${fileUrl}?download=1`} className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
|
||||
<Download className="size-3.5" aria-hidden /> {t("detail.download")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{isPdf ? (
|
||||
<iframe src={fileUrl} title={t("detail.previewTitle")} className="h-[55vh] w-full rounded-lg border bg-muted lg:h-[calc(100vh-11rem)]" />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- authenticated same-origin file, no optimisation
|
||||
<img src={fileUrl} alt={t("detail.previewTitle")} className="max-h-[calc(100vh-11rem)] w-full rounded-lg border object-contain" />
|
||||
)}
|
||||
{detail.extractionModel && <p className="mt-2 px-1 text-[12px] text-muted-foreground">{t("detail.model", { model: detail.extractionModel })}</p>}
|
||||
</div>
|
||||
<details className="shadow-card mt-3 rounded-xl border bg-card p-3">
|
||||
<summary className="min-h-11 cursor-pointer content-center px-1 font-heading text-sm font-semibold">{t("detail.extractedText")}</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap px-1 text-[12px]">{detail.extractedText || t("detail.noText")}</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{/* Status / review mask */}
|
||||
<section aria-labelledby="imp-review">
|
||||
<h2 id="imp-review" className="mb-1 font-heading text-[17px] font-semibold">{t("review.title")}</h2>
|
||||
|
||||
{inProgress && (
|
||||
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
|
||||
<Loader2 className="mt-0.5 size-5 shrink-0 animate-spin text-[var(--info)]" aria-hidden />
|
||||
<p className="text-sm">{t("detail.processing")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "failed" && (
|
||||
<div className="shadow-card space-y-3 rounded-xl border border-[var(--risk)] bg-card p-5" role="alert">
|
||||
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--risk)]">
|
||||
<XCircle className="size-4" aria-hidden /> {t("detail.failed")}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{errorKey ? t(errorKey) : t("detail.failedDetail", { message: detail.errorMessage ?? "—" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<RetryImportButton action={retryImportAction.bind(null, detail.id)} />
|
||||
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "confirmed" && (
|
||||
<div className="shadow-card space-y-2 rounded-xl border border-[var(--ok)] bg-card p-5" role="status">
|
||||
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden /> {t("detail.confirmed", { number: detail.createdWorkOrder?.number ?? "—" })}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">{t("detail.corrections", { count: correctionCount })}</p>
|
||||
{detail.createdWorkOrder && (
|
||||
<Link href={`/work-orders/${detail.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
|
||||
{t("detail.openWorkOrder")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "discarded" && (
|
||||
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
|
||||
<Info className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<p className="text-sm">{t("detail.discarded")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === "review_required" && (
|
||||
<>
|
||||
<p className="mb-3 text-[13px] text-muted-foreground">
|
||||
{detail.extraction.hints.some((h) => h.code === "manual_entry") ? t("review.manualEntry") : t("review.intro")}
|
||||
</p>
|
||||
<ImportReviewForm
|
||||
initial={extractionToForm(fields, { customerId: top && top.score >= 0.6 ? top.customerId : null, siteId: top && top.score >= 0.6 ? topSite?.siteId ?? null : null })}
|
||||
meta={formFieldMeta(fields)}
|
||||
hints={detail.extraction.hints.filter((h) => h.code !== "manual_entry")}
|
||||
customerCandidates={detail.customerCandidates}
|
||||
siteCandidates={detail.siteCandidates}
|
||||
confirmAction={confirmImportAction.bind(null, detail.id)}
|
||||
searchAction={searchCustomersAction}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,107 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { listImports } from "@/server/services/imports/queries";
|
||||
import { retryImportAction } from "@/server/actions/imports/imports";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ImportUploader } from "@/components/imports/uploader";
|
||||
import { ImportStatusPill } from "@/components/imports/status-pill";
|
||||
import { AutoRefresh } from "@/components/imports/auto-refresh";
|
||||
import { RetryImportButton } from "@/components/imports/job-actions";
|
||||
import { IMPORT_IN_PROGRESS } from "@/lib/imports/status";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="imports" />;
|
||||
/** /imports — upload + list of imports with status (spec §9, US-002). */
|
||||
export default async function ImportsPage() {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("imports");
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!hasPermission(session, "import:write")) {
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} />
|
||||
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("noPermission")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions),
|
||||
};
|
||||
const imports = await listImports(ctx);
|
||||
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||
const inProgress = imports.some((i) => IMPORT_IN_PROGRESS.includes(i.status));
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<AutoRefresh active={inProgress} />
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
<section aria-labelledby="imp-upload" className="max-w-3xl">
|
||||
<h2 id="imp-upload" className="sr-only">{t("upload.title")}</h2>
|
||||
<ImportUploader />
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="imp-list" className="mt-6">
|
||||
<h2 id="imp-list" className="mb-3 font-heading text-[15px] font-semibold">{t("list.title")}</h2>
|
||||
{imports.length === 0 ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("list.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[640px] text-left text-[13px]">
|
||||
<thead className="border-b text-[12px] text-muted-foreground">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.file")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedAt")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedBy")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.status")}</th>
|
||||
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{imports.map((imp) => (
|
||||
<tr key={imp.id} className="border-b last:border-0">
|
||||
<td className="max-w-[280px] truncate px-4 py-2.5 font-semibold">
|
||||
<Link href={`/imports/${imp.id}`} className="hover:underline">
|
||||
{imp.document?.fileName ?? "—"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap">{fmt.format(imp.createdAt)}</td>
|
||||
<td className="px-4 py-2.5">{imp.importedByName ?? "—"}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ImportStatusPill status={imp.status} label={t(`status.${imp.status}`)} />
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{imp.status === "review_required" && (
|
||||
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
|
||||
{t("list.review")}
|
||||
</Link>
|
||||
)}
|
||||
{imp.status === "failed" && <RetryImportButton action={retryImportAction.bind(null, imp.id)} size="sm" />}
|
||||
{imp.status === "confirmed" && imp.createdWorkOrder && (
|
||||
<Link href={`/work-orders/${imp.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
|
||||
{t("list.workOrder", { number: imp.createdWorkOrder.number })}
|
||||
</Link>
|
||||
)}
|
||||
{(imp.status === "uploaded" || imp.status === "processing" || imp.status === "discarded") && (
|
||||
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
|
||||
{t("list.open")}
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { confirmImport } from "@/server/services/imports/confirm";
|
||||
import { apiError, importsApiContext } from "../../_context";
|
||||
|
||||
/**
|
||||
* POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts
|
||||
* `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3.
|
||||
*/
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const ctx = await importsApiContext("import:write", "work_order:write");
|
||||
const { id } = await params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid", message: "json_required" }, { status: 400 });
|
||||
}
|
||||
return Response.json(await confirmImport(ctx, id, body));
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getImportDetail } from "@/server/services/imports/queries";
|
||||
import { apiError, importsApiContext } from "../_context";
|
||||
|
||||
/** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const ctx = await importsApiContext("import:write");
|
||||
const { id } = await params;
|
||||
return Response.json(await getImportDetail(ctx, id));
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||
import { ModuleDisabledError } from "@/server/modules";
|
||||
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative
|
||||
* guard as the server actions (session → account/identity status → permissions → module).
|
||||
* Not a route: files starting with "_" are ignored by the App Router.
|
||||
* TODO(architecture): replace with a shared `requireApiContext` once it exists.
|
||||
*/
|
||||
const guard = moduleGuard("imports");
|
||||
|
||||
export async function importsApiContext(...permissions: Permission[]): Promise<ServiceCtx> {
|
||||
return ctxFromGuard(await guard(...permissions));
|
||||
}
|
||||
|
||||
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 };
|
||||
|
||||
/** Map service/guard errors to JSON responses without leaking internals. */
|
||||
export function apiError(err: unknown): Response {
|
||||
if (err instanceof ServiceError) {
|
||||
return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] });
|
||||
}
|
||||
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
console.error("[api/imports]", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createImport } from "@/server/services/imports/upload";
|
||||
import { apiError, importsApiContext } from "../../imports/_context";
|
||||
|
||||
/**
|
||||
* POST /api/v1/work-orders/import — multipart upload of an order document (field `file`).
|
||||
* Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background.
|
||||
* Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report).
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await importsApiContext("import:write");
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await req.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 });
|
||||
}
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 });
|
||||
const job = await createImport(ctx, {
|
||||
bytes: Buffer.from(await file.arrayBuffer()),
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
});
|
||||
const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } });
|
||||
return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 });
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user