import type { AppConfig } from "../config.js"; /** * Adapter zapisu do Base.com (BaseLinker `connector.php`). * Token jest wyłącznie w konfiguracji serwera — nigdy w DTO, podglądach, * logach ani payloadach pozycji outboxu. Bez tokenu działa * `disabledBaseAdapter`, który odrzuca każdą próbę wysyłki. */ export interface BaseAdapter { /** true tylko dla skonfigurowanego adaptera live. */ readonly live: boolean; readonly name: string; /** addInventoryProduct → product_id z odpowiedzi. */ addInventoryProduct(params: Record): Promise<{ productId: number; warnings: string[] }>; /** Read-back po zapisie — getInventoryProductsData dla wskazanych ID. */ getInventoryProductsData(inventoryId: number, productIds: number[]): Promise>>; /** Pojednanie po niepewnym wysłaniu — getInventoryProductsList + filtr SKU. */ findProductIdBySku(inventoryId: number, sku: string): Promise; /** Test połączenia/uprawnień tokenu — lekka metoda getInventories. */ getInventories(): Promise>; } /** Wysyłka zablokowana konfiguracją — nie jest błędem sieci ani retryable. */ export class ExportBlocked extends Error {} /** * Odpowiedź po wysłaniu jest nieznana (timeout/reset po wysłaniu żądania). * Pozycja przechodzi w 'unconfirmed' i jest pojednywana po SKU — * nigdy ślepe ponowne tworzenie produktu. */ export class ExportUncertain extends Error {} /** Odrzucone przez API (status != SUCCESS) — treść bez tokenu. */ export class ExportRejected extends Error { constructor(message: string, readonly apiMessage?: string) { super(message); } } export const disabledBaseAdapter: BaseAdapter = { live: false, name: "disabled", addInventoryProduct: () => Promise.reject(new ExportBlocked("Adapter Base nie jest skonfigurowany (brak BASELINKER_API_TOKEN). Żądanie nie opuściło serwera.")), getInventoryProductsData: () => Promise.reject(new ExportBlocked("Adapter Base nie jest skonfigurowany (brak BASELINKER_API_TOKEN).")), findProductIdBySku: () => Promise.reject(new ExportBlocked("Adapter Base nie jest skonfigurowany (brak BASELINKER_API_TOKEN).")), getInventories: () => Promise.reject(new ExportBlocked("Adapter Base nie jest skonfigurowany (brak BASELINKER_API_TOKEN).")), }; const REQUEST_TIMEOUT_MS = 30_000; type TokenSource = () => string | null; async function callApi(cfg: AppConfig, getToken: TokenSource, method: string, parameters: Record): Promise> { const token = getToken() ?? cfg.baseApiToken; if (!token) throw new ExportBlocked("Adapter Base nie jest skonfigurowany (brak BASELINKER_API_TOKEN). Żądanie nie opuściło serwera."); const body = new URLSearchParams({ token, method, parameters: JSON.stringify(parameters) }); let res: Response; try { res = await fetch(cfg.baseApiUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (err) { if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) { throw new ExportUncertain(`Brak odpowiedzi Base po wysłaniu ${method} — wynik wymaga pojednania, nie ponownego utworzenia.`); } throw new ExportUncertain(`Błąd połączenia z Base (${method}): ${err instanceof Error ? err.message : String(err)}`); } if (!res.ok) throw new ExportUncertain(`Base odpowiedział HTTP ${res.status} dla ${method} — wynik wymaga pojednania.`); const data = await res.json().catch(() => null) as Record | null; if (!data || typeof data.status !== "string") throw new ExportUncertain(`Nieczytelna odpowiedź Base dla ${method} — wynik wymaga pojednania.`); if (data.status !== "SUCCESS") { const message = typeof data.error_message === "string" ? data.error_message : `status=${data.status}`; throw new ExportRejected(`Base odrzucił ${method}: ${message}`, message); } return data; } /** * Adapter HTTP z dynamicznym tokenem: `getToken` (np. ustawienie z panelu * w app_settings) ma pierwszeństwo przed `cfg.baseApiToken` z env. Dzięki * temu zapis tokena z Ustawień działa bez restartu; brak tokenu wciąż * odrzuca żądanie jako ExportBlocked, a `live` oddaje aktualny stan. */ export function createHttpBaseAdapter(cfg: AppConfig, getToken: TokenSource = () => null): BaseAdapter { const hasToken = () => Boolean(getToken() ?? cfg.baseApiToken); return { get live() { return hasToken(); }, name: "baselinker-http", async addInventoryProduct(params) { const data = await callApi(cfg, getToken, "addInventoryProduct", params); const productId = Number(data.product_id); if (!Number.isInteger(productId) || productId <= 0) { throw new ExportUncertain("Base nie zwróciło product_id — wynik wymaga pojednania."); } const warnings = data.warnings && typeof data.warnings === "object" ? Object.values((data.warnings as { parameters?: Record }).parameters ?? {}).map(String) : []; return { productId, warnings }; }, async getInventoryProductsData(inventoryId, productIds) { const data = await callApi(cfg, getToken, "getInventoryProductsData", { inventory_id: inventoryId, products: productIds }); const products = data.products && typeof data.products === "object" ? data.products : {}; // Base zwraca produkty jako mapę { "": {…} } — klucz jest // jedynym identyfikatorem, obiekt wewnętrzny nie ma pola id/product_id. // Bez przepięcia klucza read-back nie potrafi dopasować rekordu. if (Array.isArray(products)) return products as Array>; return Object.entries(products as Record>) .map(([id, row]) => ({ ...row, product_id: Number(id) })); }, async findProductIdBySku(inventoryId, sku) { const data = await callApi(cfg, getToken, "getInventoryProductsList", { inventory_id: inventoryId, filter_sku: sku }); const products = data.products && typeof data.products === "object" ? data.products : {}; for (const [id, row] of Object.entries(products as Record>)) { if (row.sku === sku) return Number(id); } return null; }, async getInventories() { const data = await callApi(cfg, getToken, "getInventories", {}); const inventories = data.inventories && typeof data.inventories === "object" ? data.inventories : {}; return Object.entries(inventories as Record).map(([id, row]) => ({ inventoryId: Number(id), name: typeof row?.name === "string" ? row.name : "", })); }, }; }