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

+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 };