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:
camilafagundesandCursor committed 2026-10-02 23:02:00 -03:00
commit 6e23f1533b
144 files changed
+19060

No files matched your search

+26
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+8
View File
@@ -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>;
}
+9
View File
@@ -0,0 +1,9 @@
import { LoadingBlock } from "@/components/states";
export default function Loading() {
return (
<main className="p-8">
<LoadingBlock label="Abrindo o Adaptador" />
</main>
);
}
+17
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+24
View File
@@ -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 });
}
+29
View File
@@ -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 });
}
+20
View File
@@ -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 });
}
}
+27
View File
@@ -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);
}
}
+31
View File
@@ -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;
}
+18
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/adaptador");
}
+56
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+327
View File
@@ -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>
);
}
+9
View File
@@ -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>;
}
+157
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+17
View File
@@ -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;
}
+43
View File
@@ -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);
}
+83
View File
@@ -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 };
}
+17
View File
@@ -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;
}
+27
View File
@@ -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);
}
+18
View File
@@ -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.");
}
}
+40
View File
@@ -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);
}
+28
View File
@@ -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] ?? "";
}),
);
}
+73
View File
@@ -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;
};
+37
View File
@@ -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.`);
}
+24
View File
@@ -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;
}
+28
View File
@@ -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;
}
+58
View File
@@ -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);
}
+36
View File
@@ -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();
},
};
}
+32
View File
@@ -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;
};
}
+45
View File
@@ -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 };
}
+28
View File
@@ -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);
}
+22
View File
@@ -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,
};
}
+35
View File
@@ -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;
}
+8
View File
@@ -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();
}
+86
View File
@@ -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();
}
}
+74
View File
@@ -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}`;
}
+50
View File
@@ -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) };
}
+15
View File
@@ -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"));
}
+64
View File
@@ -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(/\/$/, "")));
}
}
+34
View File
@@ -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));
}
}
+34
View File
@@ -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}`);
},
};
}
+12
View File
@@ -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);
},
});
}
+13
View File
@@ -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);
},
});
}
+21
View File
@@ -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*"],
};
+79
View File
@@ -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,
},
};
}
+32
View File
@@ -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;
}
+12
View File
@@ -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;
};
+67
View File
@@ -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,
}));
}
+15
View File
@@ -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,
};
}
+513
View File
@@ -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 };