Merge lane/import in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:07 +02:00
co-authored by Claude Opus 5
38 changed files with 4204 additions and 4 deletions
+77
View File
@@ -0,0 +1,77 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ForbiddenError } from "@/server/rbac";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { confirmImport, discardImport, retryImport } from "@/server/services/imports/confirm";
import { searchCustomers } from "@/server/services/imports/queries";
/**
* Server actions of the import module (thin adapters: guard → service → revalidate).
* Uploads do NOT go through a server action (1 MB body limit) but through
* POST /api/v1/work-orders/import with upload progress.
*/
const guard = moduleGuard("imports");
export type ConfirmState =
| { status: "idle" }
| { status: "error"; code: string; issues?: Array<{ path: string; message: string }> };
export type SimpleState = { status: "idle" } | { status: "error"; code: string };
function errorCode(err: unknown): string {
if (err instanceof ServiceError) return err.code === "invalid" || err.code === "conflict" ? err.message : err.code;
if (err instanceof ForbiddenError) return "forbidden";
console.error("[actions/imports]", err);
return "error";
}
export async function confirmImportAction(importId: string, _prev: ConfirmState, formData: FormData): Promise<ConfirmState> {
const ctx = ctxFromGuard(await guard("import:write", "work_order:write"));
let payload: unknown;
try {
payload = JSON.parse(String(formData.get("payload") ?? ""));
} catch {
return { status: "error", code: "form_invalid" };
}
try {
await confirmImport(ctx, importId, payload);
} catch (err) {
const issues = err instanceof ServiceError && err.code === "invalid" ? (err.details as Array<{ path: string; message: string }> | undefined) : undefined;
return { status: "error", code: errorCode(err), issues };
}
revalidatePath("/imports");
redirect(`/imports/${importId}`);
}
// Bound as (prev, formData) action for useActionState; the extra arguments are not needed.
export async function discardImportAction(importId: string): Promise<SimpleState> {
const ctx = ctxFromGuard(await guard("import:write"));
try {
await discardImport(ctx, importId);
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/imports");
redirect("/imports");
}
export async function retryImportAction(importId: string): Promise<SimpleState> {
const ctx = ctxFromGuard(await guard("import:write"));
try {
await retryImport(ctx, importId);
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/imports");
revalidatePath(`/imports/${importId}`);
return { status: "idle" };
}
export async function searchCustomersAction(q: string) {
const ctx = ctxFromGuard(await guard("import:write"));
const rows = await searchCustomers(ctx, String(q ?? "").slice(0, 100));
return rows;
}