1 parent
6e23f1533b
commit
419325db12
52 files changed
+1329
-110
No files matched your search
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous") redirect("/login");
|
||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||
if (!canManageSpreadsheetModels(auth.ctx)) {
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OperationPage() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous") redirect("/login");
|
||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||
return (
|
||||
<AppShell ctx={auth.ctx}>
|
||||
|
||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdapterHome() {
|
||||
const auth = await resolveAuth();
|
||||
if (auth.kind === "anonymous") redirect("/login");
|
||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||
if (auth.kind !== "ok") {
|
||||
return (
|
||||
<main className="p-8">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ status: "live", module: "spreadsheet-adapter" });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { collectProductionIssues } from "@/config/env";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const checks: Record<string, "ok" | "missing" | "down"> = {
|
||||
database: "down",
|
||||
redis: process.env.REDIS_URL ? "ok" : "missing",
|
||||
storage: process.env.SUPABASE_STORAGE_BUCKET && process.env.SUPABASE_STORAGE_BUCKET !== "public" ? "ok" : "missing",
|
||||
config: collectProductionIssues(process.env).length === 0 ? "ok" : "missing",
|
||||
};
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.withService((query) => query.query("select 1 as ok"));
|
||||
checks.database = "ok";
|
||||
} catch {
|
||||
checks.database = "down";
|
||||
}
|
||||
const ready = checks.database === "ok" && (process.env.NODE_ENV !== "production" || (checks.redis === "ok" && checks.storage === "ok" && checks.config === "ok"));
|
||||
return NextResponse.json({ status: ready ? "ready" : "not_ready", checks }, { status: ready ? 200 : 503 });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { presentError } from "@/domain/spreadsheet-adapter/error-catalog";
|
||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { resolveAuth } from "@/server/auth/context";
|
||||
@@ -9,18 +10,25 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export function errorResponse(error: unknown): NextResponse {
|
||||
if (isAdapterError(error)) {
|
||||
const view = presentError(error.code);
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, message: error.message, details: error.details ?? null } },
|
||||
{ error: { code: error.code, message: error.message, action: view.action, category: view.category, reference: error.code, 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 });
|
||||
const view = presentError("INTERNAL");
|
||||
return NextResponse.json(
|
||||
{ error: { code: "INTERNAL", message: "Erro interno.", action: view.action, category: view.category, reference: "INTERNAL" } },
|
||||
{ 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 === "anonymous" || auth.kind === "session_expired") {
|
||||
throw new AdapterError("UNAUTHENTICATED", auth.kind === "session_expired" ? "Sessão expirada. Entre novamente." : "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() };
|
||||
|
||||
+10
-3
@@ -2,14 +2,20 @@ 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 { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
|
||||
import { DevLogin } from "@/components/dev-login";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LoginPage() {
|
||||
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ next?: string; reason?: string }> }) {
|
||||
const params = await searchParams;
|
||||
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);
|
||||
const nextPath = safeReturnPath(params.next);
|
||||
if (auth.kind === "ok") redirect(nextPath);
|
||||
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) {
|
||||
redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
|
||||
}
|
||||
const expired = auth.kind === "session_expired" || params.reason === "expired";
|
||||
return (
|
||||
<main className="mx-auto max-w-xl p-8">
|
||||
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||
@@ -17,6 +23,7 @@ export default async function LoginPage() {
|
||||
<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>
|
||||
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente pelo Elo Pro.</p> : null}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ 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 [message, setMessage] = useState<{ text: string; action?: string; reference?: 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 }[]>([]);
|
||||
@@ -94,6 +94,10 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
});
|
||||
}
|
||||
|
||||
function fail(error?: { message?: string; action?: string; code?: string }, fallback = "Não foi possível continuar.") {
|
||||
setMessage({ text: error?.message ?? fallback, action: error?.action, reference: error?.code });
|
||||
}
|
||||
|
||||
async function start(modelId: string) {
|
||||
setMessage(null);
|
||||
const response = await fetch("/api/spreadsheet-adapter/jobs", {
|
||||
@@ -101,9 +105,9 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelId, idempotencyKey: crypto.randomUUID() }),
|
||||
});
|
||||
const data = await readJson<{ job?: JobDto; error?: { message: string } }>(response);
|
||||
const data = await readJson<{ job?: JobDto; error?: { message: string; action?: string; code?: string } }>(response);
|
||||
if (!response.ok || !data.job) {
|
||||
setMessage(data.error?.message ?? "Não foi possível abrir a operação");
|
||||
fail(data.error, "Não foi possível abrir a operação");
|
||||
return;
|
||||
}
|
||||
setJobId(data.job.id);
|
||||
@@ -117,12 +121,12 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
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);
|
||||
const data = await readJson<{ job: JobDto; code?: string; message?: string; error?: { message: string; action?: string; code?: string } }>(response);
|
||||
if (data.error) {
|
||||
setMessage(data.error.message);
|
||||
fail(data.error);
|
||||
return;
|
||||
}
|
||||
setMessage(data.message ?? null);
|
||||
setMessage(data.message ? { text: 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();
|
||||
@@ -147,7 +151,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
});
|
||||
const data = await readJson<PreviewContract & { error?: { message: string } }>(response);
|
||||
if (!response.ok) {
|
||||
setMessage(data.error?.message ?? "Prévia indisponível");
|
||||
fail(data.error, "Prévia indisponível");
|
||||
return;
|
||||
}
|
||||
setPreview(data);
|
||||
@@ -163,8 +167,8 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
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");
|
||||
const data = await readJson<{ error?: { message: string; action?: string; code?: string } }>(response);
|
||||
if (!response.ok) fail(data.error, "Processamento não iniciado");
|
||||
await job.refetch();
|
||||
setStep(5);
|
||||
}
|
||||
@@ -176,7 +180,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
<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}
|
||||
{message ? <ErrorState message={message.text} action={message.action} reference={message.reference} /> : null}
|
||||
{step === 0 ? (
|
||||
<Card>
|
||||
{models.isLoading ? <LoadingBlock /> : null}
|
||||
@@ -284,7 +288,9 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
||||
<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}
|
||||
{job.data.status === "failed" ? (
|
||||
<ErrorState message="O processamento não foi concluído." action="Tente novamente." reference={job.data.errorCode ?? undefined} onRetry={() => void processCurrent()} />
|
||||
) : null}
|
||||
{source?.hasOutput ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(["xlsx", "csv", "pdf"] as const).map((format) => (
|
||||
|
||||
@@ -19,11 +19,23 @@ export function EmptyState({ title, description, action }: { title: string; desc
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
export function ErrorState({
|
||||
message,
|
||||
action,
|
||||
reference,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
action?: string;
|
||||
reference?: 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>
|
||||
{action ? <p className="mt-1 text-sm">{action}</p> : null}
|
||||
{reference ? <p className="mt-2 text-xs">Referência: {reference}</p> : null}
|
||||
{onRetry ? (
|
||||
<button type="button" className="mt-3 text-sm font-semibold underline" onClick={onRetry}>
|
||||
Tentar novamente
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type EnvMap = Record<string, string | undefined>;
|
||||
|
||||
export type EnvIssue = { variable: string; message: string };
|
||||
|
||||
const REQUIRED_IN_PRODUCTION = [
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"SUPABASE_SERVICE_ROLE_KEY",
|
||||
"SUPABASE_STORAGE_BUCKET",
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"SENTRY_DSN",
|
||||
"ELO_PRO_LOGIN_URL",
|
||||
"SPREADSHEET_TEMP_TTL_SECONDS",
|
||||
] as const;
|
||||
|
||||
export function collectProductionIssues(env: EnvMap): EnvIssue[] {
|
||||
if (env.NODE_ENV !== "production") return [];
|
||||
const issues: EnvIssue[] = [];
|
||||
for (const variable of REQUIRED_IN_PRODUCTION) {
|
||||
if (!env[variable] || env[variable]?.trim() === "") {
|
||||
issues.push({ variable, message: "obrigatória em produção" });
|
||||
}
|
||||
}
|
||||
if (env.ADAPTER_DEV_AUTH === "1") {
|
||||
issues.push({ variable: "ADAPTER_DEV_AUTH", message: "não pode estar ligado em produção" });
|
||||
}
|
||||
if (env.ADAPTER_JOB_MODE === "inline") {
|
||||
issues.push({ variable: "ADAPTER_JOB_MODE", message: "inline não é permitido em produção" });
|
||||
}
|
||||
const ttl = env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||
if (ttl && ttl.trim() !== "") {
|
||||
const value = Number(ttl);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 60 * 60 * 24 * 7) {
|
||||
issues.push({ variable: "SPREADSHEET_TEMP_TTL_SECONDS", message: "inteiro entre 1 e 604800" });
|
||||
}
|
||||
}
|
||||
if (env.SUPABASE_STORAGE_BUCKET === "public") {
|
||||
issues.push({ variable: "SUPABASE_STORAGE_BUCKET", message: "bucket público não é aceito" });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function assertProductionEnv(env: EnvMap = process.env): void {
|
||||
if (env.NODE_ENV !== "production") return;
|
||||
if (env.NEXT_PHASE === "phase-production-build") return;
|
||||
if (env.npm_lifecycle_event === "build") return;
|
||||
const issues = collectProductionIssues(env);
|
||||
if (issues.length === 0) return;
|
||||
const summary = issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
||||
throw new Error(`Configuração de produção recusada. ${summary}`);
|
||||
}
|
||||
|
||||
export function jobRunsInline(env: EnvMap = process.env): boolean {
|
||||
if (env.NODE_ENV === "production" && env.ADAPTER_JOB_MODE === "inline") return false;
|
||||
if (env.ADAPTER_JOB_MODE === "worker") return false;
|
||||
if (env.ADAPTER_JOB_MODE === "inline") return true;
|
||||
return env.NODE_ENV !== "production";
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AdapterError } from "./errors";
|
||||
import { extractHeaders } from "./headers";
|
||||
import { MAX_USEFUL_ROWS } from "./limits";
|
||||
import { MAX_CELL_CHARS, MAX_COLUMNS, MAX_USEFUL_ROWS } from "./limits";
|
||||
import { isUsefulRow } from "./transform";
|
||||
import { inferColumns, typeConflictMessages } from "./typing";
|
||||
import type { ParsedGrid } from "./types";
|
||||
@@ -68,9 +68,17 @@ export function gridFromRows(allRows: string[][], headerRow: number, sheetNames:
|
||||
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] ?? []);
|
||||
if (headers.length > MAX_COLUMNS) {
|
||||
throw new AdapterError("TOO_MANY_COLUMNS", `O arquivo excede ${MAX_COLUMNS} colunas.`);
|
||||
}
|
||||
const data: string[][] = [];
|
||||
for (const raw of allRows.slice(headerRow)) {
|
||||
const sized = headers.map((_, index) => String(raw[index] ?? ""));
|
||||
for (const value of sized) {
|
||||
if (value.length > MAX_CELL_CHARS) {
|
||||
throw new AdapterError("CELL_TOO_LARGE", "Uma célula excede o tamanho permitido.");
|
||||
}
|
||||
}
|
||||
if (!isUsefulRow(sized)) continue;
|
||||
data.push(sized);
|
||||
if (data.length > MAX_USEFUL_ROWS) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export type ErrorCategory =
|
||||
| "authentication"
|
||||
| "authorization"
|
||||
| "invalid_file"
|
||||
| "file_too_large"
|
||||
| "invalid_configuration"
|
||||
| "temporary"
|
||||
| "processing"
|
||||
| "timeout"
|
||||
| "storage"
|
||||
| "worker";
|
||||
|
||||
export type PublicErrorView = {
|
||||
category: ErrorCategory;
|
||||
action: string;
|
||||
};
|
||||
|
||||
const VIEWS: Record<string, PublicErrorView> = {
|
||||
UNAUTHENTICATED: { category: "authentication", action: "Entre novamente pelo Elo Pro." },
|
||||
WORKSPACE_FORBIDDEN: { category: "authorization", action: "Peça acesso ao workspace ou selecione outro no Elo Pro." },
|
||||
FEATURE_DISABLED: { category: "authorization", action: "O módulo não está ativo neste workspace." },
|
||||
FORBIDDEN: { category: "authorization", action: "Sua função não permite esta ação." },
|
||||
FILE_TOO_LARGE: { category: "file_too_large", action: "Envie um arquivo de até 10 MB." },
|
||||
TOO_MANY_ROWS: { category: "file_too_large", action: "Divida a planilha e envie até 50.000 linhas úteis." },
|
||||
TOO_MANY_COLUMNS: { category: "file_too_large", action: "Reduza a quantidade de colunas." },
|
||||
CELL_TOO_LARGE: { category: "file_too_large", action: "Reduza o texto da célula maior e envie de novo." },
|
||||
EMPTY_FILE: { category: "invalid_file", action: "Envie um arquivo com conteúdo." },
|
||||
UNSUPPORTED_FORMAT: { category: "invalid_file", action: "Use XLSX ou CSV." },
|
||||
INVALID_STRUCTURE: { category: "invalid_file", action: "Verifique se o arquivo abre no editor e não está protegido por senha." },
|
||||
INVALID_FORMAT: { category: "invalid_file", action: "Use XLSX ou CSV." },
|
||||
MIME_MISMATCH: { category: "invalid_file", action: "O conteúdo do arquivo não corresponde à extensão." },
|
||||
CSV_INVALID: { category: "invalid_file", action: "Salve o CSV em UTF-8 e confira aspas e delimitador." },
|
||||
ZIP_BOMB: { category: "invalid_file", action: "O arquivo compactado foi recusado." },
|
||||
MACROS_NOT_ALLOWED: { category: "invalid_file", action: "Remova as macros e envie somente a planilha." },
|
||||
TIMEOUT: { category: "timeout", action: "Tente de novo com um arquivo menor." },
|
||||
REDIS_UNAVAILABLE: { category: "worker", action: "A fila está indisponível. Aguarde e tente novamente." },
|
||||
JOB_LOCKED: { category: "processing", action: "Aguarde a transferência em andamento terminar." },
|
||||
STORAGE_FAILURE: { category: "storage", action: "Não foi possível guardar o arquivo. Tente novamente." },
|
||||
INVALID_SETTING: { category: "invalid_configuration", action: "Revise a configuração com um administrador." },
|
||||
PROCESS_FAILED: { category: "processing", action: "Tente processar novamente." },
|
||||
INTERNAL: { category: "temporary", action: "Tente novamente. Se continuar, informe a referência ao suporte." },
|
||||
};
|
||||
|
||||
export function presentError(code: string): PublicErrorView {
|
||||
return VIEWS[code] ?? { category: "processing", action: "Tente novamente. Se continuar, informe a referência ao suporte." };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MAX_PROCESS_ATTEMPTS } from "./limits";
|
||||
|
||||
const NO_RETRY = new Set([
|
||||
"TIMEOUT",
|
||||
"TYPE_INCONSISTENT",
|
||||
"MAPPING_INCOMPLETE",
|
||||
"MAPPING_CONFLICT",
|
||||
"MAPPING_UNKNOWN",
|
||||
"FORBIDDEN",
|
||||
"FEATURE_DISABLED",
|
||||
"JOB_LOCKED",
|
||||
"NOT_FOUND",
|
||||
"MODEL_TAMPERED",
|
||||
"REDIS_UNAVAILABLE",
|
||||
"FILE_TOO_LARGE",
|
||||
"TOO_MANY_ROWS",
|
||||
"TOO_MANY_COLUMNS",
|
||||
"CELL_TOO_LARGE",
|
||||
"MACROS_NOT_ALLOWED",
|
||||
"INVALID_STRUCTURE",
|
||||
"ZIP_BOMB",
|
||||
]);
|
||||
|
||||
export function shouldRequeue(code: string, httpStatus: number, attempt: number): boolean {
|
||||
if (NO_RETRY.has(code)) return false;
|
||||
if (httpStatus < 500) return false;
|
||||
return attempt < MAX_PROCESS_ATTEMPTS;
|
||||
}
|
||||
@@ -55,7 +55,14 @@ export function assertZipSafe(buf: Buffer): void {
|
||||
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;
|
||||
const nameStart = offset + 30;
|
||||
const nameEnd = nameStart + nameLen;
|
||||
if (nameEnd > buf.length) throw new AdapterError("INVALID_STRUCTURE", "Estrutura ZIP inconsistente.");
|
||||
const entryName = buf.toString("utf8", nameStart, nameEnd);
|
||||
if (/vbaProject\.bin|xl\/macrosheets/i.test(entryName)) {
|
||||
throw new AdapterError("MACROS_NOT_ALLOWED", "O arquivo contém macros. O Adaptador não as executa.");
|
||||
}
|
||||
const dataStart = nameEnd + extraLen;
|
||||
if (flags & 0x08 && compressed === 0) break;
|
||||
if (dataStart + compressed > buf.length + 64) {
|
||||
throw new AdapterError("INVALID_STRUCTURE", "Estrutura ZIP inconsistente.");
|
||||
|
||||
@@ -7,6 +7,10 @@ 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 const MAX_SHEETS = 30;
|
||||
export const MAX_COLUMNS = 200;
|
||||
export const MAX_CELL_CHARS = 8_000;
|
||||
export const MAX_PROCESS_ATTEMPTS = 3;
|
||||
|
||||
export function assertFileSize(size: number): void {
|
||||
if (size <= 0) {
|
||||
|
||||
@@ -21,3 +21,13 @@ export function canUploadSpreadsheetModel(
|
||||
export function canOperateSpreadsheet(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
|
||||
return canUseSpreadsheetAdapter(ctx);
|
||||
}
|
||||
|
||||
export function adapterPermissions(
|
||||
ctx: Pick<RequestContext, "role" | "moduleEnabled" | "allowNonAdminModelUpload">,
|
||||
): string[] {
|
||||
const permissions = ["adapter.session"];
|
||||
if (canUseSpreadsheetAdapter(ctx)) permissions.push("adapter.use");
|
||||
if (canManageSpreadsheetModels(ctx)) permissions.push("adapter.manage");
|
||||
if (canUploadSpreadsheetModel(ctx)) permissions.push("adapter.upload");
|
||||
return permissions;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Política técnica provisória. O prazo numérico de produto continua em
|
||||
* SPREADSHEET_TEMP_TTL_SECONDS e ainda depende de aprovação operacional.
|
||||
*/
|
||||
export const retentionPolicy = {
|
||||
mapping: "kept_with_job",
|
||||
model: "soft_delete",
|
||||
sourceFile: "hard_delete_after_ttl",
|
||||
resultFile: "hard_delete_after_ttl",
|
||||
job: "metadata_kept",
|
||||
audit: "append_only",
|
||||
} as const;
|
||||
|
||||
export type RetentionClass = keyof typeof retentionPolicy;
|
||||
@@ -59,6 +59,7 @@ export type JobProgress = {
|
||||
sourceIndex: number;
|
||||
sourceCount: number;
|
||||
sourceId?: string;
|
||||
attempt?: number;
|
||||
};
|
||||
|
||||
export type RequestContext = {
|
||||
@@ -70,4 +71,10 @@ export type RequestContext = {
|
||||
role: Role;
|
||||
moduleEnabled: boolean;
|
||||
allowNonAdminModelUpload: boolean;
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
/** Contexto autenticado resolvido no servidor. O workspace não vem do cliente. */
|
||||
export type AuthenticatedContext = RequestContext & {
|
||||
permissions: string[];
|
||||
};
|
||||
@@ -1,4 +1,15 @@
|
||||
export { scrubSentryEvent } from "./scrub";
|
||||
import { scrubSentryEvent, sentrySafeError } from "./scrub";
|
||||
|
||||
export { scrubSentryEvent, sentrySafeError };
|
||||
|
||||
export function buildSentryPayload(error: unknown, tags: Record<string, string>) {
|
||||
const safe = sentrySafeError(error);
|
||||
return scrubSentryEvent({
|
||||
message: safe.message,
|
||||
extra: tags,
|
||||
exception: { values: [{ value: error instanceof Error ? error.message : "UNEXPECTED" }] },
|
||||
});
|
||||
}
|
||||
|
||||
export function captureAdapterException(error: unknown, tags: Record<string, string>): void {
|
||||
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||
@@ -6,7 +17,7 @@ export function captureAdapterException(error: unknown, tags: Record<string, str
|
||||
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);
|
||||
Sentry.captureException(sentrySafeError(error));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const BLOCKED_KEY = /^(cells|rows|row_values|values|content|token|password|secret|authorization|cookie|file_base64|spreadsheet|csv_body|xlsx_body)$/i;
|
||||
const BLOCKED_KEY = /^(cells|rows|row_values|values|content|token|password|secret|authorization|cookie|file_base64|spreadsheet|csv_body|xlsx_body|preview)$/i;
|
||||
const SAFE_TEXT = /^[A-Za-z0-9_.:-]{1,80}$/;
|
||||
|
||||
function redactFreeText(value: string): string {
|
||||
return SAFE_TEXT.test(value) ? value : "redacted";
|
||||
}
|
||||
|
||||
export function sanitizeAuditMetadata(input: Record<string, unknown> | undefined): Record<string, string | number | boolean | null> {
|
||||
const output: Record<string, string | number | boolean | null> = {};
|
||||
@@ -11,7 +16,7 @@ export function sanitizeAuditMetadata(input: Record<string, unknown> | undefined
|
||||
return output;
|
||||
}
|
||||
|
||||
export function scrubSentryEvent<T extends { request?: { data?: unknown; cookies?: unknown; headers?: Record<string, string> }; extra?: Record<string, unknown>; breadcrumbs?: unknown[] }>(
|
||||
export function scrubSentryEvent<T extends { request?: { data?: unknown; cookies?: unknown; headers?: Record<string, string> }; extra?: Record<string, unknown>; breadcrumbs?: unknown[]; exception?: { values?: Array<{ value?: string }> }; message?: string }>(
|
||||
event: T,
|
||||
): T {
|
||||
if (event.request) {
|
||||
@@ -27,9 +32,28 @@ export function scrubSentryEvent<T extends { request?: { data?: unknown; cookies
|
||||
}
|
||||
if (event.extra) {
|
||||
for (const key of Object.keys(event.extra)) {
|
||||
if (BLOCKED_KEY.test(key)) delete event.extra[key];
|
||||
if (BLOCKED_KEY.test(key)) {
|
||||
delete event.extra[key];
|
||||
continue;
|
||||
}
|
||||
const value = event.extra[key];
|
||||
if (typeof value === "string") event.extra[key] = redactFreeText(value);
|
||||
}
|
||||
}
|
||||
if (event.message) event.message = redactFreeText(event.message);
|
||||
for (const item of event.exception?.values ?? []) {
|
||||
if (item.value) item.value = redactFreeText(item.value);
|
||||
}
|
||||
event.breadcrumbs = [];
|
||||
return event;
|
||||
}
|
||||
|
||||
export function sentrySafeError(error: unknown): Error {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error && typeof (error as { code: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: "UNEXPECTED";
|
||||
const safe = new Error(SAFE_TEXT.test(code) ? code : "UNEXPECTED");
|
||||
safe.name = "AdapterFailure";
|
||||
return safe;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export interface Coordinator {
|
||||
releaseLock(key: string, owner: string): Promise<void>;
|
||||
setProgress(jobId: string, progress: JobProgress): Promise<void>;
|
||||
getProgress(jobId: string): Promise<JobProgress | null>;
|
||||
enqueue(jobId: string): Promise<void>;
|
||||
dequeue(): Promise<string | null>;
|
||||
}
|
||||
|
||||
type LockRecord = { owner: string; expiresAt: number };
|
||||
@@ -15,6 +17,7 @@ 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>();
|
||||
private readonly queue: string[] = [];
|
||||
|
||||
constructor(private readonly now: () => number = () => Date.now()) {}
|
||||
|
||||
@@ -58,6 +61,14 @@ export class MemoryCoordinator implements Coordinator {
|
||||
async getProgress(jobId: string): Promise<JobProgress | null> {
|
||||
return this.progress.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
async enqueue(jobId: string): Promise<void> {
|
||||
if (!this.queue.includes(jobId)) this.queue.push(jobId);
|
||||
}
|
||||
|
||||
async dequeue(): Promise<string | null> {
|
||||
return this.queue.shift() ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnavailableCoordinator implements Coordinator {
|
||||
@@ -83,4 +94,10 @@ export class UnavailableCoordinator implements Coordinator {
|
||||
getProgress(): Promise<JobProgress | null> {
|
||||
this.fail();
|
||||
}
|
||||
enqueue(): Promise<void> {
|
||||
this.fail();
|
||||
}
|
||||
dequeue(): Promise<string | null> {
|
||||
this.fail();
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ export class RedisCoordinator implements Coordinator {
|
||||
return raw ? (JSON.parse(raw) as JobProgress) : null;
|
||||
});
|
||||
}
|
||||
|
||||
async enqueue(jobId: string): Promise<void> {
|
||||
await this.run(async () => {
|
||||
await this.redis.lpush(queueKey(), jobId);
|
||||
});
|
||||
}
|
||||
|
||||
async dequeue(): Promise<string | null> {
|
||||
return this.run(async () => this.redis.rpop(queueKey()));
|
||||
}
|
||||
}
|
||||
|
||||
export function envNamespace(): string {
|
||||
@@ -72,3 +82,7 @@ export function lockKey(userId: string): string {
|
||||
export function progressKey(jobId: string): string {
|
||||
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`;
|
||||
}
|
||||
|
||||
export function queueKey(): string {
|
||||
return `spreadsheet-adapter:${envNamespace()}:queue`;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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 { MAX_CELL_CHARS, MAX_COLUMNS, MAX_SHEETS, MAX_USEFUL_ROWS } from "@/domain/spreadsheet-adapter/limits";
|
||||
import type { ParsedGrid, SpreadsheetFormat } from "@/domain/spreadsheet-adapter/types";
|
||||
|
||||
export async function readSpreadsheet(
|
||||
@@ -13,6 +13,11 @@ export async function readSpreadsheet(
|
||||
const format = detectSpreadsheetFormat(filename, bytes);
|
||||
const headerRow = options.headerRow ?? 1;
|
||||
if (format === "csv") {
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw new AdapterError("CSV_INVALID", "O CSV precisa estar em UTF-8.");
|
||||
}
|
||||
return { format, grid: gridFromRows(parseCsvText(bytes.toString("utf8")), headerRow, ["CSV"], "CSV") };
|
||||
}
|
||||
assertZipSafe(bytes);
|
||||
@@ -20,10 +25,11 @@ export async function readSpreadsheet(
|
||||
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.");
|
||||
throw new AdapterError("INVALID_STRUCTURE", "Não foi possível ler o XLSX. Ele pode estar corrompido ou protegido por senha.");
|
||||
}
|
||||
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 (names.length > MAX_SHEETS) throw new AdapterError("TOO_MANY_SHEETS", `O arquivo excede ${MAX_SHEETS} abas.`);
|
||||
if (!options.sheet && names.length > 1) {
|
||||
throw new AdapterError("SHEET_REQUIRED", "Selecione exatamente uma aba.", 409, { sheets: names });
|
||||
}
|
||||
@@ -38,8 +44,13 @@ export async function readSpreadsheet(
|
||||
row.eachCell({ includeEmpty: false }, (_cell, column) => {
|
||||
width = Math.max(width, column);
|
||||
});
|
||||
if (width > MAX_COLUMNS) throw new AdapterError("TOO_MANY_COLUMNS", `O arquivo excede ${MAX_COLUMNS} colunas.`);
|
||||
const values: string[] = [];
|
||||
for (let column = 1; column <= width; column += 1) values.push(row.getCell(column).text ?? "");
|
||||
for (let column = 1; column <= width; column += 1) {
|
||||
const text = row.getCell(column).text ?? "";
|
||||
if (text.length > MAX_CELL_CHARS) throw new AdapterError("CELL_TOO_LARGE", "Uma célula excede o tamanho permitido.");
|
||||
values.push(text);
|
||||
}
|
||||
matrix.push(values);
|
||||
}
|
||||
if (sheet.rowCount > last) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { assertProductionEnv } from "@/config/env";
|
||||
import { scrubSentryEvent, sentryInitOptions } from "@/infra/observability/capture";
|
||||
|
||||
export async function register() {
|
||||
assertProductionEnv(process.env);
|
||||
const options = sentryInitOptions(process.env.SENTRY_DSN);
|
||||
if (!options) return;
|
||||
const Sentry = await import("@sentry/nextjs");
|
||||
|
||||
+16
-15
@@ -1,21 +1,24 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import type { RequestContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
|
||||
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||
import { getDb } from "@/infra/db/client";
|
||||
import { devAuthEnabled, verifyDevSession } from "./dev-session";
|
||||
|
||||
export type AuthState =
|
||||
| { kind: "anonymous" }
|
||||
| { kind: "session_expired" }
|
||||
| { kind: "no_workspace" }
|
||||
| { kind: "not_member" }
|
||||
| { kind: "ok"; ctx: RequestContext };
|
||||
| { kind: "ok"; ctx: AuthenticatedContext };
|
||||
|
||||
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;
|
||||
const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
||||
if (devToken && devAuthEnabled()) {
|
||||
const dev = verifyDevSession(devToken);
|
||||
if (dev) {
|
||||
@@ -37,7 +40,7 @@ export async function resolveAuth(): Promise<AuthState> {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
userId = data.user?.id ?? null;
|
||||
}
|
||||
if (!userId) return { kind: "anonymous" };
|
||||
if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" };
|
||||
if (!workspaceId) return { kind: "no_workspace" };
|
||||
const db = await getDb();
|
||||
const rows = await db.withUser(userId, (query) =>
|
||||
@@ -63,17 +66,15 @@ export async function resolveAuth(): Promise<AuthState> {
|
||||
);
|
||||
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,
|
||||
},
|
||||
const 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,
|
||||
};
|
||||
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Pontos de integração com a identidade Elo Pro.
|
||||
* Os nomes abaixo são o contrato que o Adaptador lê hoje no shim local.
|
||||
* Eles NÃO estão confirmados contra o banco real da Elo Pro.
|
||||
* Quando o repositório real estiver disponível, este arquivo é o único
|
||||
* lugar que deve mudar para apontar às tabelas existentes. Não criar users,
|
||||
* senha, cadastro ou papel paralelo.
|
||||
*/
|
||||
export const eloProIdentityContract = {
|
||||
authUsers: "auth.users",
|
||||
profile: { table: "public.profiles", status: "unconfirmed_against_real_elo_pro" },
|
||||
workspace: { table: "public.workspaces", status: "unconfirmed_against_real_elo_pro" },
|
||||
membership: { table: "public.workspace_members", status: "unconfirmed_against_real_elo_pro" },
|
||||
moduleFlag: { table: "public.workspace_module_flags", key: "spreadsheet_adapter", status: "adapter_specific" },
|
||||
workspaceCookie: "elo_pro_workspace_id",
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
export function allowedReturnHosts(env: Record<string, string | undefined> = process.env): string[] {
|
||||
const hosts = (env.AUTH_ALLOWED_RETURN_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean);
|
||||
if (env.ELO_PRO_APP_URL) {
|
||||
try {
|
||||
hosts.push(new URL(env.ELO_PRO_APP_URL).host);
|
||||
} catch {
|
||||
/* host inválido não entra na lista */
|
||||
}
|
||||
}
|
||||
return [...new Set(hosts)];
|
||||
}
|
||||
|
||||
/** Aceita somente caminho relativo interno. Host externo é recusado. */
|
||||
export function safeReturnPath(input: string | null | undefined): string {
|
||||
if (!input) return "/adaptador";
|
||||
if (!input.startsWith("/") || input.startsWith("//") || input.includes("\\") || input.includes("://")) {
|
||||
return "/adaptador";
|
||||
}
|
||||
const path = input.slice(0, 300);
|
||||
return path.startsWith("/login") ? "/adaptador" : path;
|
||||
}
|
||||
|
||||
export function buildCentralLoginUrl(loginUrl: string, returnPath: string, appUrl?: string): string {
|
||||
const target = new URL(loginUrl);
|
||||
const path = safeReturnPath(returnPath);
|
||||
if (!appUrl) {
|
||||
target.searchParams.set("returnTo", path);
|
||||
return target.toString();
|
||||
}
|
||||
const app = new URL(appUrl);
|
||||
const absolute = new URL(path, app);
|
||||
if (absolute.origin !== app.origin) return target.toString();
|
||||
target.searchParams.set("returnTo", absolute.toString());
|
||||
return target.toString();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||
import { shouldRequeue } from "@/domain/spreadsheet-adapter/failure-policy";
|
||||
import { neutralizeRow, neutralizeSpreadsheetFormula } from "@/domain/spreadsheet-adapter/csv-injection";
|
||||
import { buildOutputFilename } from "@/domain/spreadsheet-adapter/filenames";
|
||||
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
||||
@@ -22,6 +23,12 @@ function assertUse(ctx: RequestContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwnedTempKey(workspaceId: string, key: string): void {
|
||||
if (!key.startsWith(`workspaces/${workspaceId}/tmp/`) || !key.includes("/tmp/") || key.includes("/models/") || key.includes("..")) {
|
||||
throw new AdapterError("PATH_TRAVERSAL", "Chave temporária rejeitada.");
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(
|
||||
query: Query,
|
||||
ctx: RequestContext,
|
||||
@@ -37,15 +44,18 @@ async function audit(
|
||||
);
|
||||
}
|
||||
|
||||
function assertTempKey(key: string): void {
|
||||
if (!key.includes("/tmp/") || key.includes("/models/") || key.includes("..")) {
|
||||
throw new AdapterError("PATH_TRAVERSAL", "Chave temporária rejeitada.");
|
||||
}
|
||||
function processTimeoutMs(): number {
|
||||
const raw = process.env.SPREADSHEET_JOB_TIMEOUT_MS;
|
||||
if (!raw) return 120_000;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value >= 0 ? value : 120_000;
|
||||
}
|
||||
|
||||
export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<void> {
|
||||
const userLock = lockKey(ctx.userId);
|
||||
const written: string[] = [];
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = processTimeoutMs();
|
||||
try {
|
||||
const owns = await deps.coordinator.ownsLock(userLock, jobId);
|
||||
if (!owns) {
|
||||
@@ -120,8 +130,14 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
|
||||
).map(neutralizeRow);
|
||||
const headers = columns.map((column) => neutralizeSpreadsheetFormula(column.name));
|
||||
let done = 0;
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
throw new AdapterError("TIMEOUT", "O processamento excedeu o tempo limite.", 504);
|
||||
}
|
||||
for (const _row of transformed) {
|
||||
done += 1;
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
throw new AdapterError("TIMEOUT", "O processamento excedeu o tempo limite.", 504);
|
||||
}
|
||||
if (done % 5000 === 0) {
|
||||
await progress({
|
||||
phase: "processing",
|
||||
@@ -136,8 +152,8 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
|
||||
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);
|
||||
assertOwnedTempKey(ctx.workspaceId, csvKey);
|
||||
assertOwnedTempKey(ctx.workspaceId, xlsxKey);
|
||||
const csv = buildCsv(headers, transformed);
|
||||
const xlsx = await buildXlsx(headers, transformed);
|
||||
await deps.storage.put(csvKey, csv);
|
||||
@@ -180,28 +196,38 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
|
||||
} 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,
|
||||
};
|
||||
const httpStatus = isAdapterError(error) ? error.status : 500;
|
||||
let requeue = false;
|
||||
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'`, [
|
||||
const current = await query.query<{ progress: unknown }>(`select progress from public.spreadsheet_jobs where id = $1`, [jobId]);
|
||||
const prev = asJson<JobProgress | null>(current[0]?.progress, null);
|
||||
const attempt = (prev?.attempt ?? 0) + 1;
|
||||
requeue = shouldRequeue(code, httpStatus, attempt);
|
||||
const next: JobProgress = {
|
||||
phase: requeue ? "validating" : "failed",
|
||||
label: requeue ? "Nova tentativa" : "Falhou",
|
||||
percent: null,
|
||||
sourceIndex: 1,
|
||||
sourceCount: 1,
|
||||
sourceId,
|
||||
code,
|
||||
]);
|
||||
attempt,
|
||||
};
|
||||
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)],
|
||||
`update public.spreadsheet_job_sources set status = $2, error_code = $3 where id = $1 and status <> 'completed'`,
|
||||
[sourceId, requeue ? "mapped" : "failed", code],
|
||||
);
|
||||
await audit(query, ctx, "job.failed", jobId, "error", { error_code: code, source_id: sourceId });
|
||||
await query.query(
|
||||
`update public.spreadsheet_jobs
|
||||
set status = $2, error_code = $3, progress = $4::jsonb,
|
||||
finished_at = case when $2 = 'failed' then now() else finished_at end, updated_at = now()
|
||||
where id = $1`,
|
||||
[jobId, requeue ? "queued" : "failed", code, JSON.stringify(next)],
|
||||
);
|
||||
await audit(query, ctx, "job.failed", jobId, "error", { error_code: code, source_id: sourceId, attempt });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await deps.coordinator.setProgress(jobId, failed).catch(() => undefined);
|
||||
if (requeue) await deps.coordinator.enqueue(jobId).catch(() => undefined);
|
||||
if (!isAdapterError(error) || error.status >= 500) {
|
||||
deps.capture?.(error, { module: "spreadsheet-adapter", job_id: jobId, workspace_id: ctx.workspaceId, error_code: code });
|
||||
}
|
||||
@@ -220,6 +246,16 @@ const CONTENT_TYPE: Record<OutputFormat, string> = {
|
||||
|
||||
export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, format: OutputFormat) {
|
||||
assertUse(ctx);
|
||||
const flags = await deps.db.withUser(ctx.userId, (query) =>
|
||||
query.query<{ enabled: boolean }>(
|
||||
`select enabled from public.workspace_module_flags where workspace_id = $1 and module_key = 'spreadsheet_adapter'`,
|
||||
[ctx.workspaceId],
|
||||
),
|
||||
);
|
||||
const flag = flags[0]?.enabled as unknown;
|
||||
if (flag !== true && flag !== "t" && flag !== "true") {
|
||||
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
||||
}
|
||||
const file = await deps.db.withUser(ctx.userId, async (query) => {
|
||||
const rows = await query.query<{
|
||||
model_name: string;
|
||||
@@ -242,7 +278,7 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
||||
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);
|
||||
assertOwnedTempKey(ctx.workspaceId, file.output_csv_key);
|
||||
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
|
||||
const grid = parseCsvText(csv);
|
||||
const headers = grid[0] ?? [];
|
||||
@@ -251,29 +287,35 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
||||
}
|
||||
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);
|
||||
assertOwnedTempKey(ctx.workspaceId, 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'`,
|
||||
query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
|
||||
`select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key
|
||||
from public.spreadsheet_job_sources s
|
||||
join public.spreadsheet_jobs j on j.id = s.job_id
|
||||
where s.expires_at < $1::timestamptz and s.status <> 'expired'`,
|
||||
[(deps.now ?? (() => new Date()))().toISOString()],
|
||||
),
|
||||
);
|
||||
let removed = 0;
|
||||
for (const row of rows) {
|
||||
const prefix = `workspaces/${row.workspace_id}/tmp/`;
|
||||
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);
|
||||
if (!key || !key.startsWith(prefix) || key.includes("..") || key.includes("/models/")) continue;
|
||||
await deps.storage.delete(key).catch((error) => {
|
||||
deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" });
|
||||
});
|
||||
}
|
||||
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]),
|
||||
);
|
||||
removed += 1;
|
||||
}
|
||||
return rows.length;
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { jobRunsInline } from "@/config/env";
|
||||
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";
|
||||
@@ -26,11 +27,22 @@ function ttl(deps: AppDeps): number {
|
||||
return deps.ttlSeconds ?? tempTtlSeconds();
|
||||
}
|
||||
|
||||
function assertUse(ctx: RequestContext): void {
|
||||
async function assertUse(deps: AppDeps, ctx: RequestContext): Promise<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);
|
||||
const flags = await deps.db.withUser(ctx.userId, (query) =>
|
||||
query.query<{ enabled: boolean }>(
|
||||
`select enabled from public.workspace_module_flags
|
||||
where workspace_id = $1 and module_key = 'spreadsheet_adapter'`,
|
||||
[ctx.workspaceId],
|
||||
),
|
||||
);
|
||||
const flag = flags[0]?.enabled as unknown;
|
||||
if (flag !== true && flag !== "t" && flag !== "true") {
|
||||
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(
|
||||
@@ -60,12 +72,12 @@ export async function rejectWorkspaceForgery(deps: AppDeps, ctx: RequestContext,
|
||||
}
|
||||
|
||||
export async function listModels(deps: AppDeps, ctx: RequestContext): Promise<ModelDto[]> {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, 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);
|
||||
await assertUse(deps, 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`,
|
||||
@@ -76,7 +88,7 @@ export async function getSettings(deps: AppDeps, ctx: RequestContext): Promise<{
|
||||
}
|
||||
|
||||
export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNonAdminModelUpload: boolean): Promise<void> {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, ctx);
|
||||
if (!canManageSpreadsheetModels(ctx)) {
|
||||
throw new AdapterError("FORBIDDEN", "Somente owner ou admin alteram a configuração do módulo.", 403);
|
||||
}
|
||||
@@ -95,7 +107,7 @@ export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNo
|
||||
}
|
||||
|
||||
export async function createModel(deps: AppDeps, ctx: RequestContext, input: ModelCreateInput): Promise<ModelDto> {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, ctx);
|
||||
if (!canUploadSpreadsheetModel(ctx)) {
|
||||
throw new AdapterError("FORBIDDEN", "Seu perfil não pode cadastrar modelos neste workspace.", 403);
|
||||
}
|
||||
@@ -158,7 +170,7 @@ export async function createModel(deps: AppDeps, ctx: RequestContext, input: Mod
|
||||
}
|
||||
|
||||
export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: string): Promise<void> {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, ctx);
|
||||
if (!canManageSpreadsheetModels(ctx)) {
|
||||
throw new AdapterError("FORBIDDEN", "Somente owner ou admin podem excluir modelos.", 403);
|
||||
}
|
||||
@@ -180,7 +192,7 @@ export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: s
|
||||
}
|
||||
|
||||
export async function createJob(deps: AppDeps, ctx: RequestContext, modelId: string, idempotencyKey?: string | null): Promise<JobDto> {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, ctx);
|
||||
const jobId = await deps.db.withUser(ctx.userId, async (query) => {
|
||||
if (idempotencyKey) {
|
||||
const existing = await query.query<{ id: string }>(
|
||||
@@ -219,7 +231,7 @@ async function ensureMutableJob(query: Query, ctx: RequestContext, jobId: string
|
||||
}
|
||||
|
||||
export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: string, file: UploadFile, sheet?: string | null, headerRow = 1) {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, ctx);
|
||||
const filename = sanitizeDisplayName(file.filename);
|
||||
let parsed: Awaited<ReturnType<typeof readSpreadsheet>> | null = null;
|
||||
let sheetRequired: string[] | null = null;
|
||||
@@ -274,6 +286,10 @@ export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: strin
|
||||
`update public.spreadsheet_jobs set source_count = source_count + 1, updated_at = now() where id = $1`,
|
||||
[jobId],
|
||||
);
|
||||
await audit(query, ctx, "source.uploaded", "job_source", sourceId, status === "invalid" ? "error" : "success", {
|
||||
size_bytes: file.bytes.length,
|
||||
row_count: parsed?.grid.rows.length ?? 0,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.storage.delete(storageKey);
|
||||
@@ -296,7 +312,7 @@ export async function updateSource(
|
||||
sourceId: string,
|
||||
input: { sheet?: string | null; headerRow?: number },
|
||||
) {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, 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 }>(
|
||||
@@ -337,7 +353,7 @@ export async function updateSource(
|
||||
}
|
||||
|
||||
export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<JobDto> {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, 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 }>(
|
||||
@@ -355,7 +371,7 @@ export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: st
|
||||
}
|
||||
|
||||
export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, mappings: MappingInput[]) {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, ctx);
|
||||
await deps.db.withUser(ctx.userId, async (query) => {
|
||||
await ensureMutableJob(query, ctx, jobId);
|
||||
const job = await readJob(query, deps.coordinator, ctx, jobId);
|
||||
@@ -387,12 +403,16 @@ export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: st
|
||||
[jobId],
|
||||
);
|
||||
}
|
||||
await audit(query, ctx, "mapping.updated", "job_source", sourceId, "success", {
|
||||
mapping_count: mappings.length,
|
||||
complete: result.ok,
|
||||
});
|
||||
}).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);
|
||||
await assertUse(deps, 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);
|
||||
@@ -427,13 +447,14 @@ export async function previewSource(deps: AppDeps, ctx: RequestContext, jobId: s
|
||||
}
|
||||
|
||||
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";
|
||||
if (process.env.NODE_ENV === "production" && process.env.ADAPTER_JOB_MODE === "inline") {
|
||||
throw new AdapterError("INVALID_SETTING", "Processamento inline não é permitido em produção.", 500);
|
||||
}
|
||||
return jobRunsInline();
|
||||
}
|
||||
|
||||
export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, idempotencyKey: string): Promise<JobDto> {
|
||||
assertUse(ctx);
|
||||
await assertUse(deps, ctx);
|
||||
if (!idempotencyKey || idempotencyKey.length > 120) {
|
||||
throw new AdapterError("IDEMPOTENCY_REQUIRED", "Informe uma chave de idempotência para processar.", 400);
|
||||
}
|
||||
@@ -501,12 +522,13 @@ export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: st
|
||||
);
|
||||
await audit(query, ctx, "job.started", "job", jobId, "success", { source_id: sourceId });
|
||||
});
|
||||
if (!inlineMode()) await deps.coordinator.enqueue(jobId).catch(() => undefined);
|
||||
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);
|
||||
await assertUse(deps, ctx);
|
||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user