Implementa o MVP do Adaptador de Planilhas no Elo Pro.
O módulo usa a identidade Supabase, isola cada workspace e registra storage, TTL, runner e biblioteca XLSX como decisões ainda abertas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
6e23f1533b
144 files changed
+19060
No files matched your search
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { SettingsPanel } from "@/components/settings-panel";
|
||||
import { AppShell } from "@/components/shell";
|
||||
import { ErrorState } from "@/components/states";
|
||||
import { canManageSpreadsheetModels } from "@/domain/spreadsheet-adapter/permissions";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous") redirect("/login");
|
||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||
if (!canManageSpreadsheetModels(auth.ctx)) {
|
||||
return (
|
||||
<AppShell ctx={auth.ctx}>
|
||||
<ErrorState message="Somente owner ou admin acessam as configurações do Adaptador." />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AppShell ctx={auth.ctx}>
|
||||
<SettingsPanel ctx={auth.ctx} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AdapterError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error("adaptador", error.digest ?? error.name);
|
||||
}, [error]);
|
||||
return (
|
||||
<main className="p-8">
|
||||
<h1 className="text-2xl font-semibold">Algo saiu do esperado</h1>
|
||||
<p className="mt-2 text-sm">A operação foi interrompida. Você pode tentar de novo sem perder a sessão do Elo Pro.</p>
|
||||
{error.digest ? <p className="mt-2 text-xs">Referência {error.digest}</p> : null}
|
||||
<button type="button" className="mt-4 underline" onClick={reset}>Tentar novamente</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdapterLayout({ children }: { children: ReactNode }) {
|
||||
return <Providers>{children}</Providers>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoadingBlock } from "@/components/states";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="p-8">
|
||||
<LoadingBlock label="Abrindo o Adaptador" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Operation } from "@/components/operation";
|
||||
import { AppShell } from "@/components/shell";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OperationPage() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous") redirect("/login");
|
||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||
return (
|
||||
<AppShell ctx={auth.ctx}>
|
||||
<Operation ctx={auth.ctx} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Dashboard } from "@/components/dashboard";
|
||||
import { AppShell } from "@/components/shell";
|
||||
import { ErrorState } from "@/components/states";
|
||||
import { Card } from "@/components/ui";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdapterHome() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous") redirect("/login");
|
||||
if (auth.kind !== "ok") {
|
||||
return (
|
||||
<main className="p-8">
|
||||
<ErrorState message={auth.kind === "no_workspace" ? "O Elo Pro ainda não informou o workspace ativo." : "Você não é membro deste workspace."} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (!auth.ctx.moduleEnabled) {
|
||||
return (
|
||||
<AppShell ctx={auth.ctx}>
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold">Módulo não habilitado</h1>
|
||||
<p className="mt-2 text-sm">O Adaptador está desligado para este workspace. A habilitação é feita na plataforma Elo Pro.</p>
|
||||
</Card>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AppShell ctx={auth.ctx}>
|
||||
<Dashboard ctx={auth.ctx} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST() {
|
||||
const jar = await cookies();
|
||||
jar.delete("adapter_dev_session");
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (url && key) {
|
||||
const supabase = createServerClient(url, key, {
|
||||
cookies: {
|
||||
getAll: () => jar.getAll(),
|
||||
setAll: (items) => {
|
||||
for (const item of items) jar.set(item.name, item.value, item.options);
|
||||
},
|
||||
},
|
||||
});
|
||||
await supabase.auth.signOut();
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
import { ensureDevFixtures } from "@/dev/fixtures";
|
||||
import { devAuthEnabled, signDevSession } from "@/server/auth/dev-session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!devAuthEnabled()) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Indisponível." } }, { status: 404 });
|
||||
const body = (await request.json()) as { userId?: string; workspaceId?: string };
|
||||
if (!body.userId || !body.workspaceId) {
|
||||
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Identidade incompleta." } }, { status: 400 });
|
||||
}
|
||||
const db = await getDb();
|
||||
await ensureDevFixtures(db);
|
||||
const members = await db.withService((query) =>
|
||||
query.query(`select role from public.workspace_members where user_id = $1 and workspace_id = $2`, [body.userId, body.workspaceId]),
|
||||
);
|
||||
if (!members[0]) return NextResponse.json({ error: { code: "WORKSPACE_FORBIDDEN", message: "Vínculo não encontrado." } }, { status: 403 });
|
||||
const jar = await cookies();
|
||||
jar.set("adapter_dev_session", signDevSession(body.userId, body.workspaceId), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.withService((query) => query.query("select 1 as ok"));
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
module: "spreadsheet-adapter",
|
||||
redis: process.env.REDIS_URL ? "configured" : "not_configured",
|
||||
storage: process.env.SUPABASE_STORAGE_BUCKET ? "supabase" : "unconfigured",
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
import type { AppDeps } from "@/server/spreadsheet-adapter/deps";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function errorResponse(error: unknown): NextResponse {
|
||||
if (isAdapterError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, message: error.message, details: error.details ?? null } },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
console.error("spreadsheet-adapter", error instanceof Error ? error.name : "error");
|
||||
return NextResponse.json({ error: { code: "INTERNAL", message: "Erro interno." } }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function requireReady(): Promise<{ ctx: RequestContext; deps: AppDeps }> {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous") throw new AdapterError("UNAUTHENTICATED", "Sessão Elo Pro necessária.", 401);
|
||||
if (auth.kind !== "ok") throw new AdapterError("WORKSPACE_FORBIDDEN", "Workspace não autorizado.", 403);
|
||||
const { runtimeDeps } = await import("@/server/spreadsheet-adapter/runtime");
|
||||
return { ctx: auth.ctx, deps: await runtimeDeps() };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../../_lib";
|
||||
import { downloadOutput } from "@/server/spreadsheet-adapter/process-job";
|
||||
import type { OutputFormat } from "@/domain/spreadsheet-adapter/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id } = await context.params;
|
||||
const url = new URL(request.url);
|
||||
const format = url.searchParams.get("format") as OutputFormat | null;
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
if (!sourceId || (format !== "xlsx" && format !== "csv" && format !== "pdf")) {
|
||||
return NextResponse.json({ error: { code: "INVALID_FORMAT", message: "Escolha XLSX, CSV ou PDF." } }, { status: 400 });
|
||||
}
|
||||
const file = await downloadOutput(deps, ctx, id, sourceId, format);
|
||||
return new NextResponse(new Uint8Array(file.body), {
|
||||
headers: {
|
||||
"Content-Type": file.contentType,
|
||||
"Content-Disposition": `attachment; filename="${file.filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../../_lib";
|
||||
import { rejectWorkspaceForgery, saveMappings } from "@/server/spreadsheet-adapter/service";
|
||||
import type { MappingInput } from "@/server/spreadsheet-adapter/dto";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as { sourceId?: string; mappings?: MappingInput[]; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
if (!body.sourceId || !Array.isArray(body.mappings)) {
|
||||
return NextResponse.json({ error: { code: "MAPPING_INCOMPLETE", message: "Informe o mapeamento." } }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ job: await saveMappings(deps, ctx, id, body.sourceId, body.mappings) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../../_lib";
|
||||
import { previewSource, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as { sourceId?: string; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
if (!body.sourceId) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Informe a fonte." } }, { status: 400 });
|
||||
return NextResponse.json(await previewSource(deps, ctx, id, body.sourceId));
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../../_lib";
|
||||
import { rejectWorkspaceForgery, startProcess } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as { sourceId?: string; idempotencyKey?: string; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
if (!body.sourceId || !body.idempotencyKey) {
|
||||
return NextResponse.json({ error: { code: "IDEMPOTENCY_REQUIRED", message: "Informe a fonte e a chave de idempotência." } }, { status: 400 });
|
||||
}
|
||||
const job = await startProcess(deps, ctx, id, body.sourceId, body.idempotencyKey);
|
||||
return NextResponse.json({ job });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../_lib";
|
||||
import { getJob } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id } = await context.params;
|
||||
return NextResponse.json({ job: await getJob(deps, ctx, id) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../../../_lib";
|
||||
import { rejectWorkspaceForgery, removeSource, updateSource } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id, sourceId } = await context.params;
|
||||
const body = (await request.json()) as { sheet?: string | null; headerRow?: number; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
const result = await updateSource(deps, ctx, id, sourceId, { sheet: body.sheet, headerRow: body.headerRow });
|
||||
return NextResponse.json(result, { status: result.httpStatus });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id, sourceId } = await context.params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
|
||||
return NextResponse.json({ job: await removeSource(deps, ctx, id, sourceId) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../_lib";
|
||||
import { createJob, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const body = (await request.json()) as { modelId?: string; idempotencyKey?: string; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
if (!body.modelId) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Selecione um modelo." } }, { status: 400 });
|
||||
const job = await createJob(deps, ctx, body.modelId, body.idempotencyKey);
|
||||
return NextResponse.json({ job }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../_lib";
|
||||
import { deleteModel, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const { id } = await context.params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
|
||||
await deleteModel(deps, ctx, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../_lib";
|
||||
import { createModel, listModels, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
return NextResponse.json({ models: await listModels(deps, ctx) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const form = await request.formData();
|
||||
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) return errorResponse(new Error("arquivo"));
|
||||
const requiredRaw = form.get("requiredColumns");
|
||||
const model = await createModel(deps, ctx, {
|
||||
name: String(form.get("name") ?? ""),
|
||||
filename: file.name,
|
||||
bytes: Buffer.from(await file.arrayBuffer()),
|
||||
sheet: form.get("sheet") ? String(form.get("sheet")) : null,
|
||||
headerRow: Number(form.get("headerRow") ?? 1),
|
||||
requiredColumns: requiredRaw ? (JSON.parse(String(requiredRaw)) as string[]) : undefined,
|
||||
});
|
||||
return NextResponse.json({ model }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "arquivo") {
|
||||
return NextResponse.json({ error: { code: "EMPTY_FILE", message: "Selecione um arquivo." } }, { status: 400 });
|
||||
}
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../_lib";
|
||||
import { getSettings, rejectWorkspaceForgery, updateSettings } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
return NextResponse.json(await getSettings(deps, ctx));
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const body = (await request.json()) as { allowNonAdminModelUpload?: boolean; workspace_id?: string };
|
||||
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
|
||||
if (typeof body.allowNonAdminModelUpload !== "boolean") {
|
||||
return NextResponse.json({ error: { code: "INVALID_SETTING", message: "Informe o valor da configuração." } }, { status: 400 });
|
||||
}
|
||||
await updateSettings(deps, ctx, body.allowNonAdminModelUpload);
|
||||
return NextResponse.json(await getSettings(deps, ctx));
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse, requireReady } from "../../_lib";
|
||||
import { addSource, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { ctx, deps } = await requireReady();
|
||||
const form = await request.formData();
|
||||
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
|
||||
const file = form.get("file");
|
||||
const jobId = String(form.get("jobId") ?? "");
|
||||
if (!(file instanceof File) || !jobId) {
|
||||
return NextResponse.json({ error: { code: "EMPTY_FILE", message: "Envie o arquivo e a operação." } }, { status: 400 });
|
||||
}
|
||||
const result = await addSource(
|
||||
deps,
|
||||
ctx,
|
||||
jobId,
|
||||
{ filename: file.name, bytes: Buffer.from(await file.arrayBuffer()) },
|
||||
form.get("sheet") ? String(form.get("sheet")) : null,
|
||||
Number(form.get("headerRow") ?? 1),
|
||||
);
|
||||
return NextResponse.json(result, { status: result.httpStatus });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-manrope), "Manrope", sans-serif;
|
||||
--color-cloud: #fbfbfe;
|
||||
--color-ink: #071b45;
|
||||
--color-action: #0b3d91;
|
||||
--color-bright: #2f8fff;
|
||||
--color-aqua: #00c2a8;
|
||||
--color-warning: #f39a2d;
|
||||
--color-danger: #e63946;
|
||||
--color-line: #e4e7f2;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-cloud);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-bright);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Manrope } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Adaptador de Planilhas · Elo Pro",
|
||||
description: "Módulo Elo Pro para padronizar planilhas sem alterar o modelo original.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pt-BR">
|
||||
<body className={manrope.variable}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { DEV_IDENTITIES } from "@/dev/fixtures";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
import { devAuthEnabled } from "@/server/auth/dev-session";
|
||||
import { DevLogin } from "@/components/dev-login";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LoginPage() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "ok") redirect("/adaptador");
|
||||
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) redirect(process.env.ELO_PRO_LOGIN_URL);
|
||||
return (
|
||||
<main className="mx-auto max-w-xl p-8">
|
||||
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold">Entrar no ecossistema</h1>
|
||||
<p className="mt-3 text-sm text-ink/80">
|
||||
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
|
||||
</p>
|
||||
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/adaptador");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
|
||||
import type { ModelDto } from "@/server/spreadsheet-adapter/dto";
|
||||
import { Button, Card, SemanticMarker } from "./ui";
|
||||
import { EmptyState, ErrorState, LoadingBlock } from "./states";
|
||||
|
||||
export function Dashboard({ ctx }: { ctx: RequestContext }) {
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.models(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/models");
|
||||
const data = (await response.json()) as { models?: ModelDto[]; error?: { message: string } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar modelos");
|
||||
return data.models ?? [];
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Adaptador de Planilhas</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-ink/80">
|
||||
Receba planilhas de origens diferentes, valide a estrutura, mapeie as colunas para um modelo e gere um arquivo novo sem alterar o modelo original.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/adaptador/operacao">
|
||||
<Button>Iniciar adaptação</Button>
|
||||
</Link>
|
||||
</header>
|
||||
{models.isLoading ? <LoadingBlock label="Carregando modelos" /> : null}
|
||||
{models.isError ? <ErrorState message={models.error.message} onRetry={() => models.refetch()} /> : null}
|
||||
{models.data && models.data.length === 0 ? (
|
||||
<EmptyState title="Nenhum modelo ativo" description="Cadastre um modelo XLSX ou CSV para começar uma adaptação." action={<Link href="/adaptador/configuracoes"><Button variant="secondary">Gerir modelos</Button></Link>} />
|
||||
) : null}
|
||||
{models.data && models.data.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{models.data.map((model) => (
|
||||
<Card key={model.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{model.name}</h2>
|
||||
<p className="text-sm text-ink/70">{model.columns.length} colunas · {model.format.toUpperCase()} · {new Date(model.createdAt).toLocaleDateString("pt-BR")} · {model.createdByName}</p>
|
||||
</div>
|
||||
<SemanticMarker tone="info">{model.columns.filter((column) => column.required).length} obrigatórias</SemanticMarker>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui";
|
||||
|
||||
export function DevLogin({ identities }: { identities: { userId: string; workspaceId: string; label: string }[] }) {
|
||||
const router = useRouter();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="mt-6 space-y-2">
|
||||
<p className="text-sm">Ambiente local: escolha uma identidade já vinculada a um workspace. Não há campo de senha.</p>
|
||||
{identities.map((identity) => (
|
||||
<Button
|
||||
key={identity.userId + identity.workspaceId}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const response = await fetch("/api/dev/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: identity.userId, workspaceId: identity.workspaceId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setMessage("Não foi possível abrir a sessão local.");
|
||||
return;
|
||||
}
|
||||
router.push("/adaptador");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
{identity.label}
|
||||
</Button>
|
||||
))}
|
||||
{message ? <p role="alert">{message}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { DndContext, type DragEndEvent, useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
|
||||
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
||||
import type { PreviewContract } from "@/domain/spreadsheet-adapter/preview";
|
||||
import type { JobDto, ModelDto } from "@/server/spreadsheet-adapter/dto";
|
||||
import { Button, Card, Dialog, JourneyTrail, Select, SemanticMarker, TextInput } from "./ui";
|
||||
import { EmptyState, ErrorState, LoadingBlock } from "./states";
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/* eslint-disable react-hooks/refs -- setNodeRef e listeners são a API de render do @dnd-kit */
|
||||
function SourceChip({ name }: { name: string }) {
|
||||
const drag = useDraggable({ id: `source:${name}` });
|
||||
return (
|
||||
<button type="button" ref={drag.setNodeRef} {...drag.listeners} {...drag.attributes} className="rounded-full border border-line bg-cloud px-3 py-1 text-sm">
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDrop({ name, required, value, options, onChange }: { name: string; required: boolean; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
const drop = useDroppable({ id: `model:${name}` });
|
||||
return (
|
||||
<div ref={drop.setNodeRef} className={`rounded-[14px] border p-3 ${drop.isOver ? "border-bright" : "border-line"}`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{name}</span>
|
||||
{required ? <SemanticMarker tone="warning">Obrigatória</SemanticMarker> : <span className="text-xs">Opcional</span>}
|
||||
</div>
|
||||
<label className="text-xs" htmlFor={`map-${name}`}>Coluna da fonte para {name}</label>
|
||||
<Select id={`map-${name}`} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">Não mapear</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* eslint-enable react-hooks/refs */
|
||||
|
||||
export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
const client = useQueryClient();
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [criteria, setCriteria] = useState(false);
|
||||
const [sheetDialog, setSheetDialog] = useState<{ sourceId: string; sheets: string[] } | null>(null);
|
||||
const [mappings, setMappings] = useState<{ sourceColumn: string; modelColumn: string }[]>([]);
|
||||
const [preview, setPreview] = useState<PreviewContract | null>(null);
|
||||
const [activeSource, setActiveSource] = useState<string | null>(null);
|
||||
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.models(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/models");
|
||||
const data = await readJson<{ models: ModelDto[]; error?: { message: string } }>(response);
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar modelos");
|
||||
return data.models;
|
||||
},
|
||||
});
|
||||
const job = useQuery({
|
||||
queryKey: queryKeys.job(ctx.workspaceId, jobId ?? "none"),
|
||||
enabled: Boolean(jobId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "queued" || status === "processing" ? 1000 : false;
|
||||
},
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}`);
|
||||
const data = await readJson<{ job: JobDto; error?: { message: string } }>(response);
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao consultar a operação");
|
||||
return data.job;
|
||||
},
|
||||
});
|
||||
|
||||
const source = job.data?.sources.find((item) => item.id === activeSource) ?? job.data?.sources.find((item) => item.status !== "completed");
|
||||
const validation = useMemo(() => {
|
||||
if (!job.data || !source) return null;
|
||||
return validateMappings(job.data.model.columns, source.columns.map((column) => column.name), mappings);
|
||||
}, [job.data, source, mappings]);
|
||||
|
||||
function assign(modelColumn: string, sourceColumn: string) {
|
||||
setMappings((current) => {
|
||||
const next = current.filter((item) => item.modelColumn !== modelColumn && item.sourceColumn !== sourceColumn);
|
||||
if (sourceColumn) next.push({ sourceColumn, modelColumn });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function start(modelId: string) {
|
||||
setMessage(null);
|
||||
const response = await fetch("/api/spreadsheet-adapter/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelId, idempotencyKey: crypto.randomUUID() }),
|
||||
});
|
||||
const data = await readJson<{ job?: JobDto; error?: { message: string } }>(response);
|
||||
if (!response.ok || !data.job) {
|
||||
setMessage(data.error?.message ?? "Não foi possível abrir a operação");
|
||||
return;
|
||||
}
|
||||
setJobId(data.job.id);
|
||||
setStep(1);
|
||||
await client.invalidateQueries({ queryKey: queryKeys.job(ctx.workspaceId, data.job.id) });
|
||||
}
|
||||
|
||||
async function upload(file: File) {
|
||||
if (!jobId) return;
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("jobId", jobId);
|
||||
const response = await fetch("/api/spreadsheet-adapter/sources/validate", { method: "POST", body: form });
|
||||
const data = await readJson<{ job: JobDto; code?: string; message?: string; error?: { message: string } }>(response);
|
||||
if (data.error) {
|
||||
setMessage(data.error.message);
|
||||
return;
|
||||
}
|
||||
setMessage(data.message ?? null);
|
||||
const created = data.job.sources.at(-1);
|
||||
if (created && data.code === "SHEET_REQUIRED") setSheetDialog({ sourceId: created.id, sheets: created.sheets });
|
||||
await job.refetch();
|
||||
}
|
||||
|
||||
const saveMap = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jobId || !source) return;
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: source.id, mappings }),
|
||||
});
|
||||
const data = await readJson<{ error?: { message: string } }>(response);
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Mapeamento rejeitado");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: source?.id }),
|
||||
});
|
||||
const data = await readJson<PreviewContract & { error?: { message: string } }>(response);
|
||||
if (!response.ok) {
|
||||
setMessage(data.error?.message ?? "Prévia indisponível");
|
||||
return;
|
||||
}
|
||||
setPreview(data);
|
||||
setStep(3);
|
||||
},
|
||||
});
|
||||
|
||||
async function processCurrent() {
|
||||
if (!jobId || !source) return;
|
||||
setStep(4);
|
||||
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/process`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: source.id, idempotencyKey: crypto.randomUUID() }),
|
||||
});
|
||||
const data = await readJson<{ error?: { message: string } }>(response);
|
||||
if (!response.ok) setMessage(data.error?.message ?? "Processamento não iniciado");
|
||||
await job.refetch();
|
||||
setStep(5);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold">Nova adaptação</h1>
|
||||
<p className="mt-2 text-sm text-ink/75">Uma transferência por vez. A prévia é obrigatória antes de gerar o arquivo.</p>
|
||||
</header>
|
||||
<JourneyTrail steps={["Modelo", "Fontes", "Mapeamento", "Revisão", "Processamento", "Resultado"]} current={step} />
|
||||
{message ? <ErrorState message={message} /> : null}
|
||||
{step === 0 ? (
|
||||
<Card>
|
||||
{models.isLoading ? <LoadingBlock /> : null}
|
||||
{models.isError ? <ErrorState message={models.error.message} onRetry={() => models.refetch()} /> : null}
|
||||
{models.data?.length === 0 ? <EmptyState title="Sem modelos" description="Peça a um administrador para cadastrar um modelo ou habilite o upload operacional." /> : null}
|
||||
<div className="grid gap-3">
|
||||
{models.data?.map((model) => (
|
||||
<div key={model.id} className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold">{model.name}</p>
|
||||
<p className="text-sm text-ink/70">{model.columns.map((column) => column.name).join(", ")}</p>
|
||||
</div>
|
||||
<Button onClick={() => start(model.id)}>Usar este modelo</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{step === 1 && job.data ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Fontes de {job.data.model.name}</h2>
|
||||
<Button variant="ghost" onClick={() => setCriteria(true)}>Ver critérios para upload</Button>
|
||||
</div>
|
||||
<label className="block text-sm font-medium" htmlFor="fontes">Enviar XLSX ou CSV</label>
|
||||
<input id="fontes" className="mt-2 block w-full" type="file" accept=".xlsx,.csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" multiple onChange={(event) => {
|
||||
const files = [...(event.target.files ?? [])];
|
||||
files.forEach((file) => void upload(file));
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<ul className="mt-4 space-y-3">
|
||||
{job.data.sources.map((item) => (
|
||||
<li key={item.id} className="rounded-[14px] border border-line p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong>{item.sourceName}</strong>
|
||||
<SemanticMarker tone={item.status === "invalid" || item.status === "pending" ? "error" : "success"}>{item.status}</SemanticMarker>
|
||||
</div>
|
||||
<label className="mt-2 block text-xs" htmlFor={`header-${item.id}`}>Linha do cabeçalho</label>
|
||||
<TextInput id={`header-${item.id}`} type="number" min={1} defaultValue={item.headerRow} onBlur={(event) => {
|
||||
void fetch(`/api/spreadsheet-adapter/jobs/${job.data?.id}/sources/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headerRow: Number(event.target.value), sheet: item.selectedSheet }),
|
||||
}).then(() => job.refetch());
|
||||
}} />
|
||||
<Button variant="ghost" onClick={() => fetch(`/api/spreadsheet-adapter/jobs/${job.data?.id}/sources/${item.id}`, { method: "DELETE" }).then(() => job.refetch())}>Remover</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button className="mt-4" disabled={!job.data.sources.some((item) => item.status === "validated" || item.status === "mapped")} onClick={() => {
|
||||
const next = job.data?.sources.find((item) => item.status === "validated" || item.status === "mapped");
|
||||
if (!next) return;
|
||||
setActiveSource(next.id);
|
||||
setMappings(next.mappings.map((item) => ({ sourceColumn: item.sourceColumn, modelColumn: item.modelColumn })));
|
||||
setStep(2);
|
||||
}}>Mapear fonte</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
{step === 2 && job.data && source ? (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-lg font-semibold">Mapear {source.sourceName}</h2>
|
||||
<p className="mb-4 text-sm">Arraste uma coluna ou use o select. As duas formas gravam o mesmo mapeamento 1:1.</p>
|
||||
<DndContext onDragEnd={(event: DragEndEvent) => {
|
||||
const sourceName = String(event.active.id).replace("source:", "");
|
||||
const modelName = String(event.over?.id ?? "").replace("model:", "");
|
||||
if (modelName) assign(modelName, sourceName);
|
||||
}}>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{source.columns.map((column) => <SourceChip key={column.name} name={column.name} />)}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{job.data.model.columns.map((column) => (
|
||||
<ModelDrop key={column.name} name={column.name} required={column.required} value={mappings.find((item) => item.modelColumn === column.name)?.sourceColumn ?? ""} options={source.columns.map((item) => item.name)} onChange={(value) => assign(column.name, value)} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
{validation && !validation.ok ? <p className="mt-3 text-sm" role="status">{validation.missingRequired.length ? `Faltam obrigatórias: ${validation.missingRequired.join(", ")}.` : validation.errors[0]}</p> : <SemanticMarker tone="success">Mapeamento pronto para revisão</SemanticMarker>}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setStep(1)}>Voltar</Button>
|
||||
<Button disabled={!validation?.ok || saveMap.isPending} loading={saveMap.isPending} onClick={() => saveMap.mutate()}>Revisar prévia</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{step === 3 && preview ? (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">Prévia do resultado</h2>
|
||||
<p className="mt-1 text-sm">Isto ainda não grava a saída. O modelo original permanece intacto.</p>
|
||||
<div className="mt-4 overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>{preview.columns.map((column) => <th key={column.name} className="border-b border-line p-2">{column.name}{column.required ? " (obrigatória)" : ""}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.rows.map((row, index) => <tr key={index}>{row.map((cell, cellIndex) => <td key={cellIndex} className="border-b border-line p-2">{cell}</td>)}</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setStep(2)}>Voltar ao mapeamento</Button>
|
||||
<Button onClick={() => void processCurrent()}>Processar</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{(step === 4 || step === 5) && job.data ? (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{job.data.progress?.label ?? "Processando"}</h2>
|
||||
<p className="mt-2 text-sm">{job.data.progress?.percent == null ? "Progresso ainda não é estimável." : `${job.data.progress.percent}%`}</p>
|
||||
{job.data.status === "failed" ? <ErrorState message={job.data.errorCode ?? "Falha no processamento"} onRetry={() => void processCurrent()} /> : null}
|
||||
{source?.hasOutput ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(["xlsx", "csv", "pdf"] as const).map((format) => (
|
||||
<a key={format} className="inline-flex min-h-11 items-center rounded-[18px] bg-action px-4 text-sm font-semibold text-white" href={`/api/spreadsheet-adapter/jobs/${job.data?.id}/download?format=${format}&sourceId=${source.id}`}>{format.toUpperCase()}</a>
|
||||
))}
|
||||
<Button variant="secondary" onClick={() => { setPreview(null); setStep(1); }}>Continuar com outra fonte</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{job.data.status === "completed" ? <div className="mt-4"><SemanticMarker tone="success">Adaptação concluída</SemanticMarker></div> : null}
|
||||
</Card>
|
||||
) : null}
|
||||
<Dialog open={criteria} title="Critérios para upload" onClose={() => setCriteria(false)}>
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm">
|
||||
<li>Formatos XLSX e CSV. XLS, ODS e XML ficam de fora.</li>
|
||||
<li>Até 10 MB e 50.000 linhas úteis por arquivo.</li>
|
||||
<li>Até 10 fontes nesta operação e 10 modelos ativos no workspace.</li>
|
||||
<li>Em XLSX com várias abas, escolha exatamente uma.</li>
|
||||
<li>Toda coluna usada precisa de cabeçalho. A linha pode ser ajustada.</li>
|
||||
</ul>
|
||||
<Button className="mt-4" onClick={() => setCriteria(false)}>Fechar</Button>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(sheetDialog)} title="Selecione uma aba" onClose={() => setSheetDialog(null)}>
|
||||
<div className="space-y-2">
|
||||
{sheetDialog?.sheets.map((sheet) => (
|
||||
<Button key={sheet} variant="secondary" onClick={() => {
|
||||
void fetch(`/api/spreadsheet-adapter/jobs/${jobId}/sources/${sheetDialog.sourceId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheet }),
|
||||
}).then(() => {
|
||||
setSheetDialog(null);
|
||||
void job.refetch();
|
||||
});
|
||||
}}>{sheet}</Button>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } }));
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
|
||||
import type { ModelDto } from "@/server/spreadsheet-adapter/dto";
|
||||
import { Button, Card, Dialog, Switch } from "./ui";
|
||||
import { EmptyState, ErrorState, LoadingBlock } from "./states";
|
||||
|
||||
export function SettingsPanel({ ctx }: { ctx: RequestContext }) {
|
||||
const client = useQueryClient();
|
||||
const [criteria, setCriteria] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<ModelDto | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [sheet, setSheet] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [required, setRequired] = useState<string[]>([]);
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.models(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/models");
|
||||
const data = (await response.json()) as { models: ModelDto[]; error?: { message: string } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar");
|
||||
return data.models;
|
||||
},
|
||||
});
|
||||
const settings = useQuery({
|
||||
queryKey: queryKeys.settings(ctx.workspaceId),
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/spreadsheet-adapter/settings");
|
||||
const data = (await response.json()) as { allowNonAdminModelUpload: boolean; error?: { message: string } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao ler configuração");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!file || !name.trim()) throw new Error("Informe o nome e o arquivo do modelo.");
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("name", name.trim());
|
||||
if (sheet.trim()) form.set("sheet", sheet.trim());
|
||||
form.set("requiredColumns", JSON.stringify(required));
|
||||
const response = await fetch("/api/spreadsheet-adapter/models", { method: "POST", body: form });
|
||||
const data = (await response.json()) as { error?: { message: string; details?: { sheets?: string[] } } };
|
||||
if (!response.ok) throw new Error(data.error?.message ?? "Upload rejeitado");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMessage(null);
|
||||
setFile(null);
|
||||
setName("");
|
||||
await client.invalidateQueries({ queryKey: queryKeys.models(ctx.workspaceId) });
|
||||
},
|
||||
onError: (error: Error) => setMessage(error.message),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold">Configurações</h1>
|
||||
<p className="mt-2 text-sm text-ink/75">Somente owner e admin gerenciam modelos e a permissão de upload.</p>
|
||||
</header>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold">Upload de modelo por gestor e agent</h2>
|
||||
<p className="text-sm text-ink/70">A opção não concede exclusão nem outras configurações.</p>
|
||||
</div>
|
||||
<Switch
|
||||
label="Permitir upload de modelo por perfis operacionais"
|
||||
checked={Boolean(settings.data?.allowNonAdminModelUpload)}
|
||||
disabled={settings.isLoading}
|
||||
onCheckedChange={async (value) => {
|
||||
await fetch("/api/spreadsheet-adapter/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowNonAdminModelUpload: value }),
|
||||
});
|
||||
await settings.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="mb-3 flex justify-between">
|
||||
<h2 className="font-semibold">Novo modelo</h2>
|
||||
<Button variant="ghost" onClick={() => setCriteria(true)}>Ver critérios para upload</Button>
|
||||
</div>
|
||||
<label className="text-sm" htmlFor="model-name">Nome lógico</label>
|
||||
<input id="model-name" className="mt-1 h-11 w-full rounded-[10px] border border-line px-3" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<label className="mt-3 block text-sm" htmlFor="model-sheet">Aba, se o XLSX tiver mais de uma</label>
|
||||
<input id="model-sheet" className="mt-1 h-11 w-full rounded-[10px] border border-line px-3" value={sheet} onChange={(event) => setSheet(event.target.value)} />
|
||||
<label className="mt-3 block text-sm" htmlFor="model-file">Arquivo</label>
|
||||
<input id="model-file" className="mt-1 block" type="file" accept=".xlsx,.csv" onChange={async (event) => {
|
||||
const next = event.target.files?.[0] ?? null;
|
||||
setFile(next);
|
||||
if (!next || !next.name.toLowerCase().endsWith(".csv")) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
const text = await next.text();
|
||||
const header = text.split(/\r?\n/)[0]?.split(/[;,]/).map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
setColumns(header);
|
||||
setRequired(header);
|
||||
}} />
|
||||
{columns.length > 0 ? (
|
||||
<fieldset className="mt-3">
|
||||
<legend className="text-sm font-medium">Colunas obrigatórias</legend>
|
||||
{columns.map((column) => (
|
||||
<label key={column} className="mt-1 flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={required.includes(column)} onChange={(event) => setRequired((current) => event.target.checked ? [...current, column] : current.filter((item) => item !== column))} />
|
||||
{column}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
{message ? <div className="mt-3"><ErrorState message={message} /></div> : null}
|
||||
<Button className="mt-4" loading={upload.isPending} disabled={!ctx.moduleEnabled} onClick={() => upload.mutate()}>Cadastrar modelo</Button>
|
||||
</Card>
|
||||
{models.isLoading ? <LoadingBlock /> : null}
|
||||
{models.data?.length === 0 ? <EmptyState title="Nenhum modelo" description="Os modelos ativos deste workspace aparecem aqui, em ordem alfabética." /> : null}
|
||||
<div className="grid gap-3">
|
||||
{models.data?.map((model) => (
|
||||
<Card key={model.id}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{model.name}</h3>
|
||||
<p className="text-sm text-ink/70">{model.originalFilename} · {model.createdByName}</p>
|
||||
</div>
|
||||
<Button variant="danger" onClick={() => setPendingDelete(model)}>Excluir</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Dialog open={criteria} title="Critérios para upload" onClose={() => setCriteria(false)}>
|
||||
<p className="text-sm">XLSX ou CSV, com cabeçalho identificável. O workspace pode ter até 10 modelos ativos e o nome lógico não pode repetir.</p>
|
||||
<Button className="mt-4" onClick={() => setCriteria(false)}>Fechar</Button>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(pendingDelete)} title="Excluir modelo" onClose={() => setPendingDelete(null)}>
|
||||
<p className="text-sm">Confirme a exclusão de {pendingDelete?.name}. A operação é bloqueada se houver processamento ativo.</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setPendingDelete(null)}>Cancelar</Button>
|
||||
<Button variant="danger" onClick={async () => {
|
||||
if (!pendingDelete) return;
|
||||
const response = await fetch(`/api/spreadsheet-adapter/models/${pendingDelete.id}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
const data = (await response.json()) as { error?: { message: string } };
|
||||
if (!response.ok) setMessage(data.error?.message ?? "Exclusão bloqueada");
|
||||
setPendingDelete(null);
|
||||
await models.refetch();
|
||||
}}>Confirmar exclusão</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { FileSpreadsheet, LogOut, Settings, Table2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { canManageSpreadsheetModels } from "@/domain/spreadsheet-adapter/permissions";
|
||||
|
||||
export function AppShell({ ctx, children }: { ctx: RequestContext; children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const admin = canManageSpreadsheetModels(ctx);
|
||||
const item = (href: string, label: string, icon: ReactNode) => (
|
||||
<Link href={href} aria-current={pathname === href ? "page" : undefined} className={`flex items-center gap-2 rounded-[14px] px-3 py-2 text-sm font-medium ${pathname === href ? "bg-ink text-white" : "hover:bg-white"}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-screen md:grid md:grid-cols-[240px_1fr]">
|
||||
<aside className="border-b border-line bg-white p-4 md:border-b-0 md:border-r">
|
||||
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||
<p className="mt-1 flex items-center gap-2 text-lg font-semibold">
|
||||
<FileSpreadsheet aria-hidden="true" size={18} /> Adaptador
|
||||
</p>
|
||||
<p className="mt-3 text-sm">Workspace {ctx.workspaceName}</p>
|
||||
<p className="text-xs text-ink/70">{ctx.displayName} · {ctx.role}</p>
|
||||
<nav className="mt-6 space-y-1" aria-label="Módulo">
|
||||
{item("/adaptador", "Início", <Table2 size={16} aria-hidden="true" />)}
|
||||
{item("/adaptador/operacao", "Nova adaptação", <FileSpreadsheet size={16} aria-hidden="true" />)}
|
||||
{admin ? item("/adaptador/configuracoes", "Configurações", <Settings size={16} aria-hidden="true" />) : null}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-8 flex items-center gap-2 text-sm font-semibold"
|
||||
onClick={async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<LogOut size={16} aria-hidden="true" /> Sair do Elo Pro
|
||||
</button>
|
||||
</aside>
|
||||
<main className="p-4 md:p-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function LoadingBlock({ label = "Carregando" }: { label?: string }) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="space-y-3" aria-label={label}>
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-line" />
|
||||
<div className="h-24 animate-pulse rounded-[14px] bg-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: { title: string; description: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[14px] border border-dashed border-line bg-white p-8 text-center">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="mt-2 text-sm text-ink/75">{description}</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div role="alert" className="rounded-[14px] border border-danger bg-white p-4">
|
||||
<p className="font-semibold text-danger">Não foi possível continuar</p>
|
||||
<p className="mt-1 text-sm">{message}</p>
|
||||
{onRetry ? (
|
||||
<button type="button" className="mt-3 text-sm font-semibold underline" onClick={onRetry}>
|
||||
Tentar novamente
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
loading = false,
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "danger" | "ghost"; loading?: boolean }) {
|
||||
const styles = {
|
||||
primary: "bg-action text-white hover:bg-bright",
|
||||
secondary: "bg-white text-ink border border-line hover:border-bright",
|
||||
danger: "bg-white text-danger border border-danger",
|
||||
ghost: "bg-transparent text-ink hover:bg-white",
|
||||
}[variant];
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={props.disabled || loading}
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-[18px] px-4 text-sm font-semibold transition duration-200 ${styles} ${props.className ?? ""}`}
|
||||
>
|
||||
{loading ? "Aguarde…" : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
|
||||
return (
|
||||
<button {...props} aria-label={label} className={`inline-flex size-11 items-center justify-center rounded-[18px] border border-line bg-white ${props.className ?? ""}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={`h-11 w-full rounded-[10px] border border-line bg-white px-3 ${props.className ?? ""}`} />;
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select {...props} className={`h-11 w-full rounded-[10px] border border-line bg-white px-3 ${props.className ?? ""}`} />;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={`relative h-7 w-12 rounded-full transition ${checked ? "bg-aqua" : "bg-line"}`}
|
||||
>
|
||||
<span className={`absolute top-1 size-5 rounded-full bg-white transition ${checked ? "left-6" : "left-1"}`} />
|
||||
<span className="sr-only">{checked ? "Ligado" : "Desligado"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <section className={`rounded-[14px] border border-line bg-white p-5 shadow-sm ${className}`}>{children}</section>;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-ink/40 p-4" role="presentation" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label={title} className="max-h-[90vh] w-full max-w-lg overflow-auto rounded-[24px] bg-cloud p-6" onClick={(event) => event.stopPropagation()}>
|
||||
<h2 className="mb-3 text-xl font-semibold">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SemanticMarker({ tone, children }: { tone: "success" | "warning" | "error" | "info"; children: ReactNode }) {
|
||||
const toneClass = { success: "text-aqua", warning: "text-warning", error: "text-danger", info: "text-bright" }[tone];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 text-sm font-medium ${toneClass}`}>
|
||||
<span aria-hidden="true">{tone === "success" ? "●" : tone === "error" ? "!" : "•"}</span>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function JourneyTrail({ steps, current }: { steps: string[]; current: number }) {
|
||||
return (
|
||||
<ol className="flex flex-wrap gap-2" aria-label="Jornada da adaptação">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step} aria-current={index === current ? "step" : undefined} className={`rounded-full px-3 py-1 text-sm ${index === current ? "bg-ink text-white" : index < current ? "bg-aqua/15 text-ink" : "bg-white text-ink/70"}`}>
|
||||
{index < current ? `Concluído: ${step}` : step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** TTL de temporários. O número de produto ainda é decisão aberta. */
|
||||
export function tempTtlSeconds(): number {
|
||||
const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||
if (raw && raw.trim() !== "") {
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 60 * 60 * 24 * 7) {
|
||||
throw new Error("SPREADSHEET_TEMP_TTL_SECONDS inválido.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em produção. A retenção de temporários continua decisão aberta.",
|
||||
);
|
||||
}
|
||||
return 60 * 60;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Db } from "@/infra/db/types";
|
||||
|
||||
export const FIXTURES = {
|
||||
alpha: "11111111-1111-4111-8111-111111111111",
|
||||
beta: "22222222-2222-4222-8222-222222222222",
|
||||
ana: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
bruno: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
carla: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
diego: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
eva: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
};
|
||||
|
||||
export const DEV_IDENTITIES = [
|
||||
{ userId: FIXTURES.ana, workspaceId: FIXTURES.alpha, label: "Ana · owner · Alpha (módulo ligado)" },
|
||||
{ userId: FIXTURES.bruno, workspaceId: FIXTURES.alpha, label: "Bruno · agent · Alpha" },
|
||||
{ userId: FIXTURES.carla, workspaceId: FIXTURES.alpha, label: "Carla · gestor · Alpha" },
|
||||
{ userId: FIXTURES.diego, workspaceId: FIXTURES.beta, label: "Diego · admin · Beta (módulo desligado)" },
|
||||
{ userId: FIXTURES.eva, workspaceId: FIXTURES.beta, label: "Eva · agent · Beta (superadmin de plataforma, sem atalho)" },
|
||||
];
|
||||
|
||||
export async function ensureDevFixtures(db: Db): Promise<void> {
|
||||
const existing = await db.withService((query) => query.query(`select id from public.workspaces limit 1`));
|
||||
if (existing.length > 0) return;
|
||||
const { alpha, beta, ana, bruno, carla, diego, eva } = FIXTURES;
|
||||
await db.withService(async (query) => {
|
||||
await query.query(
|
||||
`insert into public.profiles (id, display_name, is_superadmin) values
|
||||
($1,'Ana Owner', false), ($2,'Bruno Agent', false), ($3,'Carla Gestor', false), ($4,'Diego Admin', false), ($5,'Eva Agent', true)`,
|
||||
[ana, bruno, carla, diego, eva],
|
||||
);
|
||||
await query.query(`insert into public.workspaces (id, name) values ($1,'Alpha'), ($2,'Beta')`, [alpha, beta]);
|
||||
await query.query(
|
||||
`insert into public.workspace_members (workspace_id, user_id, role) values
|
||||
($1,$2,'owner'), ($1,$3,'agent'), ($1,$4,'gestor'), ($5,$6,'admin'), ($5,$7,'agent')`,
|
||||
[alpha, ana, bruno, carla, beta, diego, eva],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled) values
|
||||
($1,'spreadsheet_adapter', true), ($2,'spreadsheet_adapter', false)`,
|
||||
[alpha, beta],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function neutralizeSpreadsheetFormula(value: string): string {
|
||||
if (/^[=+@]/.test(value) || /^-(?![\d.,])/.test(value) || /^[\t\r]/.test(value)) {
|
||||
return `'${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function neutralizeRow(row: string[]): string[] {
|
||||
return row.map(neutralizeSpreadsheetFormula);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { AdapterError } from "./errors";
|
||||
import { extractHeaders } from "./headers";
|
||||
import { MAX_USEFUL_ROWS } from "./limits";
|
||||
import { isUsefulRow } from "./transform";
|
||||
import { inferColumns, typeConflictMessages } from "./typing";
|
||||
import type { ParsedGrid } from "./types";
|
||||
|
||||
export function detectDelimiter(line: string): "," | ";" {
|
||||
const commas = (line.match(/,/g) ?? []).length;
|
||||
const semis = (line.match(/;/g) ?? []).length;
|
||||
return semis > commas ? ";" : ",";
|
||||
}
|
||||
|
||||
export function parseCsvText(text: string): string[][] {
|
||||
const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
if (source.includes("\u0000")) {
|
||||
throw new AdapterError("CSV_INVALID", "O arquivo não é um CSV de texto.");
|
||||
}
|
||||
const firstLine = source.split(/\r?\n/, 1)[0] ?? "";
|
||||
const delimiter = detectDelimiter(firstLine);
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (quoted) {
|
||||
if (char === '"') {
|
||||
if (source[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i += 1;
|
||||
} else quoted = false;
|
||||
} else cell += char;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
quoted = true;
|
||||
continue;
|
||||
}
|
||||
if (char === delimiter) {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
if (char === "\n" || char === "\r") {
|
||||
if (char === "\r" && source[i + 1] === "\n") i += 1;
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
cell += char;
|
||||
}
|
||||
if (quoted) throw new AdapterError("CSV_INVALID", "CSV com aspas não fechadas.");
|
||||
if (cell.length > 0 || row.length > 0) {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function gridFromRows(allRows: string[][], headerRow: number, sheetNames: string[], selectedSheet: string | null): ParsedGrid {
|
||||
if (!Number.isInteger(headerRow) || headerRow < 1) {
|
||||
throw new AdapterError("HEADER_ROW_INVALID", "A linha de cabeçalho precisa ser um inteiro maior ou igual a 1.");
|
||||
}
|
||||
if (headerRow > allRows.length) {
|
||||
throw new AdapterError("HEADER_ROW_INVALID", "A linha de cabeçalho está além do conteúdo do arquivo.");
|
||||
}
|
||||
const headers = extractHeaders(allRows[headerRow - 1] ?? []);
|
||||
const data: string[][] = [];
|
||||
for (const raw of allRows.slice(headerRow)) {
|
||||
const sized = headers.map((_, index) => String(raw[index] ?? ""));
|
||||
if (!isUsefulRow(sized)) continue;
|
||||
data.push(sized);
|
||||
if (data.length > MAX_USEFUL_ROWS) {
|
||||
throw new AdapterError("TOO_MANY_ROWS", `O arquivo excede ${MAX_USEFUL_ROWS} linhas úteis.`);
|
||||
}
|
||||
}
|
||||
const columns = inferColumns(headers, data);
|
||||
const warnings = typeConflictMessages(columns);
|
||||
return { sheetNames, selectedSheet, headerRow, headers, rows: data, columns, warnings };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export class AdapterError extends Error {
|
||||
readonly code: string;
|
||||
readonly status: number;
|
||||
readonly details?: Record<string, unknown>;
|
||||
|
||||
constructor(code: string, message: string, status = 400, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "AdapterError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdapterError(error: unknown): error is AdapterError {
|
||||
return error instanceof AdapterError;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AdapterError } from "./errors";
|
||||
import { assertFileSize } from "./limits";
|
||||
|
||||
export function isZip(buf: Buffer): boolean {
|
||||
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
|
||||
}
|
||||
|
||||
export function isOle(buf: Buffer): boolean {
|
||||
return buf.length >= 8 && buf[0] === 0xd0 && buf[1] === 0xcf && buf[2] === 0x11 && buf[3] === 0xe0;
|
||||
}
|
||||
|
||||
export function detectSpreadsheetFormat(filename: string, buf: Buffer): "xlsx" | "csv" {
|
||||
assertFileSize(buf.length);
|
||||
const ext = filename.toLowerCase().split(".").pop() ?? "";
|
||||
if (ext === "xls" || isOle(buf)) {
|
||||
throw new AdapterError("UNSUPPORTED_FORMAT", "XLS não faz parte do MVP. Envie XLSX ou CSV.");
|
||||
}
|
||||
if (ext === "ods" || ext === "xml") {
|
||||
throw new AdapterError("UNSUPPORTED_FORMAT", "ODS e XML não fazem parte do MVP.");
|
||||
}
|
||||
if (ext === "xlsx") {
|
||||
if (!isZip(buf)) throw new AdapterError("MIME_MISMATCH", "A extensão XLSX não corresponde ao conteúdo do arquivo.");
|
||||
return "xlsx";
|
||||
}
|
||||
if (ext === "csv") {
|
||||
if (isZip(buf) || isOle(buf)) throw new AdapterError("MIME_MISMATCH", "A extensão CSV não corresponde ao conteúdo do arquivo.");
|
||||
return "csv";
|
||||
}
|
||||
throw new AdapterError("UNSUPPORTED_FORMAT", "Envie um arquivo XLSX ou CSV.");
|
||||
}
|
||||
|
||||
export function assertZipSafe(buf: Buffer): void {
|
||||
if (!isZip(buf)) throw new AdapterError("INVALID_STRUCTURE", "Arquivo XLSX inválido.");
|
||||
let offset = 0;
|
||||
let uncompressed = 0;
|
||||
let entries = 0;
|
||||
while (offset + 30 <= buf.length) {
|
||||
const sig = buf.readUInt32LE(offset);
|
||||
if (sig !== 0x04034b50) break;
|
||||
const flags = buf.readUInt16LE(offset + 6);
|
||||
const method = buf.readUInt16LE(offset + 8);
|
||||
const compressed = buf.readUInt32LE(offset + 18);
|
||||
const rawSize = buf.readUInt32LE(offset + 22);
|
||||
const nameLen = buf.readUInt16LE(offset + 26);
|
||||
const extraLen = buf.readUInt16LE(offset + 28);
|
||||
if (method !== 0 && method !== 8) {
|
||||
throw new AdapterError("INVALID_STRUCTURE", "A compressão deste XLSX não é suportada.");
|
||||
}
|
||||
entries += 1;
|
||||
if (entries > 2000) throw new AdapterError("ZIP_BOMB", "O arquivo compactado tem estrutura complexa demais.");
|
||||
uncompressed += rawSize;
|
||||
if (uncompressed > 256 * 1024 * 1024) {
|
||||
throw new AdapterError("ZIP_BOMB", "O arquivo expandiria além do limite seguro.");
|
||||
}
|
||||
if (rawSize > 0 && compressed > 0 && rawSize / compressed > 100 && rawSize > 10_000_000) {
|
||||
throw new AdapterError("ZIP_BOMB", "Taxa de compressão anômala.");
|
||||
}
|
||||
const dataStart = offset + 30 + nameLen + extraLen;
|
||||
if (flags & 0x08 && compressed === 0) break;
|
||||
if (dataStart + compressed > buf.length + 64) {
|
||||
throw new AdapterError("INVALID_STRUCTURE", "Estrutura ZIP inconsistente.");
|
||||
}
|
||||
offset = dataStart + compressed;
|
||||
}
|
||||
if (entries === 0) throw new AdapterError("INVALID_STRUCTURE", "XLSX sem conteúdo reconhecível.");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AdapterError } from "./errors";
|
||||
|
||||
const UNSAFE = /[<>:"/\\|?*\u0000-\u001f]/g;
|
||||
|
||||
export function sanitizeDisplayName(name: string): string {
|
||||
const base = name.replace(/\\/g, "/").split("/").pop() ?? "";
|
||||
const cleaned = base.replace(UNSAFE, "_").replace(/^\.+/, "").trim().slice(0, 120);
|
||||
if (!cleaned || cleaned === "." || cleaned === "..") {
|
||||
throw new AdapterError("INVALID_FILENAME", "Nome de arquivo inválido.");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function assertStorageKey(key: string): void {
|
||||
if (!key || key.startsWith("/") || key.includes("\\") || key.split("/").some((p) => p === ".." || p === "." || p === "")) {
|
||||
throw new AdapterError("PATH_TRAVERSAL", "Chave de armazenamento rejeitada.");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOutputFilename(modelName: string, format: "xlsx" | "csv" | "pdf", now = new Date()): string {
|
||||
const safe = modelName
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w.-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.slice(0, 40) || "modelo";
|
||||
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
||||
return `adaptado_${safe}_${stamp}.${format}`;
|
||||
}
|
||||
|
||||
export function normalizeModelName(name: string): string {
|
||||
const trimmed = name.trim().replace(/\s+/g, " ");
|
||||
if (trimmed.length < 1 || trimmed.length > 120) {
|
||||
throw new AdapterError("INVALID_MODEL_NAME", "Informe um nome de modelo entre 1 e 120 caracteres.");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AdapterError } from "./errors";
|
||||
|
||||
export function normalizeHeader(value: string): string {
|
||||
return value.replace(/\u00a0/g, " ").trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function extractHeaders(cells: string[]): string[] {
|
||||
const trimmed = cells.map((cell) => normalizeHeader(cell ?? ""));
|
||||
let end = trimmed.length;
|
||||
while (end > 0 && trimmed[end - 1] === "") end -= 1;
|
||||
const headers = trimmed.slice(0, end);
|
||||
if (headers.length === 0) {
|
||||
throw new AdapterError("EMPTY_HEADER", "Nenhum cabeçalho identificável nesta linha.");
|
||||
}
|
||||
if (headers.some((header) => header === "")) {
|
||||
throw new AdapterError("EMPTY_HEADER", "Há coluna utilizada sem cabeçalho. Ajuste a linha de cabeçalho.");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const header of headers) {
|
||||
const key = header.toLocaleLowerCase("pt-BR");
|
||||
if (seen.has(key)) {
|
||||
throw new AdapterError("DUPLICATE_HEADER", `O cabeçalho "${header}" está duplicado.`);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { JobStatus } from "./types";
|
||||
|
||||
const ALLOWED: Record<JobStatus, JobStatus[]> = {
|
||||
draft: ["validating", "ready", "cancelled", "expired", "failed"],
|
||||
validating: ["ready", "failed", "draft"],
|
||||
ready: ["queued", "draft", "cancelled", "validating"],
|
||||
queued: ["processing", "failed", "cancelled"],
|
||||
processing: ["completed", "failed", "ready"],
|
||||
completed: ["ready", "expired"],
|
||||
failed: ["queued", "ready", "draft"],
|
||||
expired: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export function canTransition(from: JobStatus, to: JobStatus): boolean {
|
||||
return ALLOWED[from].includes(to);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AdapterError } from "./errors";
|
||||
|
||||
export const MAX_ACTIVE_MODELS = 10;
|
||||
export const MAX_SOURCES_PER_JOB = 10;
|
||||
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_USEFUL_ROWS = 50_000;
|
||||
export const PREVIEW_ROWS = 8;
|
||||
export const PDF_MAX_ROWS = 200;
|
||||
export const PDF_MAX_COLUMNS = 12;
|
||||
|
||||
export function assertFileSize(size: number): void {
|
||||
if (size <= 0) {
|
||||
throw new AdapterError("EMPTY_FILE", "O arquivo está vazio.");
|
||||
}
|
||||
if (size > MAX_FILE_BYTES) {
|
||||
throw new AdapterError("FILE_TOO_LARGE", "O arquivo excede 10 MB.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ColumnSchema, MappingInput } from "./types";
|
||||
|
||||
export type MappingValidation = {
|
||||
ok: boolean;
|
||||
code: "OK" | "MAPPING_CONFLICT" | "MAPPING_INCOMPLETE" | "MAPPING_UNKNOWN";
|
||||
errors: string[];
|
||||
missingRequired: string[];
|
||||
};
|
||||
|
||||
export function validateMappings(
|
||||
modelColumns: Pick<ColumnSchema, "name" | "required">[],
|
||||
sourceColumns: string[],
|
||||
mappings: MappingInput[],
|
||||
): MappingValidation {
|
||||
const errors: string[] = [];
|
||||
const sourceSet = new Set(sourceColumns);
|
||||
const modelSet = new Set(modelColumns.map((column) => column.name));
|
||||
const usedSource = new Set<string>();
|
||||
const usedModel = new Set<string>();
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!sourceSet.has(mapping.sourceColumn) || !modelSet.has(mapping.modelColumn)) {
|
||||
errors.push("O mapeamento referencia uma coluna que não existe na fonte ou no modelo.");
|
||||
}
|
||||
if (usedSource.has(mapping.sourceColumn)) {
|
||||
errors.push(`A coluna fonte "${mapping.sourceColumn}" está mapeada mais de uma vez.`);
|
||||
}
|
||||
if (usedModel.has(mapping.modelColumn)) {
|
||||
errors.push(`A coluna do modelo "${mapping.modelColumn}" recebe mais de uma fonte.`);
|
||||
}
|
||||
usedSource.add(mapping.sourceColumn);
|
||||
usedModel.add(mapping.modelColumn);
|
||||
}
|
||||
|
||||
const missingRequired = modelColumns.filter((column) => column.required && !usedModel.has(column.name)).map((column) => column.name);
|
||||
const unknown = errors.some((error) => error.includes("não existe"));
|
||||
const conflict = errors.some((error) => error.includes("mais de uma"));
|
||||
const code = unknown ? "MAPPING_UNKNOWN" : conflict ? "MAPPING_CONFLICT" : missingRequired.length ? "MAPPING_INCOMPLETE" : "OK";
|
||||
return { ok: code === "OK", code, errors, missingRequired };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RequestContext, Role } from "./types";
|
||||
|
||||
const OPERATIONAL: Role[] = ["owner", "admin", "gestor", "agent"];
|
||||
|
||||
export function canUseSpreadsheetAdapter(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
|
||||
return ctx.moduleEnabled && OPERATIONAL.includes(ctx.role);
|
||||
}
|
||||
|
||||
export function canManageSpreadsheetModels(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
|
||||
return canUseSpreadsheetAdapter(ctx) && (ctx.role === "owner" || ctx.role === "admin");
|
||||
}
|
||||
|
||||
export function canUploadSpreadsheetModel(
|
||||
ctx: Pick<RequestContext, "role" | "moduleEnabled" | "allowNonAdminModelUpload">,
|
||||
): boolean {
|
||||
if (!canUseSpreadsheetAdapter(ctx)) return false;
|
||||
if (ctx.role === "owner" || ctx.role === "admin") return true;
|
||||
return ctx.allowNonAdminModelUpload && (ctx.role === "gestor" || ctx.role === "agent");
|
||||
}
|
||||
|
||||
export function canOperateSpreadsheet(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
|
||||
return canUseSpreadsheetAdapter(ctx);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PREVIEW_ROWS } from "./limits";
|
||||
import { transformRows } from "./transform";
|
||||
import type { ColumnSchema, MappingInput } from "./types";
|
||||
|
||||
export type PreviewContract = {
|
||||
preview: true;
|
||||
columns: { name: string; required: boolean }[];
|
||||
rows: string[][];
|
||||
rowCount: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export function buildPreview(
|
||||
modelColumns: ColumnSchema[],
|
||||
sourceHeaders: string[],
|
||||
dataRows: string[][],
|
||||
mappings: MappingInput[],
|
||||
): PreviewContract {
|
||||
const ordered = modelColumns.map((column) => column.name);
|
||||
const all = transformRows(ordered, sourceHeaders, dataRows, mappings);
|
||||
return {
|
||||
preview: true,
|
||||
columns: modelColumns.map((column) => ({ name: column.name, required: column.required })),
|
||||
rows: all.slice(0, PREVIEW_ROWS),
|
||||
rowCount: all.length,
|
||||
truncated: all.length > PREVIEW_ROWS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const queryKeys = {
|
||||
models: (workspaceId: string) => ["spreadsheet-adapter", workspaceId, "models"] as const,
|
||||
settings: (workspaceId: string) => ["spreadsheet-adapter", workspaceId, "settings"] as const,
|
||||
job: (workspaceId: string, jobId: string) => ["spreadsheet-adapter", workspaceId, "job", jobId] as const,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MappingInput } from "./types";
|
||||
|
||||
export function isUsefulRow(row: string[]): boolean {
|
||||
return row.some((cell) => cell.trim() !== "");
|
||||
}
|
||||
|
||||
export function transformRows(
|
||||
modelColumns: string[],
|
||||
sourceHeaders: string[],
|
||||
dataRows: string[][],
|
||||
mappings: MappingInput[],
|
||||
): string[][] {
|
||||
const indexByHeader = new Map(sourceHeaders.map((header, index) => [header, index]));
|
||||
const sourceByModel = new Map(mappings.map((mapping) => [mapping.modelColumn, mapping.sourceColumn]));
|
||||
return dataRows.filter(isUsefulRow).map((row) =>
|
||||
modelColumns.map((column) => {
|
||||
const source = sourceByModel.get(column);
|
||||
if (!source) return "";
|
||||
const index = indexByHeader.get(source);
|
||||
if (index === undefined) return "";
|
||||
return row[index] ?? "";
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export const ROLES = ["owner", "admin", "gestor", "agent"] as const;
|
||||
export type Role = (typeof ROLES)[number];
|
||||
|
||||
export const JOB_STATUSES = [
|
||||
"draft",
|
||||
"validating",
|
||||
"ready",
|
||||
"queued",
|
||||
"processing",
|
||||
"completed",
|
||||
"failed",
|
||||
"expired",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type JobStatus = (typeof JOB_STATUSES)[number];
|
||||
|
||||
export const SOURCE_STATUSES = [
|
||||
"pending",
|
||||
"validated",
|
||||
"invalid",
|
||||
"mapped",
|
||||
"processing",
|
||||
"completed",
|
||||
"failed",
|
||||
"expired",
|
||||
] as const;
|
||||
export type SourceStatus = (typeof SOURCE_STATUSES)[number];
|
||||
|
||||
export type SpreadsheetFormat = "xlsx" | "csv";
|
||||
export type OutputFormat = "xlsx" | "csv" | "pdf";
|
||||
export type InferredType = "text" | "number" | "date" | "empty" | "mixed";
|
||||
|
||||
export type ColumnSchema = {
|
||||
name: string;
|
||||
position: number;
|
||||
required: boolean;
|
||||
inferredType: InferredType;
|
||||
};
|
||||
|
||||
export type MappingInput = {
|
||||
sourceColumn: string;
|
||||
modelColumn: string;
|
||||
};
|
||||
|
||||
export type ParsedGrid = {
|
||||
sheetNames: string[];
|
||||
selectedSheet: string | null;
|
||||
headerRow: number;
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
columns: ColumnSchema[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type JobProgress = {
|
||||
phase: "validating" | "processing" | "generating" | "completed" | "failed";
|
||||
label: string;
|
||||
percent: number | null;
|
||||
sourceIndex: number;
|
||||
sourceCount: number;
|
||||
sourceId?: string;
|
||||
};
|
||||
|
||||
export type RequestContext = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
isSuperadmin: boolean;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
role: Role;
|
||||
moduleEnabled: boolean;
|
||||
allowNonAdminModelUpload: boolean;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ColumnSchema, InferredType } from "./types";
|
||||
|
||||
const NUMBER_RE = /^-?\d{1,15}([.,]\d+)?$/;
|
||||
const DATE_RE = /^(\d{4}-\d{2}-\d{2}|\d{2}\/\d{2}\/\d{4}|\d{2}-\d{2}-\d{4})$/;
|
||||
|
||||
function classify(value: string): InferredType | null {
|
||||
const v = value.trim();
|
||||
if (!v) return null;
|
||||
if (NUMBER_RE.test(v)) return "number";
|
||||
if (DATE_RE.test(v)) return "date";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function inferColumns(headers: string[], rows: string[][], requiredNames?: Set<string>): ColumnSchema[] {
|
||||
return headers.map((name, position) => {
|
||||
const kinds = new Set<InferredType>();
|
||||
for (const row of rows) {
|
||||
const kind = classify(row[position] ?? "");
|
||||
if (kind) kinds.add(kind);
|
||||
}
|
||||
let inferredType: InferredType = "empty";
|
||||
if (kinds.size === 1) inferredType = [...kinds][0];
|
||||
else if (kinds.size > 1) inferredType = "mixed";
|
||||
return {
|
||||
name,
|
||||
position,
|
||||
required: requiredNames ? requiredNames.has(name) : true,
|
||||
inferredType,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function typeConflictMessages(columns: ColumnSchema[]): string[] {
|
||||
return columns
|
||||
.filter((column) => column.inferredType === "mixed")
|
||||
.map((column) => `A coluna "${column.name}" mistura tipos e o MVP não converte valores automaticamente.`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import "server-only";
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "./pglite";
|
||||
import { createPostgresDb } from "./postgres";
|
||||
import { migrate } from "./migrate";
|
||||
import type { Db } from "./types";
|
||||
|
||||
let singleton: Promise<Db> | null = null;
|
||||
|
||||
export async function getDb(): Promise<Db> {
|
||||
if (!singleton) {
|
||||
singleton = (async () => {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (databaseUrl) return createPostgresDb(databaseUrl);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("DATABASE_URL é obrigatório em produção.");
|
||||
}
|
||||
const db = await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await migrate(db);
|
||||
return db;
|
||||
})();
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Db } from "./types";
|
||||
|
||||
export function migrationsDir(): string {
|
||||
return path.join(process.cwd(), "migrations");
|
||||
}
|
||||
|
||||
export async function migrate(db: Db, directory = migrationsDir()): Promise<string[]> {
|
||||
await db.exec(`
|
||||
create table if not exists public.schema_migrations (
|
||||
id text primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
const files = (await readdir(directory)).filter((file) => file.endsWith(".sql")).sort();
|
||||
const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"));
|
||||
const applied = new Set(appliedRows.map((row) => row.id));
|
||||
const ran: string[] = [];
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = await readFile(path.join(directory, file), "utf8");
|
||||
await db.exec(sql);
|
||||
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file]));
|
||||
ran.push(file);
|
||||
}
|
||||
return ran;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { createMutex, type Db, type Query, type Row, assertUuid } from "./types";
|
||||
|
||||
type PgClient = {
|
||||
exec(sql: string): Promise<unknown>;
|
||||
query(sql: string, params?: unknown[]): Promise<{ rows: Row[] }>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
function asQuery(client: PgClient): Query {
|
||||
return {
|
||||
async query<T = Row>(sql: string, params: unknown[] = []) {
|
||||
const result = await client.query(sql, params);
|
||||
return result.rows as T[];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapPglite(client: PgClient): Db {
|
||||
const exclusive = createMutex();
|
||||
return {
|
||||
exec(sql) {
|
||||
return exclusive(() => client.exec(sql).then(() => undefined));
|
||||
},
|
||||
withService(fn) {
|
||||
return exclusive(() => fn(asQuery(client)));
|
||||
},
|
||||
withUser(userId, fn) {
|
||||
assertUuid(userId, "user");
|
||||
return exclusive(async () => {
|
||||
await client.exec("begin");
|
||||
try {
|
||||
await client.exec(`select set_config('app.user_id', '${userId}', true)`);
|
||||
await client.exec("set local role app_user");
|
||||
const value = await fn(asQuery(client));
|
||||
await client.exec("commit");
|
||||
return value;
|
||||
} catch (error) {
|
||||
await client.exec("rollback");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
close() {
|
||||
return client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMemoryDb(): Promise<Db> {
|
||||
const client = new PGlite();
|
||||
return wrapPglite(client as unknown as PgClient);
|
||||
}
|
||||
|
||||
export async function createFileDb(directory: string): Promise<Db> {
|
||||
const client = new PGlite(directory);
|
||||
return wrapPglite(client as unknown as PgClient);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import postgres from "postgres";
|
||||
import { assertUuid, type Db, type Query, type Row } from "./types";
|
||||
|
||||
export function createPostgresDb(connectionString: string): Db {
|
||||
const sql = postgres(connectionString, { max: 8, prepare: false });
|
||||
const serviceQuery: Query = {
|
||||
async query<T = Row>(text: string, params: unknown[] = []) {
|
||||
return sql.unsafe(text, params as never[]) as unknown as T[];
|
||||
},
|
||||
};
|
||||
return {
|
||||
async exec(text: string) {
|
||||
await sql.unsafe(text);
|
||||
},
|
||||
withService(fn) {
|
||||
return fn(serviceQuery);
|
||||
},
|
||||
withUser: async <T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T> => {
|
||||
assertUuid(userId, "user");
|
||||
const result = await sql.begin(async (tx) => {
|
||||
await tx.unsafe(`select set_config('app.user_id', '${userId}', true)`);
|
||||
await tx.unsafe("set local role app_user");
|
||||
const query: Query = {
|
||||
async query<R = Row>(text: string, params: unknown[] = []) {
|
||||
return tx.unsafe(text, params as never[]) as unknown as R[];
|
||||
},
|
||||
};
|
||||
return fn(query);
|
||||
});
|
||||
return result as T;
|
||||
},
|
||||
async close() {
|
||||
await sql.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export interface Query {
|
||||
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||
}
|
||||
|
||||
export interface Db {
|
||||
exec(sql: string): Promise<void>;
|
||||
withUser<T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T>;
|
||||
withService<T>(fn: (query: Query) => Promise<T>): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function assertUuid(value: string, label = "id"): void {
|
||||
if (!UUID_RE.test(value)) {
|
||||
throw new Error(`${label} inválido`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMutex() {
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
return function exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = chain.then(fn, fn);
|
||||
chain = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Document, Page, StyleSheet, Text, View, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { PDF_MAX_COLUMNS, PDF_MAX_ROWS } from "@/domain/spreadsheet-adapter/limits";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { padding: 24, fontSize: 8, color: "#071B45" },
|
||||
title: { fontSize: 14, marginBottom: 6 },
|
||||
note: { fontSize: 8, marginBottom: 10, color: "#0B3A82" },
|
||||
row: { flexDirection: "row", borderBottomWidth: 0.4, borderBottomColor: "#D9DEEA", paddingVertical: 2 },
|
||||
header: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#071B45", paddingBottom: 3, marginBottom: 2 },
|
||||
cell: { flex: 1, paddingRight: 4 },
|
||||
});
|
||||
|
||||
export async function buildPdf(title: string, headers: string[], rows: string[][]): Promise<{ body: Buffer; truncated: boolean }> {
|
||||
const usedHeaders = headers.slice(0, PDF_MAX_COLUMNS);
|
||||
const usedRows = rows.slice(0, PDF_MAX_ROWS).map((row) => row.slice(0, PDF_MAX_COLUMNS));
|
||||
const truncated = rows.length > PDF_MAX_ROWS || headers.length > PDF_MAX_COLUMNS;
|
||||
const note = truncated
|
||||
? "Prévia em PDF limitada para leitura. Os arquivos XLSX e CSV contêm o resultado completo."
|
||||
: "Resultado gerado pelo Adaptador de Planilhas. O modelo original não foi alterado.";
|
||||
const element = createElement(
|
||||
Document,
|
||||
null,
|
||||
createElement(
|
||||
Page,
|
||||
{ size: "A4", orientation: "landscape", style: styles.page },
|
||||
createElement(Text, { style: styles.title }, title),
|
||||
createElement(Text, { style: styles.note }, note),
|
||||
createElement(
|
||||
View,
|
||||
{ style: styles.header },
|
||||
...usedHeaders.map((header, index) => createElement(Text, { key: `h-${index}`, style: styles.cell }, header)),
|
||||
),
|
||||
...usedRows.map((row, rowIndex) =>
|
||||
createElement(
|
||||
View,
|
||||
{ key: `r-${rowIndex}`, style: styles.row },
|
||||
...usedHeaders.map((_, column) => createElement(Text, { key: `c-${rowIndex}-${column}`, style: styles.cell }, row[column] ?? "")),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const raw = await renderToBuffer(element);
|
||||
return { body: Buffer.from(raw), truncated };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
function escapeCell(value: string): string {
|
||||
if (/[",\r\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildCsv(headers: string[], rows: string[][]): Buffer {
|
||||
const lines = [headers, ...rows].map((row) => row.map(escapeCell).join(","));
|
||||
return Buffer.from(`\uFEFF${lines.join("\r\n")}`, "utf8");
|
||||
}
|
||||
|
||||
export async function buildXlsx(headers: string[], rows: string[][]): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Adaptado");
|
||||
const headerRow = sheet.addRow(headers);
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.numFmt = "@";
|
||||
});
|
||||
for (const row of rows) {
|
||||
const added = sheet.addRow(row);
|
||||
added.eachCell((cell) => {
|
||||
cell.numFmt = "@";
|
||||
});
|
||||
}
|
||||
const raw = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(raw);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export { scrubSentryEvent } from "./scrub";
|
||||
|
||||
export function captureAdapterException(error: unknown, tags: Record<string, string>): void {
|
||||
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||
void import("@sentry/nextjs").then((Sentry) => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("module", "spreadsheet-adapter");
|
||||
for (const [key, value] of Object.entries(tags)) scope.setTag(key, value.slice(0, 120));
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function sentryInitOptions(dsn: string | undefined) {
|
||||
if (!dsn) return null;
|
||||
return {
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
sendDefaultPii: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const BLOCKED_KEY = /^(cells|rows|row_values|values|content|token|password|secret|authorization|cookie|file_base64|spreadsheet|csv_body|xlsx_body)$/i;
|
||||
|
||||
export function sanitizeAuditMetadata(input: Record<string, unknown> | undefined): Record<string, string | number | boolean | null> {
|
||||
const output: Record<string, string | number | boolean | null> = {};
|
||||
if (!input) return output;
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (BLOCKED_KEY.test(key)) continue;
|
||||
if (typeof value === "string") output[key] = value.slice(0, 180);
|
||||
else if (typeof value === "number" || typeof value === "boolean" || value === null) output[key] = value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function scrubSentryEvent<T extends { request?: { data?: unknown; cookies?: unknown; headers?: Record<string, string> }; extra?: Record<string, unknown>; breadcrumbs?: unknown[] }>(
|
||||
event: T,
|
||||
): T {
|
||||
if (event.request) {
|
||||
delete event.request.data;
|
||||
delete event.request.cookies;
|
||||
if (event.request.headers) {
|
||||
for (const key of Object.keys(event.request.headers)) {
|
||||
if (BLOCKED_KEY.test(key) || key.toLowerCase() === "authorization" || key.toLowerCase() === "cookie") {
|
||||
delete event.request.headers[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.extra) {
|
||||
for (const key of Object.keys(event.extra)) {
|
||||
if (BLOCKED_KEY.test(key)) delete event.extra[key];
|
||||
}
|
||||
}
|
||||
event.breadcrumbs = [];
|
||||
return event;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory";
|
||||
import { RedisCoordinator } from "./redis";
|
||||
|
||||
export function createCoordinator(): Coordinator {
|
||||
if (process.env.REDIS_URL) return new RedisCoordinator(process.env.REDIS_URL);
|
||||
if (process.env.NODE_ENV === "production") return new UnavailableCoordinator();
|
||||
return new MemoryCoordinator();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
|
||||
export interface Coordinator {
|
||||
acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
|
||||
renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
|
||||
ownsLock(key: string, owner: string): Promise<boolean>;
|
||||
releaseLock(key: string, owner: string): Promise<void>;
|
||||
setProgress(jobId: string, progress: JobProgress): Promise<void>;
|
||||
getProgress(jobId: string): Promise<JobProgress | null>;
|
||||
}
|
||||
|
||||
type LockRecord = { owner: string; expiresAt: number };
|
||||
|
||||
export class MemoryCoordinator implements Coordinator {
|
||||
private readonly locks = new Map<string, LockRecord>();
|
||||
private readonly progress = new Map<string, JobProgress>();
|
||||
|
||||
constructor(private readonly now: () => number = () => Date.now()) {}
|
||||
|
||||
private live(key: string): LockRecord | null {
|
||||
const record = this.locks.get(key);
|
||||
if (!record) return null;
|
||||
if (record.expiresAt <= this.now()) {
|
||||
this.locks.delete(key);
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
|
||||
const current = this.live(key);
|
||||
if (current && current.owner !== owner) return false;
|
||||
this.locks.set(key, { owner, expiresAt: this.now() + ttlSeconds * 1000 });
|
||||
return true;
|
||||
}
|
||||
|
||||
async renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
|
||||
const current = this.live(key);
|
||||
if (!current || current.owner !== owner) return false;
|
||||
current.expiresAt = this.now() + ttlSeconds * 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
async ownsLock(key: string, owner: string): Promise<boolean> {
|
||||
return this.live(key)?.owner === owner;
|
||||
}
|
||||
|
||||
async releaseLock(key: string, owner: string): Promise<void> {
|
||||
const current = this.live(key);
|
||||
if (current?.owner === owner) this.locks.delete(key);
|
||||
}
|
||||
|
||||
async setProgress(jobId: string, progress: JobProgress): Promise<void> {
|
||||
this.progress.set(jobId, progress);
|
||||
}
|
||||
|
||||
async getProgress(jobId: string): Promise<JobProgress | null> {
|
||||
return this.progress.get(jobId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnavailableCoordinator implements Coordinator {
|
||||
private fail(): never {
|
||||
throw new AdapterError("REDIS_UNAVAILABLE", "Coordenação indisponível. O processamento não foi iniciado.", 503);
|
||||
}
|
||||
|
||||
acquireLock(): Promise<boolean> {
|
||||
this.fail();
|
||||
}
|
||||
renewLock(): Promise<boolean> {
|
||||
this.fail();
|
||||
}
|
||||
ownsLock(): Promise<boolean> {
|
||||
this.fail();
|
||||
}
|
||||
releaseLock(): Promise<void> {
|
||||
this.fail();
|
||||
}
|
||||
setProgress(): Promise<void> {
|
||||
this.fail();
|
||||
}
|
||||
getProgress(): Promise<JobProgress | null> {
|
||||
this.fail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Redis from "ioredis";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
|
||||
import type { Coordinator } from "./memory";
|
||||
|
||||
const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
||||
const RENEW = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end`;
|
||||
|
||||
export class RedisCoordinator implements Coordinator {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(url: string) {
|
||||
this.redis = new Redis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
connectTimeout: 2000,
|
||||
lazyConnect: true,
|
||||
});
|
||||
this.redis.on("error", () => undefined);
|
||||
}
|
||||
|
||||
private async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
if (this.redis.status === "wait") await this.redis.connect();
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof AdapterError) throw error;
|
||||
throw new AdapterError("REDIS_UNAVAILABLE", "Coordenação indisponível. O processamento não foi iniciado.", 503);
|
||||
}
|
||||
}
|
||||
|
||||
async acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
|
||||
return this.run(async () => (await this.redis.set(key, owner, "EX", ttlSeconds, "NX")) === "OK");
|
||||
}
|
||||
|
||||
async renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
|
||||
return this.run(async () => Number(await this.redis.eval(RENEW, 1, key, owner, String(ttlSeconds))) === 1);
|
||||
}
|
||||
|
||||
async ownsLock(key: string, owner: string): Promise<boolean> {
|
||||
return this.run(async () => (await this.redis.get(key)) === owner);
|
||||
}
|
||||
|
||||
async releaseLock(key: string, owner: string): Promise<void> {
|
||||
await this.run(async () => {
|
||||
await this.redis.eval(RELEASE, 1, key, owner);
|
||||
});
|
||||
}
|
||||
|
||||
async setProgress(jobId: string, progress: JobProgress): Promise<void> {
|
||||
await this.run(async () => {
|
||||
await this.redis.set(progressKey(jobId), JSON.stringify(progress), "EX", 60 * 60);
|
||||
});
|
||||
}
|
||||
|
||||
async getProgress(jobId: string): Promise<JobProgress | null> {
|
||||
return this.run(async () => {
|
||||
const raw = await this.redis.get(progressKey(jobId));
|
||||
return raw ? (JSON.parse(raw) as JobProgress) : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function envNamespace(): string {
|
||||
return process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "local";
|
||||
}
|
||||
|
||||
export function lockKey(userId: string): string {
|
||||
return `spreadsheet-adapter:${envNamespace()}:lock:user:${userId}`;
|
||||
}
|
||||
|
||||
export function progressKey(jobId: string): string {
|
||||
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { gridFromRows, parseCsvText } from "@/domain/spreadsheet-adapter/csv";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { assertZipSafe, detectSpreadsheetFormat } from "@/domain/spreadsheet-adapter/file-signature";
|
||||
import { MAX_USEFUL_ROWS } from "@/domain/spreadsheet-adapter/limits";
|
||||
import type { ParsedGrid, SpreadsheetFormat } from "@/domain/spreadsheet-adapter/types";
|
||||
|
||||
export async function readSpreadsheet(
|
||||
filename: string,
|
||||
bytes: Buffer,
|
||||
options: { sheet?: string | null; headerRow?: number } = {},
|
||||
): Promise<{ format: SpreadsheetFormat; grid: ParsedGrid }> {
|
||||
const format = detectSpreadsheetFormat(filename, bytes);
|
||||
const headerRow = options.headerRow ?? 1;
|
||||
if (format === "csv") {
|
||||
return { format, grid: gridFromRows(parseCsvText(bytes.toString("utf8")), headerRow, ["CSV"], "CSV") };
|
||||
}
|
||||
assertZipSafe(bytes);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
try {
|
||||
await workbook.xlsx.load(bytes as unknown as ExcelJS.Buffer);
|
||||
} catch {
|
||||
throw new AdapterError("INVALID_STRUCTURE", "Não foi possível ler o XLSX. O arquivo pode estar corrompido.");
|
||||
}
|
||||
const names = workbook.worksheets.map((sheet) => sheet.name).filter(Boolean);
|
||||
if (names.length === 0) throw new AdapterError("INVALID_STRUCTURE", "O XLSX não possui abas.");
|
||||
if (!options.sheet && names.length > 1) {
|
||||
throw new AdapterError("SHEET_REQUIRED", "Selecione exatamente uma aba.", 409, { sheets: names });
|
||||
}
|
||||
const selected = options.sheet || names[0];
|
||||
const sheet = workbook.getWorksheet(selected);
|
||||
if (!sheet) throw new AdapterError("SHEET_REQUIRED", "A aba selecionada não existe.", 409, { sheets: names });
|
||||
const matrix: string[][] = [];
|
||||
const last = Math.min(sheet.rowCount, headerRow + MAX_USEFUL_ROWS + 1);
|
||||
for (let rowIndex = 1; rowIndex <= last; rowIndex += 1) {
|
||||
const row = sheet.getRow(rowIndex);
|
||||
let width = row.cellCount;
|
||||
row.eachCell({ includeEmpty: false }, (_cell, column) => {
|
||||
width = Math.max(width, column);
|
||||
});
|
||||
const values: string[] = [];
|
||||
for (let column = 1; column <= width; column += 1) values.push(row.getCell(column).text ?? "");
|
||||
matrix.push(values);
|
||||
}
|
||||
if (sheet.rowCount > last) {
|
||||
const extra = sheet.getRow(last + 1);
|
||||
if (extra.hasValues) matrix.push(["__overflow__"]);
|
||||
}
|
||||
return { format, grid: gridFromRows(matrix, headerRow, names, selected) };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { LocalPrivateStorage } from "./local";
|
||||
import { createSupabaseStorage } from "./supabase";
|
||||
import type { ObjectStorage } from "./memory";
|
||||
|
||||
export function createStorage(): ObjectStorage {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const bucket = process.env.SUPABASE_STORAGE_BUCKET;
|
||||
if (url && serviceKey && bucket) return createSupabaseStorage(url, serviceKey, bucket);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("Armazenamento privado não configurado. A decisão de object storage continua aberta.");
|
||||
}
|
||||
return new LocalPrivateStorage(path.join(process.cwd(), ".data", "storage"));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames";
|
||||
import type { ObjectStorage } from "./memory";
|
||||
|
||||
export class LocalPrivateStorage implements ObjectStorage {
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
private resolve(key: string): string {
|
||||
assertStorageKey(key);
|
||||
const full = path.resolve(this.root, ...key.split("/"));
|
||||
const root = path.resolve(this.root);
|
||||
if (full !== root && !full.startsWith(root + path.sep)) {
|
||||
throw new AdapterError("PATH_TRAVERSAL", "Chave de armazenamento rejeitada.");
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
async put(key: string, body: Buffer): Promise<void> {
|
||||
const full = this.resolve(key);
|
||||
await mkdir(path.dirname(full), { recursive: true });
|
||||
await writeFile(full, body);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<Buffer> {
|
||||
try {
|
||||
return await readFile(this.resolve(key));
|
||||
} catch (error) {
|
||||
if (error instanceof AdapterError) throw error;
|
||||
throw new AdapterError("NOT_FOUND", "Arquivo não encontrado.", 404);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await rm(this.resolve(key), { force: true });
|
||||
} catch (error) {
|
||||
if (error instanceof AdapterError) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix: string): Promise<string[]> {
|
||||
const normalized = prefix.replace(/\/$/, "");
|
||||
const start = this.resolve(normalized.includes("/") ? normalized : `${normalized}/noop`.replace("/noop", ""));
|
||||
const root = path.resolve(this.root);
|
||||
const found: string[] = [];
|
||||
const walk = async (dir: string) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(full);
|
||||
else found.push(path.relative(root, full).split(path.sep).join("/"));
|
||||
}
|
||||
};
|
||||
await walk(start);
|
||||
return found.filter((key) => key.startsWith(prefix.replace(/\/$/, "")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames";
|
||||
|
||||
export interface ObjectStorage {
|
||||
put(key: string, body: Buffer): Promise<void>;
|
||||
get(key: string): Promise<Buffer>;
|
||||
delete(key: string): Promise<void>;
|
||||
list(prefix: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export class MemoryStorage implements ObjectStorage {
|
||||
private readonly objects = new Map<string, Buffer>();
|
||||
|
||||
async put(key: string, body: Buffer): Promise<void> {
|
||||
assertStorageKey(key);
|
||||
this.objects.set(key, Buffer.from(body));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<Buffer> {
|
||||
assertStorageKey(key);
|
||||
const value = this.objects.get(key);
|
||||
if (!value) throw new AdapterError("NOT_FOUND", "Arquivo não encontrado.", 404);
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
assertStorageKey(key);
|
||||
this.objects.delete(key);
|
||||
}
|
||||
|
||||
async list(prefix: string): Promise<string[]> {
|
||||
return [...this.objects.keys()].filter((key) => key.startsWith(prefix));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames";
|
||||
import type { ObjectStorage } from "./memory";
|
||||
|
||||
export function createSupabaseStorage(url: string, serviceKey: string, bucket: string): ObjectStorage {
|
||||
const client: SupabaseClient = createClient(url, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
const bucketApi = () => client.storage.from(bucket);
|
||||
return {
|
||||
async put(key, body) {
|
||||
assertStorageKey(key);
|
||||
const { error } = await bucketApi().upload(key, body, { upsert: false, contentType: "application/octet-stream" });
|
||||
if (error) throw new AdapterError("STORAGE_FAILURE", "Falha ao gravar no armazenamento privado.", 503);
|
||||
},
|
||||
async get(key) {
|
||||
assertStorageKey(key);
|
||||
const { data, error } = await bucketApi().download(key);
|
||||
if (error || !data) throw new AdapterError("NOT_FOUND", "Arquivo não encontrado.", 404);
|
||||
return Buffer.from(await data.arrayBuffer());
|
||||
},
|
||||
async delete(key) {
|
||||
assertStorageKey(key);
|
||||
await bucketApi().remove([key]);
|
||||
},
|
||||
async list(prefix) {
|
||||
const folder = prefix.replace(/\/$/, "");
|
||||
const { data, error } = await bucketApi().list(folder, { limit: 1000 });
|
||||
if (error || !data) return [];
|
||||
return data.filter((item) => item.name).map((item) => `${folder}/${item.name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { scrubSentryEvent, sentryInitOptions } from "@/infra/observability/capture";
|
||||
|
||||
const options = sentryInitOptions(process.env.NEXT_PUBLIC_SENTRY_DSN);
|
||||
if (options) {
|
||||
Sentry.init({
|
||||
...options,
|
||||
beforeSend(event) {
|
||||
return scrubSentryEvent(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { scrubSentryEvent, sentryInitOptions } from "@/infra/observability/capture";
|
||||
|
||||
export async function register() {
|
||||
const options = sentryInitOptions(process.env.SENTRY_DSN);
|
||||
if (!options) return;
|
||||
const Sentry = await import("@sentry/nextjs");
|
||||
Sentry.init({
|
||||
...options,
|
||||
beforeSend(event) {
|
||||
return scrubSentryEvent(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const guarded = pathname.startsWith("/adaptador") || pathname.startsWith("/api/spreadsheet-adapter");
|
||||
if (!guarded) return NextResponse.next();
|
||||
const hasDev = Boolean(request.cookies.get("adapter_dev_session")?.value);
|
||||
const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
||||
if (hasDev || hasSupabase) return NextResponse.next();
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Sessão Elo Pro necessária." } }, { status: 401 });
|
||||
}
|
||||
const login = request.nextUrl.clone();
|
||||
login.pathname = "/login";
|
||||
login.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(login);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*"],
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import type { RequestContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
import { devAuthEnabled, verifyDevSession } from "./dev-session";
|
||||
|
||||
export type AuthState =
|
||||
| { kind: "anonymous" }
|
||||
| { kind: "no_workspace" }
|
||||
| { kind: "not_member" }
|
||||
| { kind: "ok"; ctx: RequestContext };
|
||||
|
||||
export async function resolveAuth(): Promise<AuthState> {
|
||||
const jar = await cookies();
|
||||
let userId: string | null = null;
|
||||
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null;
|
||||
const devToken = jar.get("adapter_dev_session")?.value;
|
||||
if (devToken && devAuthEnabled()) {
|
||||
const dev = verifyDevSession(devToken);
|
||||
if (dev) {
|
||||
userId = dev.userId;
|
||||
workspaceId = dev.workspaceId;
|
||||
}
|
||||
}
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!userId && supabaseUrl && supabaseKey) {
|
||||
const supabase = createServerClient(supabaseUrl, supabaseKey, {
|
||||
cookies: {
|
||||
getAll: () => jar.getAll(),
|
||||
setAll: (items) => {
|
||||
for (const item of items) jar.set(item.name, item.value, item.options);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { data } = await supabase.auth.getUser();
|
||||
userId = data.user?.id ?? null;
|
||||
}
|
||||
if (!userId) return { kind: "anonymous" };
|
||||
if (!workspaceId) return { kind: "no_workspace" };
|
||||
const db = await getDb();
|
||||
const rows = await db.withUser(userId, (query) =>
|
||||
query.query<{
|
||||
display_name: string;
|
||||
is_superadmin: boolean;
|
||||
workspace_name: string;
|
||||
role: Role;
|
||||
module_enabled: boolean;
|
||||
allow_upload: boolean;
|
||||
}>(
|
||||
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
|
||||
coalesce(f.enabled, false) as module_enabled,
|
||||
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
|
||||
from public.profiles p
|
||||
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
|
||||
join public.workspaces w on w.id = m.workspace_id
|
||||
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
||||
where p.id = $1`,
|
||||
[userId, workspaceId],
|
||||
),
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return { kind: "not_member" };
|
||||
return {
|
||||
kind: "ok",
|
||||
ctx: {
|
||||
userId,
|
||||
displayName: row.display_name,
|
||||
isSuperadmin: row.is_superadmin,
|
||||
workspaceId,
|
||||
workspaceName: row.workspace_name,
|
||||
role: row.role,
|
||||
moduleEnabled: row.module_enabled,
|
||||
allowNonAdminModelUpload: row.allow_upload,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export type DevSession = { userId: string; workspaceId: string; exp: number };
|
||||
|
||||
export function devAuthEnabled(): boolean {
|
||||
return process.env.NODE_ENV !== "production" && process.env.ADAPTER_DEV_AUTH === "1" && Boolean(process.env.ADAPTER_DEV_AUTH_SECRET);
|
||||
}
|
||||
|
||||
function secret(): string {
|
||||
const value = process.env.ADAPTER_DEV_AUTH_SECRET;
|
||||
if (!value) throw new Error("ADAPTER_DEV_AUTH_SECRET ausente.");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function signDevSession(userId: string, workspaceId: string, ttlSeconds = 60 * 60 * 8): string {
|
||||
const body = Buffer.from(JSON.stringify({ userId, workspaceId, exp: Date.now() + ttlSeconds * 1000 })).toString("base64url");
|
||||
const sig = createHmac("sha256", secret()).update(body).digest("base64url");
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyDevSession(token: string): DevSession | null {
|
||||
if (!devAuthEnabled()) return null;
|
||||
const [body, sig] = token.split(".");
|
||||
if (!body || !sig) return null;
|
||||
const expected = createHmac("sha256", secret()).update(body).digest("base64url");
|
||||
const left = Buffer.from(sig);
|
||||
const right = Buffer.from(expected);
|
||||
if (left.length !== right.length || !timingSafeEqual(left, right)) return null;
|
||||
const parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as DevSession;
|
||||
if (!parsed.exp || parsed.exp < Date.now()) return null;
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
|
||||
export function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
export function asJson<T>(value: unknown, fallback: T): T {
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function mapDbError(error: unknown): never {
|
||||
if (isAdapterError(error)) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("MODEL_LIMIT")) {
|
||||
throw new AdapterError("TOO_MANY_MODELS", "Este workspace já possui 10 modelos ativos.", 409);
|
||||
}
|
||||
if (message.includes("SOURCE_LIMIT")) {
|
||||
throw new AdapterError("TOO_MANY_SOURCES", "A operação aceita no máximo 10 fontes.", 409);
|
||||
}
|
||||
if (message.includes("spreadsheet_models_active_name")) {
|
||||
throw new AdapterError("DUPLICATE_MODEL_NAME", "Já existe um modelo ativo com este nome neste workspace.", 409);
|
||||
}
|
||||
if (message.includes("spreadsheet_job_mappings") && message.toLowerCase().includes("unique")) {
|
||||
throw new AdapterError("MAPPING_CONFLICT", "O mapeamento precisa ser 1:1.", 409);
|
||||
}
|
||||
if (message.includes("MODEL_WORKSPACE_MISMATCH")) {
|
||||
throw new AdapterError("WORKSPACE_FORBIDDEN", "O modelo não pertence ao workspace ativo.", 403);
|
||||
}
|
||||
if (message.toLowerCase().includes("row-level security") || message.toLowerCase().includes("permission denied")) {
|
||||
throw new AdapterError("FORBIDDEN", "Operação não autorizada para este perfil.", 403);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Db } from "@/infra/db/types";
|
||||
import type { Coordinator } from "@/infra/redis/memory";
|
||||
import type { ObjectStorage } from "@/infra/storage/memory";
|
||||
|
||||
export type AppDeps = {
|
||||
db: Db;
|
||||
storage: ObjectStorage;
|
||||
coordinator: Coordinator;
|
||||
now?: () => Date;
|
||||
ttlSeconds?: number;
|
||||
capture?: (error: unknown, tags: Record<string, string>) => void;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ColumnSchema, JobProgress, JobStatus, MappingInput, SourceStatus } from "@/domain/spreadsheet-adapter/types";
|
||||
|
||||
export type MappingDto = {
|
||||
sourceColumn: string;
|
||||
modelColumn: string;
|
||||
isRequired: boolean;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type ModelDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
originalFilename: string;
|
||||
format: "xlsx" | "csv";
|
||||
sheetName: string | null;
|
||||
headerRow: number;
|
||||
columns: ColumnSchema[];
|
||||
createdAt: string;
|
||||
createdByName: string;
|
||||
};
|
||||
|
||||
export type SourceDto = {
|
||||
id: string;
|
||||
sourceName: string;
|
||||
status: SourceStatus;
|
||||
selectedSheet: string | null;
|
||||
sheets: string[];
|
||||
headerRow: number;
|
||||
rowCount: number;
|
||||
sizeBytes: number;
|
||||
errorCode: string | null;
|
||||
warnings: string[];
|
||||
columns: ColumnSchema[];
|
||||
mappings: MappingDto[];
|
||||
expiresAt: string;
|
||||
hasOutput: boolean;
|
||||
};
|
||||
|
||||
export type JobDto = {
|
||||
id: string;
|
||||
status: JobStatus;
|
||||
errorCode: string | null;
|
||||
progress: JobProgress | null;
|
||||
currentSource: number;
|
||||
model: { id: string; name: string; columns: ColumnSchema[] };
|
||||
sources: SourceDto[];
|
||||
};
|
||||
|
||||
export type SourceSchemaPayload = {
|
||||
columns: ColumnSchema[];
|
||||
sheets: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type UploadFile = {
|
||||
filename: string;
|
||||
bytes: Buffer;
|
||||
};
|
||||
|
||||
export type ModelCreateInput = UploadFile & {
|
||||
name: string;
|
||||
sheet?: string | null;
|
||||
headerRow?: number;
|
||||
requiredColumns?: string[];
|
||||
};
|
||||
|
||||
export type { MappingInput };
|
||||
@@ -0,0 +1,316 @@
|
||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { neutralizeRow, neutralizeSpreadsheetFormula } from "@/domain/spreadsheet-adapter/csv-injection";
|
||||
import { buildOutputFilename } from "@/domain/spreadsheet-adapter/filenames";
|
||||
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
||||
import { canUseSpreadsheetAdapter } from "@/domain/spreadsheet-adapter/permissions";
|
||||
import { transformRows } from "@/domain/spreadsheet-adapter/transform";
|
||||
import type { JobProgress, OutputFormat, RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { buildCsv, buildXlsx } from "@/infra/export/tabular";
|
||||
import { buildPdf } from "@/infra/export/pdf";
|
||||
import type { Query } from "@/infra/db/types";
|
||||
import { lockKey } from "@/infra/redis/redis";
|
||||
import { sanitizeAuditMetadata } from "@/infra/observability/scrub";
|
||||
import { parseCsvText } from "@/domain/spreadsheet-adapter/csv";
|
||||
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||
import { asJson, mapDbError, sha256 } from "./db-utils";
|
||||
import type { AppDeps } from "./deps";
|
||||
import { readJob } from "./read-model";
|
||||
|
||||
function assertUse(ctx: RequestContext): void {
|
||||
if (!ctx.moduleEnabled || !canUseSpreadsheetAdapter(ctx)) {
|
||||
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(
|
||||
query: Query,
|
||||
ctx: RequestContext,
|
||||
action: string,
|
||||
resourceId: string,
|
||||
result: "success" | "error",
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await query.query(
|
||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
|
||||
values ($1,$2,$3,'job',$4,$5,$6::jsonb)`,
|
||||
[ctx.workspaceId, ctx.userId, action, resourceId, result, JSON.stringify(sanitizeAuditMetadata(metadata))],
|
||||
);
|
||||
}
|
||||
|
||||
function assertTempKey(key: string): void {
|
||||
if (!key.includes("/tmp/") || key.includes("/models/") || key.includes("..")) {
|
||||
throw new AdapterError("PATH_TRAVERSAL", "Chave temporária rejeitada.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<void> {
|
||||
const userLock = lockKey(ctx.userId);
|
||||
const written: string[] = [];
|
||||
try {
|
||||
const owns = await deps.coordinator.ownsLock(userLock, jobId);
|
||||
if (!owns) {
|
||||
const acquired = await deps.coordinator.acquireLock(userLock, jobId, 120);
|
||||
if (!acquired) throw new AdapterError("JOB_LOCKED", "Já existe uma transferência em andamento para este usuário.", 409);
|
||||
}
|
||||
const loaded = await deps.db.withUser(ctx.userId, async (query) => {
|
||||
const rows = await query.query<{
|
||||
storage_key: string;
|
||||
content_sha256: string;
|
||||
model_name: string;
|
||||
columns_schema: unknown;
|
||||
temp_storage_key: string;
|
||||
source_name: string;
|
||||
selected_sheet: string | null;
|
||||
header_row: number;
|
||||
source_count: number;
|
||||
}>(
|
||||
`select m.storage_key, m.content_sha256, m.name as model_name, m.columns_schema,
|
||||
s.temp_storage_key, s.source_name, s.selected_sheet, s.header_row, j.source_count
|
||||
from public.spreadsheet_jobs j
|
||||
join public.spreadsheet_models m on m.id = j.model_id
|
||||
join public.spreadsheet_job_sources s on s.job_id = j.id
|
||||
where j.id = $1 and j.workspace_id = $2 and j.user_id = $3 and s.id = $4`,
|
||||
[jobId, ctx.workspaceId, ctx.userId, sourceId],
|
||||
);
|
||||
const mappings = await query.query<{ source_column: string; model_column: string }>(
|
||||
`select source_column, model_column from public.spreadsheet_job_mappings where job_source_id = $1 order by position`,
|
||||
[sourceId],
|
||||
);
|
||||
return { row: rows[0], mappings };
|
||||
});
|
||||
if (!loaded.row) throw new AdapterError("NOT_FOUND", "Fonte da operação não encontrada.", 404);
|
||||
const columns = asJson<{ name: string; required: boolean }[]>(loaded.row.columns_schema, []);
|
||||
const validation = validateMappings(
|
||||
columns,
|
||||
[],
|
||||
loaded.mappings.map((mapping) => ({ sourceColumn: mapping.source_column, modelColumn: mapping.model_column })),
|
||||
);
|
||||
const modelBytes = await deps.storage.get(loaded.row.storage_key);
|
||||
if (sha256(modelBytes) !== loaded.row.content_sha256) {
|
||||
throw new AdapterError("MODEL_TAMPERED", "O arquivo do modelo não confere com o cadastro.", 409);
|
||||
}
|
||||
const sourceBytes = await deps.storage.get(loaded.row.temp_storage_key);
|
||||
const parsed = await readSpreadsheet(loaded.row.source_name, sourceBytes, {
|
||||
sheet: loaded.row.selected_sheet,
|
||||
headerRow: loaded.row.header_row,
|
||||
});
|
||||
const checked = validateMappings(
|
||||
columns,
|
||||
parsed.grid.headers,
|
||||
loaded.mappings.map((mapping) => ({ sourceColumn: mapping.source_column, modelColumn: mapping.model_column })),
|
||||
);
|
||||
if (!checked.ok || validation.missingRequired.length) {
|
||||
throw new AdapterError(checked.code, checked.missingRequired.length ? "Mapeamento obrigatório incompleto." : "Mapeamento inválido.", 422);
|
||||
}
|
||||
if (parsed.grid.columns.some((column) => column.inferredType === "mixed")) {
|
||||
throw new AdapterError("TYPE_INCONSISTENT", "A fonte mudou e agora tem tipos inconsistentes.", 422);
|
||||
}
|
||||
const sourceIndex = 1;
|
||||
const total = Number(loaded.row.source_count) || 1;
|
||||
const progress = async (value: JobProgress) => {
|
||||
await deps.coordinator.setProgress(jobId, value).catch(() => undefined);
|
||||
await deps.coordinator.renewLock(userLock, jobId, 120).catch(() => undefined);
|
||||
};
|
||||
await progress({ phase: "processing", label: "Processando", percent: 0, sourceIndex, sourceCount: total, sourceId });
|
||||
const transformed = transformRows(
|
||||
columns.map((column) => column.name),
|
||||
parsed.grid.headers,
|
||||
parsed.grid.rows,
|
||||
loaded.mappings.map((mapping) => ({ sourceColumn: mapping.source_column, modelColumn: mapping.model_column })),
|
||||
).map(neutralizeRow);
|
||||
const headers = columns.map((column) => neutralizeSpreadsheetFormula(column.name));
|
||||
let done = 0;
|
||||
for (const _row of transformed) {
|
||||
done += 1;
|
||||
if (done % 5000 === 0) {
|
||||
await progress({
|
||||
phase: "processing",
|
||||
label: "Processando",
|
||||
percent: Math.round((done / transformed.length) * 100),
|
||||
sourceIndex,
|
||||
sourceCount: total,
|
||||
sourceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await progress({ phase: "generating", label: "Gerando arquivo", percent: null, sourceIndex, sourceCount: total, sourceId });
|
||||
const csvKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.csv`;
|
||||
const xlsxKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.xlsx`;
|
||||
assertTempKey(csvKey);
|
||||
assertTempKey(xlsxKey);
|
||||
const csv = buildCsv(headers, transformed);
|
||||
const xlsx = await buildXlsx(headers, transformed);
|
||||
await deps.storage.put(csvKey, csv);
|
||||
written.push(csvKey);
|
||||
await deps.storage.put(xlsxKey, xlsx);
|
||||
written.push(xlsxKey);
|
||||
const after = await deps.storage.get(loaded.row.storage_key);
|
||||
if (!after.equals(modelBytes)) {
|
||||
throw new AdapterError("MODEL_TAMPERED", "O modelo original foi modificado durante a transferência.", 500);
|
||||
}
|
||||
const finished: JobProgress = {
|
||||
phase: "completed",
|
||||
label: "Concluído",
|
||||
percent: 100,
|
||||
sourceIndex,
|
||||
sourceCount: total,
|
||||
sourceId,
|
||||
};
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
await query.query(
|
||||
`update public.spreadsheet_job_sources
|
||||
set status = 'completed', output_csv_key = $2, output_xlsx_key = $3, processed_at = now(), error_code = null
|
||||
where id = $1`,
|
||||
[sourceId, csvKey, xlsxKey],
|
||||
);
|
||||
const pending = await query.query<{ total: number }>(
|
||||
`select count(*)::int as total from public.spreadsheet_job_sources where job_id = $1 and status <> 'completed'`,
|
||||
[jobId],
|
||||
);
|
||||
const status = Number(pending[0]?.total ?? 0) === 0 ? "completed" : "ready";
|
||||
await query.query(
|
||||
`update public.spreadsheet_jobs
|
||||
set status = $2, progress = $3::jsonb, error_code = null, finished_at = case when $2 = 'completed' then now() else finished_at end, updated_at = now()
|
||||
where id = $1`,
|
||||
[jobId, status, JSON.stringify(finished)],
|
||||
);
|
||||
await audit(query, ctx, "job.completed", jobId, "success", { source_id: sourceId, output_rows: transformed.length });
|
||||
});
|
||||
await deps.coordinator.setProgress(jobId, finished).catch(() => undefined);
|
||||
} catch (error) {
|
||||
for (const key of written) await deps.storage.delete(key).catch(() => undefined);
|
||||
const code = isAdapterError(error) ? error.code : "PROCESS_FAILED";
|
||||
const failed: JobProgress = {
|
||||
phase: "failed",
|
||||
label: "Falhou",
|
||||
percent: null,
|
||||
sourceIndex: 1,
|
||||
sourceCount: 1,
|
||||
sourceId,
|
||||
};
|
||||
await deps.db
|
||||
.withUser(ctx.userId, async (query) => {
|
||||
await query.query(`update public.spreadsheet_job_sources set status = 'failed', error_code = $2 where id = $1 and status <> 'completed'`, [
|
||||
sourceId,
|
||||
code,
|
||||
]);
|
||||
await query.query(
|
||||
`update public.spreadsheet_jobs set status = 'failed', error_code = $2, progress = $3::jsonb, finished_at = now(), updated_at = now() where id = $1`,
|
||||
[jobId, code, JSON.stringify(failed)],
|
||||
);
|
||||
await audit(query, ctx, "job.failed", jobId, "error", { error_code: code, source_id: sourceId });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await deps.coordinator.setProgress(jobId, failed).catch(() => undefined);
|
||||
if (!isAdapterError(error) || error.status >= 500) {
|
||||
deps.capture?.(error, { module: "spreadsheet-adapter", job_id: jobId, workspace_id: ctx.workspaceId, error_code: code });
|
||||
}
|
||||
if (isAdapterError(error)) throw error;
|
||||
throw new AdapterError("PROCESS_FAILED", "O processamento falhou. Você pode tentar novamente.", 500);
|
||||
} finally {
|
||||
await deps.coordinator.releaseLock(userLock, jobId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const CONTENT_TYPE: Record<OutputFormat, string> = {
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
csv: "text/csv; charset=utf-8",
|
||||
pdf: "application/pdf",
|
||||
};
|
||||
|
||||
export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, format: OutputFormat) {
|
||||
assertUse(ctx);
|
||||
const file = await deps.db.withUser(ctx.userId, async (query) => {
|
||||
const rows = await query.query<{
|
||||
model_name: string;
|
||||
output_csv_key: string | null;
|
||||
output_xlsx_key: string | null;
|
||||
expires_at: string | Date;
|
||||
status: string;
|
||||
}>(
|
||||
`select m.name as model_name, s.output_csv_key, s.output_xlsx_key, s.expires_at, s.status
|
||||
from public.spreadsheet_job_sources s
|
||||
join public.spreadsheet_jobs j on j.id = s.job_id
|
||||
join public.spreadsheet_models m on m.id = j.model_id
|
||||
where s.id = $1 and j.id = $2 and j.workspace_id = $3 and j.user_id = $4 and s.expires_at > $5::timestamptz`,
|
||||
[sourceId, jobId, ctx.workspaceId, ctx.userId, (deps.now ?? (() => new Date()))().toISOString()],
|
||||
);
|
||||
if (!rows[0] || rows[0].status !== "completed") throw new AdapterError("NOT_FOUND", "Saída indisponível ou expirada.", 404);
|
||||
await audit(query, ctx, "job.download", jobId, "success", { format, source_id: sourceId });
|
||||
return rows[0];
|
||||
});
|
||||
const filename = buildOutputFilename(file.model_name, format, deps.now?.() ?? new Date());
|
||||
if (format === "pdf") {
|
||||
if (!file.output_csv_key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
|
||||
assertTempKey(file.output_csv_key);
|
||||
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
|
||||
const grid = parseCsvText(csv);
|
||||
const headers = grid[0] ?? [];
|
||||
const body = await buildPdf(file.model_name, headers, grid.slice(1));
|
||||
return { filename, contentType: CONTENT_TYPE.pdf, body: body.body };
|
||||
}
|
||||
const key = format === "csv" ? file.output_csv_key : file.output_xlsx_key;
|
||||
if (!key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
|
||||
assertTempKey(key);
|
||||
return { filename, contentType: CONTENT_TYPE[format], body: await deps.storage.get(key) };
|
||||
}
|
||||
|
||||
export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
||||
const rows = await deps.db.withService((query) =>
|
||||
query.query<{ id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
|
||||
`select id, temp_storage_key, output_csv_key, output_xlsx_key
|
||||
from public.spreadsheet_job_sources
|
||||
where expires_at < $1::timestamptz and status <> 'expired'`,
|
||||
[(deps.now ?? (() => new Date()))().toISOString()],
|
||||
),
|
||||
);
|
||||
for (const row of rows) {
|
||||
for (const key of [row.temp_storage_key, row.output_csv_key, row.output_xlsx_key]) {
|
||||
if (!key || !key.includes("/tmp/") || key.includes("/models/")) continue;
|
||||
await deps.storage.delete(key).catch(() => undefined);
|
||||
}
|
||||
await deps.db.withService((query) =>
|
||||
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
|
||||
);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
||||
const rows = await deps.db.withService((query) =>
|
||||
query.query<{ id: string; user_id: string; workspace_id: string }>(
|
||||
`select id, user_id, workspace_id from public.spreadsheet_jobs where status = 'processing'`,
|
||||
),
|
||||
);
|
||||
let recovered = 0;
|
||||
for (const row of rows) {
|
||||
const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false);
|
||||
if (owns) continue;
|
||||
await deps.db.withService((query) =>
|
||||
query.query(
|
||||
`update public.spreadsheet_jobs set status = 'failed', error_code = 'INTERRUPTED', updated_at = now() where id = $1 and status = 'processing'`,
|
||||
[row.id],
|
||||
),
|
||||
);
|
||||
recovered += 1;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
export async function readJobForWorker(deps: AppDeps, jobId: string) {
|
||||
return deps.db.withService((query) =>
|
||||
query.query<{ user_id: string; workspace_id: string; progress: unknown }>(
|
||||
`select user_id, workspace_id, progress from public.spreadsheet_jobs where id = $1 and status = 'queued'`,
|
||||
[jobId],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> {
|
||||
const rows = await deps.db.withService((query) =>
|
||||
query.query<{ id: string }>(`select id from public.spreadsheet_jobs where status = 'queued' order by created_at asc limit 5`),
|
||||
);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
export { mapDbError, readJob };
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Query } from "@/infra/db/types";
|
||||
import { asJson } from "./db-utils";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
|
||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import type { ColumnSchema, JobStatus, SourceStatus } from "@/domain/spreadsheet-adapter/types";
|
||||
import type { Coordinator } from "@/infra/redis/memory";
|
||||
import type { JobDto, MappingDto, ModelDto, SourceDto, SourceSchemaPayload } from "./dto";
|
||||
|
||||
function iso(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const date = new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
|
||||
}
|
||||
|
||||
function payloadOf(value: unknown): SourceSchemaPayload {
|
||||
const parsed = asJson<SourceSchemaPayload | ColumnSchema[]>(value, { columns: [], sheets: [], warnings: [] });
|
||||
if (Array.isArray(parsed)) return { columns: parsed, sheets: [], warnings: [] };
|
||||
return { columns: parsed.columns ?? [], sheets: parsed.sheets ?? [], warnings: parsed.warnings ?? [] };
|
||||
}
|
||||
|
||||
type JobRow = {
|
||||
id: string;
|
||||
status: JobStatus;
|
||||
error_code: string | null;
|
||||
progress: unknown;
|
||||
current_source: number;
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
model_columns: unknown;
|
||||
};
|
||||
|
||||
type SourceRow = {
|
||||
id: string;
|
||||
source_name: string;
|
||||
status: SourceStatus;
|
||||
selected_sheet: string | null;
|
||||
header_row: number;
|
||||
row_count: number;
|
||||
size_bytes: number;
|
||||
error_code: string | null;
|
||||
columns_schema: unknown;
|
||||
output_csv_key: string | null;
|
||||
expires_at: unknown;
|
||||
};
|
||||
|
||||
export async function readJob(query: Query, coordinator: Coordinator, ctx: RequestContext, jobId: string): Promise<JobDto> {
|
||||
const jobs = await query.query<JobRow>(
|
||||
`select j.id, j.status, j.error_code, j.progress, j.current_source, j.model_id,
|
||||
m.name as model_name, m.columns_schema as model_columns
|
||||
from public.spreadsheet_jobs j
|
||||
join public.spreadsheet_models m on m.id = j.model_id
|
||||
where j.id = $1 and j.workspace_id = $2 and j.user_id = $3`,
|
||||
[jobId, ctx.workspaceId, ctx.userId],
|
||||
);
|
||||
const job = jobs[0];
|
||||
if (!job) throw new AdapterError("NOT_FOUND", "Operação não encontrada.", 404);
|
||||
const sources = await query.query<SourceRow>(
|
||||
`select id, source_name, status, selected_sheet, header_row, row_count, size_bytes, error_code,
|
||||
columns_schema, output_csv_key, expires_at
|
||||
from public.spreadsheet_job_sources
|
||||
where job_id = $1
|
||||
order by created_at asc`,
|
||||
[jobId],
|
||||
);
|
||||
const mappings = await query.query<{
|
||||
job_source_id: string;
|
||||
source_column: string;
|
||||
model_column: string;
|
||||
is_required: boolean;
|
||||
position: number;
|
||||
}>(
|
||||
`select mp.job_source_id, mp.source_column, mp.model_column, mp.is_required, mp.position
|
||||
from public.spreadsheet_job_mappings mp
|
||||
join public.spreadsheet_job_sources s on s.id = mp.job_source_id
|
||||
where s.job_id = $1
|
||||
order by mp.position asc`,
|
||||
[jobId],
|
||||
);
|
||||
const bySource = new Map<string, MappingDto[]>();
|
||||
for (const mapping of mappings) {
|
||||
const list = bySource.get(mapping.job_source_id) ?? [];
|
||||
list.push({
|
||||
sourceColumn: mapping.source_column,
|
||||
modelColumn: mapping.model_column,
|
||||
isRequired: mapping.is_required,
|
||||
position: mapping.position,
|
||||
});
|
||||
bySource.set(mapping.job_source_id, list);
|
||||
}
|
||||
let progress = asJson<JobProgress | null>(job.progress, null);
|
||||
if (job.status === "processing" || job.status === "queued") {
|
||||
try {
|
||||
progress = (await coordinator.getProgress(job.id)) ?? progress;
|
||||
} catch {
|
||||
progress = progress ?? null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
errorCode: job.error_code,
|
||||
progress,
|
||||
currentSource: job.current_source,
|
||||
model: {
|
||||
id: job.model_id,
|
||||
name: job.model_name,
|
||||
columns: asJson<ColumnSchema[]>(job.model_columns, []),
|
||||
},
|
||||
sources: sources.map((source) => {
|
||||
const schema = payloadOf(source.columns_schema);
|
||||
return {
|
||||
id: source.id,
|
||||
sourceName: source.source_name,
|
||||
status: source.status,
|
||||
selectedSheet: source.selected_sheet,
|
||||
sheets: schema.sheets,
|
||||
headerRow: source.header_row,
|
||||
rowCount: source.row_count,
|
||||
sizeBytes: Number(source.size_bytes),
|
||||
errorCode: source.error_code,
|
||||
warnings: schema.warnings,
|
||||
columns: schema.columns,
|
||||
mappings: bySource.get(source.id) ?? [],
|
||||
expiresAt: iso(source.expires_at),
|
||||
hasOutput: Boolean(source.output_csv_key),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readModels(query: Query, workspaceId: string): Promise<ModelDto[]> {
|
||||
const rows = await query.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
original_filename: string;
|
||||
format: "xlsx" | "csv";
|
||||
sheet_name: string | null;
|
||||
header_row: number;
|
||||
columns_schema: unknown;
|
||||
created_at: unknown;
|
||||
created_by_name: string;
|
||||
}>(
|
||||
`select m.id, m.name, m.original_filename, m.format, m.sheet_name, m.header_row, m.columns_schema,
|
||||
m.created_at, p.display_name as created_by_name
|
||||
from public.spreadsheet_models m
|
||||
join public.profiles p on p.id = m.created_by
|
||||
where m.workspace_id = $1 and m.status = 'active'
|
||||
order by lower(m.name) asc`,
|
||||
[workspaceId],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
originalFilename: row.original_filename,
|
||||
format: row.format,
|
||||
sheetName: row.sheet_name,
|
||||
headerRow: row.header_row,
|
||||
columns: asJson<ColumnSchema[]>(row.columns_schema, []),
|
||||
createdAt: iso(row.created_at),
|
||||
createdByName: row.created_by_name,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import "server-only";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
import { createCoordinator } from "@/infra/redis";
|
||||
import { createStorage } from "@/infra/storage";
|
||||
import { captureAdapterException } from "@/infra/observability/capture";
|
||||
import type { AppDeps } from "./deps";
|
||||
|
||||
export async function runtimeDeps(): Promise<AppDeps> {
|
||||
return {
|
||||
db: await getDb(),
|
||||
storage: createStorage(),
|
||||
coordinator: createCoordinator(),
|
||||
capture: captureAdapterException,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { MAX_ACTIVE_MODELS, MAX_SOURCES_PER_JOB } from "@/domain/spreadsheet-adapter/limits";
|
||||
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
||||
import { buildPreview } from "@/domain/spreadsheet-adapter/preview";
|
||||
import {
|
||||
canManageSpreadsheetModels,
|
||||
canOperateSpreadsheet,
|
||||
canUploadSpreadsheetModel,
|
||||
canUseSpreadsheetAdapter,
|
||||
} from "@/domain/spreadsheet-adapter/permissions";
|
||||
import { normalizeModelName, sanitizeDisplayName } from "@/domain/spreadsheet-adapter/filenames";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import type { Query } from "@/infra/db/types";
|
||||
import { lockKey } from "@/infra/redis/redis";
|
||||
import type { AppDeps } from "./deps";
|
||||
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||
import { sanitizeAuditMetadata } from "@/infra/observability/scrub";
|
||||
import { tempTtlSeconds } from "@/config/retention";
|
||||
import type { JobDto, MappingInput, ModelCreateInput, ModelDto, UploadFile } from "./dto";
|
||||
import { mapDbError, sha256 } from "./db-utils";
|
||||
import { readJob, readModels } from "./read-model";
|
||||
import { runProcess } from "./process-job";
|
||||
|
||||
function ttl(deps: AppDeps): number {
|
||||
return deps.ttlSeconds ?? tempTtlSeconds();
|
||||
}
|
||||
|
||||
function assertUse(ctx: RequestContext): void {
|
||||
if (!ctx.moduleEnabled || !canUseSpreadsheetAdapter(ctx)) {
|
||||
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
||||
}
|
||||
if (!canOperateSpreadsheet(ctx)) throw new AdapterError("FORBIDDEN", "Sem permissão para operar o Adaptador.", 403);
|
||||
}
|
||||
|
||||
async function audit(
|
||||
query: Query,
|
||||
ctx: RequestContext,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
result: "success" | "denied" | "error",
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await query.query(
|
||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||||
[ctx.workspaceId, ctx.userId, action, resourceType, resourceId, result, JSON.stringify(sanitizeAuditMetadata(metadata))],
|
||||
);
|
||||
}
|
||||
|
||||
export async function rejectWorkspaceForgery(deps: AppDeps, ctx: RequestContext, incoming: unknown): Promise<void> {
|
||||
if (typeof incoming !== "string" || incoming.length === 0 || incoming === ctx.workspaceId) return;
|
||||
await deps.db.withUser(ctx.userId, (query) =>
|
||||
audit(query, ctx, "security.workspace_forgery", "workspace", ctx.workspaceId, "denied", {
|
||||
attempted_workspace_id: incoming.slice(0, 80),
|
||||
}),
|
||||
);
|
||||
throw new AdapterError("WORKSPACE_FORBIDDEN", "O workspace informado não é o workspace ativo.", 403);
|
||||
}
|
||||
|
||||
export async function listModels(deps: AppDeps, ctx: RequestContext): Promise<ModelDto[]> {
|
||||
assertUse(ctx);
|
||||
return deps.db.withUser(ctx.userId, (query) => readModels(query, ctx.workspaceId));
|
||||
}
|
||||
|
||||
export async function getSettings(deps: AppDeps, ctx: RequestContext): Promise<{ allowNonAdminModelUpload: boolean }> {
|
||||
assertUse(ctx);
|
||||
const rows = await deps.db.withUser(ctx.userId, (query) =>
|
||||
query.query<{ allow_non_admin_model_upload: boolean }>(
|
||||
`select allow_non_admin_model_upload from public.spreadsheet_adapter_settings where workspace_id = $1`,
|
||||
[ctx.workspaceId],
|
||||
),
|
||||
);
|
||||
return { allowNonAdminModelUpload: Boolean(rows[0]?.allow_non_admin_model_upload) };
|
||||
}
|
||||
|
||||
export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNonAdminModelUpload: boolean): Promise<void> {
|
||||
assertUse(ctx);
|
||||
if (!canManageSpreadsheetModels(ctx)) {
|
||||
throw new AdapterError("FORBIDDEN", "Somente owner ou admin alteram a configuração do módulo.", 403);
|
||||
}
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
await query.query(
|
||||
`insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload)
|
||||
values ($1, $2)
|
||||
on conflict (workspace_id) do update
|
||||
set allow_non_admin_model_upload = excluded.allow_non_admin_model_upload, updated_at = now()`,
|
||||
[ctx.workspaceId, allowNonAdminModelUpload],
|
||||
);
|
||||
await audit(query, ctx, "settings.upload_policy_changed", "settings", ctx.workspaceId, "success", {
|
||||
allow_non_admin_model_upload: allowNonAdminModelUpload,
|
||||
});
|
||||
}).catch(mapDbError);
|
||||
}
|
||||
|
||||
export async function createModel(deps: AppDeps, ctx: RequestContext, input: ModelCreateInput): Promise<ModelDto> {
|
||||
assertUse(ctx);
|
||||
if (!canUploadSpreadsheetModel(ctx)) {
|
||||
throw new AdapterError("FORBIDDEN", "Seu perfil não pode cadastrar modelos neste workspace.", 403);
|
||||
}
|
||||
const name = normalizeModelName(input.name);
|
||||
const filename = sanitizeDisplayName(input.filename);
|
||||
const parsed = await readSpreadsheet(filename, input.bytes, { sheet: input.sheet, headerRow: input.headerRow ?? 1 });
|
||||
if (parsed.grid.columns.some((column) => column.inferredType === "mixed")) {
|
||||
throw new AdapterError("TYPE_INCONSISTENT", "O modelo tem colunas com tipos inconsistentes. Ajuste a origem antes de cadastrar.", 422);
|
||||
}
|
||||
let columns = parsed.grid.columns;
|
||||
if (input.requiredColumns) {
|
||||
const known = new Set(columns.map((column) => column.name));
|
||||
for (const required of input.requiredColumns) {
|
||||
if (!known.has(required)) throw new AdapterError("UNKNOWN_COLUMN", "Uma coluna obrigatória não existe no modelo.");
|
||||
}
|
||||
const required = new Set(input.requiredColumns);
|
||||
columns = columns.map((column) => ({ ...column, required: required.has(column.name) }));
|
||||
}
|
||||
const id = randomUUID();
|
||||
const storageKey = `workspaces/${ctx.workspaceId}/models/${id}/original`;
|
||||
const digest = sha256(input.bytes);
|
||||
await deps.storage.put(storageKey, input.bytes);
|
||||
try {
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
const counts = await query.query<{ total: number }>(
|
||||
`select count(*)::int as total from public.spreadsheet_models where workspace_id = $1 and status = 'active'`,
|
||||
[ctx.workspaceId],
|
||||
);
|
||||
if (Number(counts[0]?.total ?? 0) >= MAX_ACTIVE_MODELS) {
|
||||
throw new AdapterError("TOO_MANY_MODELS", "Este workspace já possui 10 modelos ativos.", 409);
|
||||
}
|
||||
await query.query(
|
||||
`insert into public.spreadsheet_models
|
||||
(id, workspace_id, name, original_filename, format, storage_key, content_sha256, sheet_name, header_row, columns_schema, created_by, status)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11,'active')`,
|
||||
[
|
||||
id,
|
||||
ctx.workspaceId,
|
||||
name,
|
||||
filename,
|
||||
parsed.format,
|
||||
storageKey,
|
||||
digest,
|
||||
parsed.grid.selectedSheet,
|
||||
parsed.grid.headerRow,
|
||||
JSON.stringify(columns),
|
||||
ctx.userId,
|
||||
],
|
||||
);
|
||||
await audit(query, ctx, "model.uploaded", "model", id, "success", { format: parsed.format, column_count: columns.length });
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.storage.delete(storageKey);
|
||||
mapDbError(error);
|
||||
}
|
||||
const models = await listModels(deps, ctx);
|
||||
const created = models.find((model) => model.id === id);
|
||||
if (!created) throw new AdapterError("NOT_FOUND", "Modelo não encontrado após o cadastro.", 500);
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: string): Promise<void> {
|
||||
assertUse(ctx);
|
||||
if (!canManageSpreadsheetModels(ctx)) {
|
||||
throw new AdapterError("FORBIDDEN", "Somente owner ou admin podem excluir modelos.", 403);
|
||||
}
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
const active = await query.query<{ busy: boolean }>(`select public.model_has_active_job($1::uuid) as busy`, [modelId]);
|
||||
if (active[0]?.busy) {
|
||||
throw new AdapterError("MODEL_IN_USE", "Este modelo está em uma operação ativa e não pode ser excluído.", 409);
|
||||
}
|
||||
const updated = await query.query<{ id: string }>(
|
||||
`update public.spreadsheet_models
|
||||
set status = 'deleted', deleted_at = now(), updated_at = now()
|
||||
where id = $1 and workspace_id = $2 and status = 'active'
|
||||
returning id`,
|
||||
[modelId, ctx.workspaceId],
|
||||
);
|
||||
if (!updated[0]) throw new AdapterError("NOT_FOUND", "Modelo não encontrado.", 404);
|
||||
await audit(query, ctx, "model.deleted", "model", modelId, "success");
|
||||
}).catch(mapDbError);
|
||||
}
|
||||
|
||||
export async function createJob(deps: AppDeps, ctx: RequestContext, modelId: string, idempotencyKey?: string | null): Promise<JobDto> {
|
||||
assertUse(ctx);
|
||||
const jobId = await deps.db.withUser(ctx.userId, async (query) => {
|
||||
if (idempotencyKey) {
|
||||
const existing = await query.query<{ id: string }>(
|
||||
`select id from public.spreadsheet_jobs where user_id = $1 and idempotency_key = $2`,
|
||||
[ctx.userId, idempotencyKey],
|
||||
);
|
||||
if (existing[0]) return existing[0].id;
|
||||
}
|
||||
const models = await query.query<{ id: string }>(
|
||||
`select id from public.spreadsheet_models where id = $1 and workspace_id = $2 and status = 'active'`,
|
||||
[modelId, ctx.workspaceId],
|
||||
);
|
||||
if (!models[0]) throw new AdapterError("NOT_FOUND", "Modelo não encontrado neste workspace.", 404);
|
||||
const id = randomUUID();
|
||||
await query.query(
|
||||
`insert into public.spreadsheet_jobs
|
||||
(id, workspace_id, user_id, model_id, status, source_count, current_source, idempotency_key)
|
||||
values ($1,$2,$3,$4,'draft',0,0,$5)`,
|
||||
[id, ctx.workspaceId, ctx.userId, modelId, idempotencyKey ?? null],
|
||||
);
|
||||
await audit(query, ctx, "job.created", "job", id, "success", { model_id: modelId });
|
||||
return id;
|
||||
}).catch(mapDbError);
|
||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
}
|
||||
|
||||
async function ensureMutableJob(query: Query, ctx: RequestContext, jobId: string): Promise<void> {
|
||||
const rows = await query.query<{ status: string }>(
|
||||
`select status from public.spreadsheet_jobs where id = $1 and workspace_id = $2 and user_id = $3`,
|
||||
[jobId, ctx.workspaceId, ctx.userId],
|
||||
);
|
||||
if (!rows[0]) throw new AdapterError("NOT_FOUND", "Operação não encontrada.", 404);
|
||||
if (rows[0].status === "processing" || rows[0].status === "queued") {
|
||||
throw new AdapterError("JOB_LOCKED", "Aguarde o término da transferência em andamento.", 409);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: string, file: UploadFile, sheet?: string | null, headerRow = 1) {
|
||||
assertUse(ctx);
|
||||
const filename = sanitizeDisplayName(file.filename);
|
||||
let parsed: Awaited<ReturnType<typeof readSpreadsheet>> | null = null;
|
||||
let sheetRequired: string[] | null = null;
|
||||
try {
|
||||
parsed = await readSpreadsheet(filename, file.bytes, { sheet, headerRow });
|
||||
} catch (error) {
|
||||
if (isAdapterError(error) && error.code === "SHEET_REQUIRED") sheetRequired = (error.details?.sheets as string[]) ?? [];
|
||||
else throw error;
|
||||
}
|
||||
const sourceId = randomUUID();
|
||||
const storageKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/source`;
|
||||
const expires = new Date((deps.now ?? (() => new Date()))().getTime() + ttl(deps) * 1000);
|
||||
const mixed = parsed?.grid.columns.some((column) => column.inferredType === "mixed") ?? false;
|
||||
await deps.storage.put(storageKey, file.bytes);
|
||||
try {
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
await ensureMutableJob(query, ctx, jobId);
|
||||
const counts = await query.query<{ total: number }>(
|
||||
`select count(*)::int as total from public.spreadsheet_job_sources where job_id = $1`,
|
||||
[jobId],
|
||||
);
|
||||
if (Number(counts[0]?.total ?? 0) >= MAX_SOURCES_PER_JOB) {
|
||||
throw new AdapterError("TOO_MANY_SOURCES", "A operação aceita no máximo 10 fontes.", 409);
|
||||
}
|
||||
const status = sheetRequired ? "pending" : mixed ? "invalid" : "validated";
|
||||
const errorCode = sheetRequired ? "SHEET_REQUIRED" : mixed ? "TYPE_INCONSISTENT" : null;
|
||||
const schema = {
|
||||
columns: parsed?.grid.columns ?? [],
|
||||
sheets: sheetRequired ?? parsed?.grid.sheetNames ?? [],
|
||||
warnings: parsed?.grid.warnings ?? [],
|
||||
};
|
||||
await query.query(
|
||||
`insert into public.spreadsheet_job_sources
|
||||
(id, job_id, source_name, temp_storage_key, selected_sheet, header_row, row_count, size_bytes, status, columns_schema, error_code, expires_at)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11,$12)`,
|
||||
[
|
||||
sourceId,
|
||||
jobId,
|
||||
filename,
|
||||
storageKey,
|
||||
parsed?.grid.selectedSheet ?? null,
|
||||
parsed?.grid.headerRow ?? headerRow,
|
||||
parsed?.grid.rows.length ?? 0,
|
||||
file.bytes.length,
|
||||
status,
|
||||
JSON.stringify(schema),
|
||||
errorCode,
|
||||
expires.toISOString(),
|
||||
],
|
||||
);
|
||||
await query.query(
|
||||
`update public.spreadsheet_jobs set source_count = source_count + 1, updated_at = now() where id = $1`,
|
||||
[jobId],
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.storage.delete(storageKey);
|
||||
mapDbError(error);
|
||||
}
|
||||
const job = await deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
if (sheetRequired) {
|
||||
return { httpStatus: 409, code: "SHEET_REQUIRED", message: "Selecione exatamente uma aba.", job };
|
||||
}
|
||||
if (mixed) {
|
||||
return { httpStatus: 422, code: "TYPE_INCONSISTENT", message: "Há colunas com tipos inconsistentes. Ajuste a linha de cabeçalho ou a fonte.", job };
|
||||
}
|
||||
return { httpStatus: 200, job };
|
||||
}
|
||||
|
||||
export async function updateSource(
|
||||
deps: AppDeps,
|
||||
ctx: RequestContext,
|
||||
jobId: string,
|
||||
sourceId: string,
|
||||
input: { sheet?: string | null; headerRow?: number },
|
||||
) {
|
||||
assertUse(ctx);
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
await ensureMutableJob(query, ctx, jobId);
|
||||
const rows = await query.query<{ temp_storage_key: string; source_name: string; header_row: number; selected_sheet: string | null }>(
|
||||
`select temp_storage_key, source_name, header_row, selected_sheet
|
||||
from public.spreadsheet_job_sources where id = $1 and job_id = $2`,
|
||||
[sourceId, jobId],
|
||||
);
|
||||
const source = rows[0];
|
||||
if (!source) throw new AdapterError("NOT_FOUND", "Fonte não encontrada.", 404);
|
||||
const bytes = await deps.storage.get(source.temp_storage_key);
|
||||
const parsed = await readSpreadsheet(source.source_name, bytes, {
|
||||
sheet: input.sheet === undefined ? source.selected_sheet : input.sheet,
|
||||
headerRow: input.headerRow ?? source.header_row,
|
||||
});
|
||||
const mixed = parsed.grid.columns.some((column) => column.inferredType === "mixed");
|
||||
await query.query(`delete from public.spreadsheet_job_mappings where job_source_id = $1`, [sourceId]);
|
||||
await query.query(
|
||||
`update public.spreadsheet_job_sources
|
||||
set selected_sheet = $2, header_row = $3, row_count = $4, status = $5, error_code = $6, columns_schema = $7::jsonb
|
||||
where id = $1`,
|
||||
[
|
||||
sourceId,
|
||||
parsed.grid.selectedSheet,
|
||||
parsed.grid.headerRow,
|
||||
parsed.grid.rows.length,
|
||||
mixed ? "invalid" : "validated",
|
||||
mixed ? "TYPE_INCONSISTENT" : null,
|
||||
JSON.stringify({ columns: parsed.grid.columns, sheets: parsed.grid.sheetNames, warnings: parsed.grid.warnings }),
|
||||
],
|
||||
);
|
||||
}).catch(mapDbError);
|
||||
const job = await deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
const source = job.sources.find((item) => item.id === sourceId);
|
||||
if (source?.status === "invalid") {
|
||||
return { httpStatus: 422, code: source.errorCode ?? "TYPE_INCONSISTENT", message: "A fonte continua inválida para esta aba ou cabeçalho.", job };
|
||||
}
|
||||
return { httpStatus: 200, job };
|
||||
}
|
||||
|
||||
export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<JobDto> {
|
||||
assertUse(ctx);
|
||||
const key = await deps.db.withUser(ctx.userId, async (query) => {
|
||||
await ensureMutableJob(query, ctx, jobId);
|
||||
const rows = await query.query<{ temp_storage_key: string; status: string }>(
|
||||
`delete from public.spreadsheet_job_sources
|
||||
where id = $1 and job_id = $2 and status not in ('processing', 'completed')
|
||||
returning temp_storage_key, status`,
|
||||
[sourceId, jobId],
|
||||
);
|
||||
if (!rows[0]) throw new AdapterError("NOT_FOUND", "Fonte não encontrada ou já processada.", 404);
|
||||
await query.query(`update public.spreadsheet_jobs set source_count = greatest(source_count - 1, 0), updated_at = now() where id = $1`, [jobId]);
|
||||
return rows[0].temp_storage_key;
|
||||
}).catch(mapDbError);
|
||||
if (key.includes("/tmp/")) await deps.storage.delete(key);
|
||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
}
|
||||
|
||||
export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, mappings: MappingInput[]) {
|
||||
assertUse(ctx);
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
await ensureMutableJob(query, ctx, jobId);
|
||||
const job = await readJob(query, deps.coordinator, ctx, jobId);
|
||||
const source = job.sources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new AdapterError("NOT_FOUND", "Fonte não encontrada.", 404);
|
||||
if (source.status === "invalid" || source.status === "pending") {
|
||||
throw new AdapterError(source.errorCode ?? "INVALID_STRUCTURE", "Corrija a fonte antes de mapear.", 422);
|
||||
}
|
||||
const result = validateMappings(job.model.columns, source.columns.map((column) => column.name), mappings);
|
||||
if (!result.ok && result.code !== "MAPPING_INCOMPLETE") {
|
||||
throw new AdapterError(result.code, result.errors[0] ?? "Mapeamento inválido.", 422);
|
||||
}
|
||||
await query.query(`delete from public.spreadsheet_job_mappings where job_source_id = $1`, [sourceId]);
|
||||
for (const [index, mapping] of mappings.entries()) {
|
||||
const required = job.model.columns.find((column) => column.name === mapping.modelColumn)?.required ?? false;
|
||||
await query.query(
|
||||
`insert into public.spreadsheet_job_mappings (job_source_id, source_column, model_column, is_required, position)
|
||||
values ($1,$2,$3,$4,$5)`,
|
||||
[sourceId, mapping.sourceColumn, mapping.modelColumn, required, index],
|
||||
);
|
||||
}
|
||||
await query.query(`update public.spreadsheet_job_sources set status = $2, error_code = null where id = $1`, [
|
||||
sourceId,
|
||||
result.ok ? "mapped" : "validated",
|
||||
]);
|
||||
if (result.ok) {
|
||||
await query.query(
|
||||
`update public.spreadsheet_jobs set status = 'ready', updated_at = now() where id = $1 and status in ('draft','failed','ready','completed')`,
|
||||
[jobId],
|
||||
);
|
||||
}
|
||||
}).catch(mapDbError);
|
||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
}
|
||||
|
||||
export async function previewSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string) {
|
||||
assertUse(ctx);
|
||||
return deps.db.withUser(ctx.userId, async (query) => {
|
||||
const job = await readJob(query, deps.coordinator, ctx, jobId);
|
||||
const source = job.sources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new AdapterError("NOT_FOUND", "Fonte não encontrada.", 404);
|
||||
const validation = validateMappings(
|
||||
job.model.columns,
|
||||
source.columns.map((column) => column.name),
|
||||
source.mappings.map((mapping) => ({ sourceColumn: mapping.sourceColumn, modelColumn: mapping.modelColumn })),
|
||||
);
|
||||
if (!validation.ok) {
|
||||
const message = validation.missingRequired.length
|
||||
? `Mapeie as colunas obrigatórias: ${validation.missingRequired.join(", ")}.`
|
||||
: validation.errors[0] ?? "Mapeamento incompleto.";
|
||||
throw new AdapterError(validation.code, message, 422, { missingRequired: validation.missingRequired });
|
||||
}
|
||||
const stored = await query.query<{ temp_storage_key: string; source_name: string; selected_sheet: string | null; header_row: number }>(
|
||||
`select temp_storage_key, source_name, selected_sheet, header_row from public.spreadsheet_job_sources where id = $1`,
|
||||
[sourceId],
|
||||
);
|
||||
const bytes = await deps.storage.get(stored[0].temp_storage_key);
|
||||
const parsed = await readSpreadsheet(stored[0].source_name, bytes, {
|
||||
sheet: stored[0].selected_sheet,
|
||||
headerRow: stored[0].header_row,
|
||||
});
|
||||
return buildPreview(
|
||||
job.model.columns,
|
||||
parsed.grid.headers,
|
||||
parsed.grid.rows,
|
||||
source.mappings.map((mapping) => ({ sourceColumn: mapping.sourceColumn, modelColumn: mapping.modelColumn })),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function inlineMode(): boolean {
|
||||
if (process.env.ADAPTER_JOB_MODE === "worker") return false;
|
||||
if (process.env.ADAPTER_JOB_MODE === "inline") return true;
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, idempotencyKey: string): Promise<JobDto> {
|
||||
assertUse(ctx);
|
||||
if (!idempotencyKey || idempotencyKey.length > 120) {
|
||||
throw new AdapterError("IDEMPOTENCY_REQUIRED", "Informe uma chave de idempotência para processar.", 400);
|
||||
}
|
||||
const current = await deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
const source = current.sources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new AdapterError("NOT_FOUND", "Fonte não encontrada.", 404);
|
||||
const prior = await deps.db.withUser(ctx.userId, (query) =>
|
||||
query.query<{ process_idempotency_key: string | null; status: string }>(
|
||||
`select process_idempotency_key, status from public.spreadsheet_job_sources where id = $1 and job_id = $2`,
|
||||
[sourceId, jobId],
|
||||
),
|
||||
);
|
||||
if (
|
||||
prior[0]?.process_idempotency_key === idempotencyKey &&
|
||||
(prior[0].status === "completed" || prior[0].status === "processing")
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const validation = validateMappings(
|
||||
current.model.columns,
|
||||
source.columns.map((column) => column.name),
|
||||
source.mappings.map((mapping) => ({ sourceColumn: mapping.sourceColumn, modelColumn: mapping.modelColumn })),
|
||||
);
|
||||
if (!validation.ok) {
|
||||
throw new AdapterError(
|
||||
validation.code,
|
||||
validation.missingRequired.length
|
||||
? `Mapeie as colunas obrigatórias: ${validation.missingRequired.join(", ")}.`
|
||||
: "Mapeamento inválido.",
|
||||
422,
|
||||
{ missingRequired: validation.missingRequired },
|
||||
);
|
||||
}
|
||||
let locked = false;
|
||||
try {
|
||||
locked = await deps.coordinator.acquireLock(lockKey(ctx.userId), jobId, 120);
|
||||
} catch (error) {
|
||||
if (isAdapterError(error) && error.code === "REDIS_UNAVAILABLE") throw error;
|
||||
throw new AdapterError("REDIS_UNAVAILABLE", "Coordenação indisponível. O processamento não foi iniciado.", 503);
|
||||
}
|
||||
if (!locked) {
|
||||
const owns = await deps.coordinator.ownsLock(lockKey(ctx.userId), jobId).catch(() => false);
|
||||
if (!owns) throw new AdapterError("JOB_LOCKED", "Já existe uma transferência em andamento para este usuário.", 409);
|
||||
}
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
const sourceIndex = current.sources.findIndex((item) => item.id === sourceId) + 1;
|
||||
const progress = {
|
||||
phase: "validating",
|
||||
label: "Validando",
|
||||
percent: null,
|
||||
sourceIndex,
|
||||
sourceCount: current.sources.length,
|
||||
sourceId,
|
||||
};
|
||||
await query.query(
|
||||
`update public.spreadsheet_jobs
|
||||
set status = 'queued', error_code = null, progress = $3::jsonb, current_source = $4,
|
||||
started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where id = $1 and user_id = $2 and status in ('ready','draft','failed','completed')`,
|
||||
[jobId, ctx.userId, JSON.stringify(progress), sourceIndex],
|
||||
);
|
||||
await query.query(
|
||||
`update public.spreadsheet_job_sources set process_idempotency_key = $2 where id = $1`,
|
||||
[sourceId, idempotencyKey],
|
||||
);
|
||||
await audit(query, ctx, "job.started", "job", jobId, "success", { source_id: sourceId });
|
||||
});
|
||||
if (inlineMode()) await runProcess(deps, ctx, jobId, sourceId);
|
||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
}
|
||||
|
||||
export async function getJob(deps: AppDeps, ctx: RequestContext, jobId: string): Promise<JobDto> {
|
||||
assertUse(ctx);
|
||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
}
|
||||
|
||||
export { runProcess };
|
||||
Reference in new issue
Block a user