UNPKG

@horizon-integrations/jetimob-crm

Version:

Adapter de integração com o CRM Jetimob — padrão Airbyte (@horizon-js/integrations-core)

1,402 lines (1,387 loc) 52.3 kB
"use strict"; // src/manifest.ts import { SyncMode } from "@horizon-js/integrations-core"; // src/spec.ts var jetimobSpec = { name: "jetimob", title: "Jetimob", documentationUrl: "https://api.jetimob.com", configSchema: { type: "object", required: ["webserviceKey"], properties: { webserviceKey: { type: "string", title: "Webservice Key", description: "Chave webservice da conta Jetimob. Vai no path da URL (/webservice/{key}/imoveis). Obtida no painel Jetimob da imobili\xE1ria.", airbyteSecret: true } } } }; // package.json var package_default = { name: "@horizon-integrations/jetimob-crm", version: "3.1.4", description: "Adapter de integra\xE7\xE3o com o CRM Jetimob \u2014 padr\xE3o Airbyte (@horizon-js/integrations-core)", main: "dist/index.js", module: "dist/index.mjs", types: "dist/index.d.ts", files: [ "dist", "docs", "examples" ], scripts: { build: "tsup", dev: "tsup --watch", test: "vitest", "test:coverage": "vitest --coverage", typecheck: "tsc --noEmit" }, keywords: [ "imobiliario", "jetimob", "crm", "integration", "airbyte", "converter", "horizon" ], author: "Horizon Modules", license: "MIT", dependencies: { "@horizon-js/integrations-core": "^0.5.1", "@horizon-js/property-domain-schema": "^3.17.0", zod: "^3.25.67" }, devDependencies: { "@types/node": "^20.0.0", tsup: "^8.0.0", tsx: "^4.0.0", typescript: "^5.0.0", vitest: "^1.0.0" }, exports: { ".": { types: "./dist/index.d.ts", import: "./dist/index.mjs", require: "./dist/index.js", default: "./dist/index.js" } }, repository: { type: "git", url: "https://github.com/imobland/horizon-integrations.git", directory: "horizon-integrations/jetimob" }, publishConfig: { access: "public" } }; // src/manifest.ts var jetimobManifest = { metadata: { name: "jetimob", displayName: "Jetimob", type: "crm", vendor: { company: "Jetimob", website: "https://jetimob.com", docsUrl: "https://api.jetimob.com" }, supportTier: "community", adapterVersion: package_default.version }, spec: jetimobSpec, capabilities: { // API v4 pagina de verdade — ?page=&pageSize=, resposta traz totalPages. pagination: true, // ?start/&end (Unix timestamp), ?codigos, ?id — todos testados e funcionando. filters: true, // Cada imóvel tem data_update / updated_at — melhor que SI9 (que não tem). nativeTimestamp: true, // ?start={unix} filtra por data de atualização server-side. Testado: funciona. incrementalByTimestamp: true, // Busca individual via ?id={id_imovel} (ou ?codigos={codigo}). individualFetch: true, // /imoveis-ativos devolve todos os id_imovel ativos numa tacada (~0.3s, ~9KB). lightweightListing: true, webhooks: false, rateLimit: null, customFields: false, writes: { // API de leads existe (par chave-pública/privada) — writer ainda não implementado. sendLead: false, createProperty: false, updateProperty: false, deleteProperty: false }, softDelete: { enabled: false } }, streams: [ { name: "properties", displayName: "Im\xF3veis", resourceType: "property", supportedSyncModes: [SyncMode.FULL_REFRESH, SyncMode.INCREMENTAL], cursorField: "source_updated_at", primaryKey: ["reference"], hasListing: true, hasIndividualFetch: true } ], writers: [], knownIssues: [ { id: "JET-001", severity: "medium", summary: "Doc interna antiga afirmava que GET /imoveis retorna array direto \u2014 na verdade retorna envelope { total, page, pageSize, totalPages, data: [...] }", workaround: "JetimobPropertyStream.parseResponse() l\xEA o envelope `data`. Doc API_JETIMOB.md corrigida com base na doc oficial (docs.jetimob.com).", category: "documentation", reportedToVendor: false, discoveredAt: "2026-05-14" }, { id: "JET-002", severity: "low", summary: "/imoveis-ativos e /imoveis usam identificadores diferentes: /imoveis-ativos devolve `id_imovel`; ?codigos= filtra por `codigo` (que N\xC3O \xE9 o id). ?id= filtra por `id_imovel`.", workaround: "getListing() usa /imoveis-ativos \u2192 ListingEntry.ref = id_imovel. fetchByRef() tenta ?id= primeiro e cai pra ?codigos= como fallback, aceitando os dois. O converter exp\xF5e `id_imovel` como campo extra pra correla\xE7\xE3o (reference continua sendo o `codigo`).", category: "documentation", reportedToVendor: false, discoveredAt: "2026-05-14" }, { id: "JET-003", severity: "low", summary: "Medidas de terreno (terreno_total, terreno_frente, etc.) \xE0s vezes v\xEAm como string em vez de number", workaround: "Schema raw (jetimob-property-schema.zod.ts) aceita string|number nesses campos; o conversor faz Number().", category: "api_bug", reportedToVendor: false, discoveredAt: "2026-05-14" }, { id: "JET-004", severity: "low", summary: "API retorna null (n\xE3o undefined) em praticamente todos os campos opcionais", workaround: "Schema raw usa .nullish() em todos os campos opcionais (aceita null | undefined).", category: "api_bug", reportedToVendor: false, discoveredAt: "2026-05-14" }, { id: "JET-005", severity: "low", summary: "/imoveis n\xE3o tem par\xE2metro de sele\xE7\xE3o de campos (testado: fields, campos, field, select, only \u2014 todos ignorados). Toda resposta de /imoveis traz os ~96 campos completos (~9.5KB por im\xF3vel).", workaround: "Pra listagem leve usar /imoveis-ativos (s\xF3 id_imovel). Pra delta eficiente usar ?start={unix} (incremental server-side). N\xE3o h\xE1 como pedir menos campos do /imoveis.", category: "missing_feature", reportedToVendor: false, discoveredAt: "2026-05-14" }, { id: "JET-006", severity: "low", summary: "API Jetimob devolve 502 Bad Gateway transiente espor\xE1dico (nginx). Em pagina\xE7\xE3o de muitas p\xE1ginas, um 502 isolado derrubaria o sync inteiro.", workaround: "JetimobPropertyStream usa retryOptions (httpRetryPolicy, 4 tentativas, backoff exponencial). 5xx/429/timeout s\xE3o re-tentados automaticamente.", category: "performance", reportedToVendor: false, discoveredAt: "2026-05-14" }, { id: "JET-007", severity: "high", summary: "`generateApproximateCoords` usava Math.random() \u2192 o campo geo_aproximado mudava a cada convers\xE3o \u2192 sync_hash inst\xE1vel \u2192 o delta re-upsertava ~60% dos im\xF3veis (os com geoposicionamento_visivel=2) em TODO sync.", workaround: "Corrigido em 3.1.0: o offset agora \xE9 determin\xEDstico (FNV-1a hash do codigo/id do im\xF3vel). Mesmo im\xF3vel \u2192 mesmo geo_aproximado \u2192 sync_hash est\xE1vel.", category: "breaking_change", reportedToVendor: false, discoveredAt: "2026-05-14", resolvedAt: "2026-05-14" } ], client: { packageName: "@horizon-integrations/jetimob-crm-client", className: "JetimobClient", apiVersion: "v1", endpoints: { properties: "/api/providers/jetimob/v1/properties", "properties.index": "/api/providers/jetimob/v1/properties/index", "properties.byRef": "/api/providers/jetimob/v1/properties/{ref}" } } }; // src/streams/JetimobPropertyStream.ts import { HttpStream, SyncMode as SyncMode2, SyncError, fetchWithTimeout, retry, httpRetryPolicy } from "@horizon-js/integrations-core"; // src/services/PropertyConverter/index.ts import { hashObject } from "@horizon-js/integrations-core"; // src/schemas/jetimob-property-schema.zod.ts import { z } from "zod"; var ruralSchema = z.object({ atividade_rural: z.string().nullish(), rural_sedes: z.union([z.string(), z.number()]).nullish(), rural_area_aravel: z.number().nullish(), medida_total_area_aravel: z.string().nullish() }).nullish(); var imagemSchema = z.object({ link: z.string(), titulo: z.string().nullish(), link_thumb: z.string().nullish() }).passthrough(); var JetimobPropertySchemaSchema = z.object({ codigo: z.string(), titulo_anuncio: z.string().max(200).nullish(), observacoes: z.string().nullish(), descricao_anuncio: z.string().nullish(), contrato: z.string().nullish(), tipo: z.string().nullish(), subtipo: z.string().nullish(), status: z.string().nullish(), situacao: z.string().nullish(), destaque: z.string().nullish(), destaque_fim: z.string().nullish(), exclusividade: z.union([z.boolean(), z.number()]).nullish(), financiavel: z.union([z.boolean(), z.number()]).nullish(), permuta: z.union([z.boolean(), z.number()]).nullish(), seguro_fianca: z.union([z.boolean(), z.number()]).nullish(), mobiliado: z.union([z.boolean(), z.number()]).nullish(), // Valores monetários valor_venda: z.number().nullish(), valor_venda_visivel: z.union([z.boolean(), z.number()]).nullish(), valor_locacao: z.number().nullish(), valor_locacao_visivel: z.union([z.boolean(), z.number()]).nullish(), valor_temporada: z.number().nullish(), valor_temporada_visivel: z.union([z.boolean(), z.number()]).nullish(), valor_condominio: z.number().nullish(), valor_condominio_visivel: z.union([z.boolean(), z.number()]).nullish(), valor_iptu: z.number().nullish(), valor_iptu_visivel: z.union([z.boolean(), z.number()]).nullish(), valor_seguro_incendio: z.number().nullish(), valor_taxa_limpeza: z.number().nullish(), // Características físicas dormitorios: z.number().nullish(), suites: z.number().nullish(), banheiros: z.number().nullish(), garagens: z.number().nullish(), area_total: z.number().nullish(), area_privativa: z.number().nullish(), area_util: z.number().nullish(), andar: z.number().nullish(), andar_visivel: z.union([z.boolean(), z.number()]).nullish(), // Endereço endereco_cep: z.string().max(9).nullish(), endereco_estado: z.string().nullish(), endereco_estado_visivel: z.union([z.boolean(), z.number()]).nullish(), endereco_cidade: z.string().nullish(), endereco_cidade_visivel: z.union([z.boolean(), z.number()]).nullish(), endereco_bairro: z.string().nullish(), endereco_bairro_visivel: z.union([z.boolean(), z.number()]).nullish(), endereco_zona: z.string().nullish(), endereco_logradouro: z.string().nullish(), endereco_logradouro_visivel: z.union([z.boolean(), z.number()]).nullish(), endereco_numero: z.union([z.string(), z.number()]).nullish(), endereco_numero_visivel: z.union([z.boolean(), z.number()]).nullish(), endereco_complemento: z.string().nullish(), endereco_complemento_visivel: z.union([z.boolean(), z.number()]).nullish(), endereco_referencia: z.string().nullish(), endereco_referencia_visivel: z.union([z.boolean(), z.number()]).nullish(), // Coordenadas e geolocalização latitude: z.union([z.number(), z.string()]).nullish(), longitude: z.union([z.number(), z.string()]).nullish(), geoposicionamento_visivel: z.number().nullish(), // IDs internos id_imovel: z.number().nullish(), id_estado: z.number().nullish(), id_cidade: z.number().nullish(), id_bairro: z.number().nullish(), id_condominio: z.number().nullish(), id_subcondominio: z.number().nullish(), id_corretor: z.number().nullish(), // Campos específicos distancia_mar: z.number().nullish(), tipo_construcao: z.string().nullish(), tipo_piso: z.string().nullish(), entrega_ano: z.union([z.number(), z.string()]).nullish(), entrega_mes: z.union([z.number(), z.string()]).nullish(), posicao: z.string().nullish(), posicao_solar: z.string().nullish(), tags: z.string().nullish(), // Terreno — API às vezes envia medidas como string; converter faz Number() terreno_frente: z.union([z.number(), z.string()]).nullish(), terreno_fundos: z.union([z.number(), z.string()]).nullish(), terreno_esquerdo: z.union([z.number(), z.string()]).nullish(), terreno_direita: z.union([z.number(), z.string()]).nullish(), terreno_total: z.union([z.number(), z.string()]).nullish(), medida_terreno_total: z.string().nullish(), // Pessoas e períodos numero_pessoas: z.number().nullish(), periodicidade_iptu: z.string().nullish(), // Condomínio condominio_fechado: z.union([z.boolean(), z.number()]).nullish(), condominio_nome: z.string().nullish(), condominio_tipo: z.string().nullish(), condominio_comodidades: z.union([z.string(), z.array(z.string())]).nullish(), // Características imovel_comodidades: z.string().nullish(), // Rural rural: ruralSchema, // Calendário calendario_temporada: z.union([z.string(), z.array(z.any())]).nullish(), // SEO meta_description: z.string().nullish(), // Mídia imagens: z.array(imagemSchema).nullish(), videos: z.array(z.any()).nullish(), plantas: z.array(z.any()).nullish(), tour360: z.array(z.any()).nullish(), // Configurações medida: z.string().nullish(), // Datas data_cadastro: z.string().nullish(), data_update: z.string().nullish(), data_atualizacao: z.string().nullish(), updated_at: z.string().nullish() }).passthrough(); var validateJetimobPropertySchema = (data) => { return JetimobPropertySchemaSchema.parse(data); }; var safeValidateJetimobPropertySchema = (data) => { return JetimobPropertySchemaSchema.safeParse(data); }; // src/services/PropertyConverter/convertBaseFields.ts function convertToISO(dateStr) { if (!dateStr) return null; if (dateStr.includes("T")) return dateStr; return dateStr.replace(" ", "T") + ".000Z"; } function hashSeed(seed) { let h = 2166136261; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function generateApproximateCoords(lat, lng, seed) { const offsetDegrees = 500 / 111e3; const h = hashSeed(seed); const angle = h % 3600 / 3600 * 2 * Math.PI; const distance = (h >>> 12) % 1e3 / 1e3 * offsetDegrees; return { lat: lat + Math.sin(angle) * distance, lng: lng + Math.cos(angle) * distance }; } function convertBaseFields(imovel) { const result = {}; result.source_key = "jetimob-api-imoveis"; result.reference = imovel.codigo; result.title = imovel.titulo_anuncio || "Sem t\xEDtulo"; result.description = imovel.observacoes || "Sem descri\xE7\xE3o"; result.source_updated_at = convertToISO(imovel.data_update ?? imovel.updated_at ?? void 0); if (imovel.meta_description) result.seo_description = imovel.meta_description; result.currency = "BRL"; result.unit_area = imovel.medida === "m\xB2" || imovel.medida === "m2" ? "m2" : "m2"; result.unit_distance = "meters"; if (imovel.contrato) { const partes = imovel.contrato.split(",").map((s) => s.trim().toLowerCase()); const operacoes = []; for (const parte of partes) { if (parte === "compra" || parte === "venda") operacoes.push("venda"); else if (parte === "loca\xE7\xE3o" || parte === "locacao" || parte === "aluguel") operacoes.push("locacao"); else if (parte === "temporada") operacoes.push("temporada"); } if (operacoes.length > 0) result.operacao = operacoes; } if (imovel.valor_venda && imovel.valor_venda_visivel) { const valor = Number(imovel.valor_venda); if (!isNaN(valor) && valor > 0) result.valor_venda = valor; } if (imovel.valor_locacao && imovel.valor_locacao_visivel) { const valor = Number(imovel.valor_locacao); if (!isNaN(valor) && valor > 0) result.valor_locacao = valor; } if (imovel.valor_temporada && imovel.valor_temporada_visivel) { const valor = Number(imovel.valor_temporada); if (!isNaN(valor) && valor > 0) result.valor_diaria = valor; } if (imovel.valor_condominio && imovel.valor_condominio_visivel) { const valor = Number(imovel.valor_condominio); if (!isNaN(valor) && valor >= 0) result.valor_condominio = valor; } if (imovel.valor_iptu && imovel.valor_iptu_visivel) { const valor = Number(imovel.valor_iptu); if (!isNaN(valor) && valor >= 0) result.valor_iptu = valor; } if (imovel.area_total) { const area = Number(imovel.area_total); if (!isNaN(area) && area > 0) result.area_total = area; } if (imovel.area_privativa) { const area = Number(imovel.area_privativa); if (!isNaN(area) && area > 0) result.area_privativa = area; } if (imovel.area_util) { const area = Number(imovel.area_util); if (!isNaN(area) && area > 0) result.area_util = area; } if (imovel.dormitorios) result.dormitorios = Number(imovel.dormitorios); if (imovel.suites) result.suites = Number(imovel.suites); if (imovel.banheiros) result.banheiros = Number(imovel.banheiros); if (imovel.garagens) result.vagas_garagem = Number(imovel.garagens); if (imovel.subtipo) result.tipo = imovel.subtipo; if (imovel.endereco_cep) { const cepLimpo = imovel.endereco_cep.replace(/\D/g, ""); if (cepLimpo.length === 8) { result.endereco_cep = `${cepLimpo.slice(0, 5)}-${cepLimpo.slice(5)}`; } else { result.endereco_cep = imovel.endereco_cep; } } if (imovel.endereco_estado && imovel.endereco_estado_visivel) result.endereco_estado = imovel.endereco_estado; if (imovel.endereco_cidade && imovel.endereco_cidade_visivel) result.endereco_cidade = imovel.endereco_cidade; if (imovel.endereco_bairro && imovel.endereco_bairro_visivel) result.endereco_bairro = imovel.endereco_bairro; if (imovel.endereco_logradouro && imovel.endereco_logradouro_visivel) result.endereco_logradouro = imovel.endereco_logradouro; if (imovel.endereco_numero && imovel.endereco_numero_visivel) result.endereco_numero = String(imovel.endereco_numero).slice(0, 20); if (imovel.endereco_complemento && imovel.endereco_complemento_visivel) result.endereco_complemento = imovel.endereco_complemento; if (imovel.endereco_referencia && imovel.endereco_referencia_visivel) result.endereco_referencia = imovel.endereco_referencia; if (imovel.endereco_zona) result.endereco_zona = imovel.endereco_zona; if (imovel.latitude && imovel.longitude) { const lat = Number(imovel.latitude); const lng = Number(imovel.longitude); if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) { const geoVisivel = Number(imovel.geoposicionamento_visivel); if (geoVisivel === 1) { result.lat = lat; result.lng = lng; } else if (geoVisivel === 2) { result.geo_aproximado = generateApproximateCoords( lat, lng, String(imovel.codigo ?? imovel.id_imovel ?? `${lat},${lng}`) ); } } } if (imovel.destaque && imovel.destaque !== "Sem destaque") { if (imovel.destaque === "Destaque" || imovel.destaque === "true" || String(imovel.destaque) === "true") { result.destaque = true; } } if (imovel.id_corretor) { result.corretor_key = String(imovel.id_corretor); } if (imovel.id_condominio) { result.condominio_key = String(imovel.id_condominio); } if (imovel.condominio_nome) { result.condominio_nome = imovel.condominio_nome; } if (imovel.tags) { result.tags = imovel.tags.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0); } if (imovel.numero_pessoas) { const pessoas = Number(imovel.numero_pessoas); if (!isNaN(pessoas) && pessoas > 0) result.numero_pessoas = pessoas; } if (imovel.imagens && imovel.imagens.length > 0) { result.images = imovel.imagens.map((imagem, index) => ({ full: imagem.link, md: imagem.link_thumb || imagem.link, sm: imagem.link_thumb || imagem.link, cover: index === 0 // primeira imagem como cover })); const imagemPrincipal = imovel.imagens[0]; if (imagemPrincipal) { result.main_image = { full: imagemPrincipal.link, md: imagemPrincipal.link_thumb || imagemPrincipal.link, sm: imagemPrincipal.link_thumb || imagemPrincipal.link }; } } else { result.images = []; } if (imovel.videos && imovel.videos.length > 0) { result.videos = imovel.videos.map((video) => ({ url: video.link ? `https://youtube.com/watch?v=${video.link}` : null })).filter((v) => Boolean(v.url)); } else { result.videos = []; } if (imovel.tour360 && imovel.tour360.length > 0) { result.virtual_tours = imovel.tour360.map((tour) => { const embedUrl = typeof tour === "string" ? tour : tour.link || tour.url || ""; return { embed_url: embedUrl }; }).filter((t) => t.embed_url); } else { result.virtual_tours = []; } return result; } // src/services/PropertyConverter/convertExtendedFields.ts function convertExtendedFields(imovel) { const result = {}; if (imovel.valor_seguro_incendio) { const valor = Number(imovel.valor_seguro_incendio); if (!isNaN(valor) && valor >= 0) result.valor_seguro_incendio = valor; } if (imovel.valor_taxa_limpeza) { const valor = Number(imovel.valor_taxa_limpeza); if (!isNaN(valor) && valor >= 0) result.valor_taxa_limpeza = valor; } if (imovel.andar && imovel.andar_visivel) result.andar = Number(imovel.andar); if (String(imovel.financiavel) === "1" || String(imovel.financiavel) === "true") { result.financiavel = true; } if (String(imovel.exclusividade) === "true" || String(imovel.exclusividade) === "1") { result.exclusividade = true; } if (String(imovel.permuta) === "true" || String(imovel.permuta) === "1") { result.permuta = true; } if (String(imovel.seguro_fianca) === "true" || String(imovel.seguro_fianca) === "1") { result.seguro_fianca = true; } if (String(imovel.mobiliado) === "1" || String(imovel.mobiliado) === "true") { result.mobiliado = true; } if (imovel.imovel_comodidades) { const comodidadesArray = String(imovel.imovel_comodidades).split(",").map((c) => c.trim()).filter((c) => c.length > 0); if (comodidadesArray.length > 0) { result.caracteristicas = comodidadesArray; } } if (imovel.plantas && imovel.plantas.length > 0) { result.plantas = imovel.plantas.map((planta) => { return typeof planta === "string" ? planta : planta.link || ""; }).filter(Boolean); } if (imovel.id_imovel != null) result.id_imovel = String(imovel.id_imovel); if (imovel.tipo) result.finalidade = imovel.tipo; if (imovel.situacao) result.situacao = imovel.situacao; if (imovel.distancia_mar) result.distancia_mar = Number(imovel.distancia_mar); if (imovel.status) result.status = imovel.status; if (imovel.posicao) result.posicao = imovel.posicao; if (imovel.posicao_solar) { const arr = String(imovel.posicao_solar).split(",").map((p) => p.trim()).filter((p) => p.length > 0); if (arr.length > 0) result.posicao_solar = arr; } if (imovel.calendario_temporada) { result.calendario_temporada = Array.isArray(imovel.calendario_temporada) ? imovel.calendario_temporada : [imovel.calendario_temporada]; } if (imovel.condominio_comodidades) { const comodidadesCondArray = String(imovel.condominio_comodidades).split(",").map((c) => c.trim()).filter((c) => c.length > 0); if (comodidadesCondArray.length > 0) { result.condominio_comodidades = comodidadesCondArray; } } if (imovel.condominio_tipo) result.condominio_tipo = imovel.condominio_tipo; if (imovel.id_subcondominio) result.subcondominio_id = String(imovel.id_subcondominio); if (String(imovel.condominio_fechado) === "1" || String(imovel.condominio_fechado) === "true") { result.condominio_fechado = true; } if (imovel.entrega_ano) { const ano = Number(imovel.entrega_ano); if (!isNaN(ano) && ano > 0) result.entrega_ano = ano; } if (imovel.entrega_mes) { const mes = Number(imovel.entrega_mes); if (!isNaN(mes) && mes > 0 && mes <= 12) result.entrega_mes = mes; } if (imovel.tipo_construcao) result.tipo_construcao = imovel.tipo_construcao; if (imovel.tipo_piso) { const arr = String(imovel.tipo_piso).split(",").map((p) => p.trim()).filter((p) => p.length > 0); if (arr.length > 0) result.tipo_piso = arr; } if (imovel.periodicidade_iptu) result.periodicidade_iptu = imovel.periodicidade_iptu; if (imovel.terreno_frente) { const medida = Number(imovel.terreno_frente); if (!isNaN(medida) && medida > 0) result.terreno_frente = medida; } if (imovel.terreno_fundos) { const medida = Number(imovel.terreno_fundos); if (!isNaN(medida) && medida > 0) result.terreno_fundos = medida; } if (imovel.terreno_esquerdo) { const medida = Number(imovel.terreno_esquerdo); if (!isNaN(medida) && medida > 0) result.terreno_esquerdo = medida; } if (imovel.terreno_direita) { const medida = Number(imovel.terreno_direita); if (!isNaN(medida) && medida > 0) result.terreno_direita = medida; } if (imovel.terreno_total) { const area = Number(imovel.terreno_total); if (!isNaN(area) && area > 0) result.terreno_total = area; } if (imovel.rural?.rural_area_aravel) { const area = Number(imovel.rural.rural_area_aravel); if (!isNaN(area) && area > 0) result.rural_area_aravel = area; } if (imovel.rural?.rural_sedes) { const sedes = Number(imovel.rural.rural_sedes); if (!isNaN(sedes) && sedes > 0) result.rural_sedes = sedes; } if (imovel.rural?.atividade_rural) result.rural_atividade = imovel.rural.atividade_rural; return result; } // src/services/PropertyConverter/index.ts function convertJetimobPropertyToHorizon(rawImovel) { const validation = safeValidateJetimobPropertySchema(rawImovel); if (!validation.success) { const errors = validation.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); throw new Error(`Valida\xE7\xE3o Jetimob falhou: ${errors}`); } const imovel = validation.data; const baseFields = convertBaseFields(imovel); const extendedFields = convertExtendedFields(imovel); const payload = { ...baseFields, ...extendedFields }; return { ...payload, sync_hash: hashObject(payload) }; } // src/streams/JetimobPropertyStream.ts var DEFAULT_BASE_URL = "https://api.jetimob.com"; var DEFAULT_PAGE_SIZE = 200; var JetimobPropertyStream = class extends HttpStream { constructor(credentials, options = {}) { super(); this.name = "properties"; this.supportedSyncModes = [SyncMode2.FULL_REFRESH, SyncMode2.INCREMENTAL]; this.cursorField = "source_updated_at"; this.primaryKey = ["reference"]; this.webserviceKey = credentials.webserviceKey; this.base = options.baseUrl ?? DEFAULT_BASE_URL; this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4; this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; this.retryOptions = httpRetryPolicy({ maxAttempts: 4 }); } // ======================================== // HOOKS DO HttpStream // ======================================== /** A `webserviceKey` é a auth — vai no path da URL base. */ get urlBase() { return `${this.base}/webservice/${this.webserviceKey}/`; } path() { return "imoveis"; } requestParams(context) { const page = context.nextPageToken != null ? Number(context.nextPageToken) : 1; const params = { v: "4", page: String(page), pageSize: String(this.pageSize) }; const cursor = context.state?.cursor; if (cursor != null) { const unix = isoToUnix(String(cursor)); if (unix != null) params.start = String(unix); } return params; } /** API v4 retorna `{ total, page, pageSize, totalPages, data: [...] }` (JET-001). */ parseResponse(body) { if (Array.isArray(body)) return body; const env = body ?? {}; return Array.isArray(env.data) ? env.data : []; } /** Próxima página via metadados do envelope. `undefined` encerra o loop. */ nextPageToken(response) { const env = response.body ?? {}; if (typeof env.page === "number" && typeof env.totalPages === "number") { return env.page < env.totalPages ? env.page + 1 : void 0; } return void 0; } convertRecord(raw) { return convertJetimobPropertyToHorizon(raw); } getJsonSchema() { return { type: "object", required: ["reference", "source_key", "sync_hash"], properties: { reference: { type: "string" }, source_key: { type: "string" }, source_updated_at: { type: ["string", "null"] }, sync_hash: { type: "string" } } }; } /** Mapeia status HTTP da Jetimob pros FailureType corretos. */ async onHttpError(response) { return this.buildHttpError(response, "request"); } // ======================================== // iterateRaw — paginação automática (fonte única de verdade) // ======================================== /** * Itera os imóveis **crus** (sem conversão) página por página, seguindo a * paginação automática do `HttpStream`. Base de `readRecords` e `fetchRaw`. * * Quando `state` é passado (INCREMENTAL), `requestParams` injeta `?start={unix}` * e a própria API devolve só o delta — a paginação fica curta. */ async *iterateRaw(state) { let nextPageToken = void 0; do { const url = this.buildUrl({ state, nextPageToken }); const init = this.buildRequestInit({ state, nextPageToken }); const { body, headers, status } = await this.doRequest(url, init); for (const raw of this.parseResponse(body)) { yield raw; } nextPageToken = this.nextPageToken({ body, headers, status }); } while (nextPageToken !== void 0); } /** * Retorna todos os imóveis **crus** (array, sem conversão). Útil pra * profiling, debug, ou pra consumers que querem converter em lote com * `parseResponseDefensive` do core. Espelha `Si9PropertyStream.fetchRaw()`. */ async fetchRaw() { const all = []; for await (const raw of this.iterateRaw()) all.push(raw); return all; } // ======================================== // readRecords — DEFENSIVO + INCREMENTAL server-side via ?start // ======================================== async *readRecords(syncMode, state) { const incremental = syncMode === SyncMode2.INCREMENTAL; const cursor = incremental ? state?.cursor ?? void 0 : void 0; for await (const raw of this.iterateRaw(incremental ? state : void 0)) { let record; try { record = this.convertRecord(raw); } catch (err) { const msg = err instanceof Error ? err.message : String(err); const codigo = raw?.codigo; console.warn( `[JetimobPropertyStream] skipped record codigo=${String(codigo ?? "?")}: ${msg}` ); continue; } if (cursor && record.source_updated_at && String(record.source_updated_at) <= String(cursor)) { continue; } yield record; } } /** * Avança o cursor incremental a partir de um record lido. * Cursor = maior `source_updated_at` visto. */ getUpdatedState(currentState, record) { const value = record.source_updated_at; if (!value) return currentState; const current = currentState.cursor; if (current != null && String(current) >= value) return currentState; return { ...currentState, cursor: value }; } // ======================================== // getListing — lista leve via /imoveis-ativos // ======================================== /** * Lista leve `[{ ref, updatedAt }]`. Usa o endpoint **`/imoveis-ativos`**, que * devolve só os `id_imovel` ativos numa única resposta (~0.3s, ~9KB). * * Notas (JET-002): * - `ref` = `id_imovel` (não o `codigo`). É o que `/imoveis-ativos` fornece e * o que `?id=` consome. O converter expõe `id_imovel` como campo extra. * - `updatedAt` é sempre `null` — `/imoveis-ativos` não traz timestamp. Pra * delta com data, use `readRecords(INCREMENTAL)` (que filtra via `?start`). */ async getListing() { const url = `${this.urlBase}imoveis-ativos?v=4`; const ids = await retry(async () => { const response = await fetchWithTimeout(url, { timeoutMs: this.requestTimeoutMs }); if (!response.ok) { throw await this.buildHttpError(response, "getListing(/imoveis-ativos)"); } return extractAtivosIds(await response.json()); }, this.retryOptions); return ids.map((id) => ({ ref: String(id), updatedAt: null })); } // ======================================== // fetchByRef — busca individual (?id= com fallback ?codigos=) // ======================================== /** * Busca UM imóvel pelo `ref`. A Jetimob não tem endpoint individual — usa o * filtro no endpoint de lista. Tenta `?id={ref}` primeiro (pareia com o * `id_imovel` que `getListing` devolve) e, se não achar, tenta `?codigos={ref}`. * Retorna `null` se nenhum dos dois encontrar. */ async fetchByRef(ref) { const byId = await this.fetchOne( `id=${encodeURIComponent(ref)}`, `fetchByRef(id=${ref})` ); if (byId) return byId; return this.fetchOne( `codigos=${encodeURIComponent(ref)}`, `fetchByRef(codigos=${ref})` ); } /** Busca 1 imóvel por um filtro de query (`id=...` ou `codigos=...`). */ async fetchOne(filterParam, context) { const url = `${this.urlBase}imoveis?v=4&${filterParam}&pageSize=1`; const items = await retry(async () => { const response = await fetchWithTimeout(url, { timeoutMs: this.requestTimeoutMs }); if (!response.ok) { throw await this.buildHttpError(response, context); } return this.parseResponse(await response.json()); }, this.retryOptions); if (items.length === 0) return null; return this.convertRecord(items[0]); } /** * Valida a credencial fazendo o request mais barato possível * (`/imoveis-ativos`, ~9KB). Lança `SyncError` se a `webserviceKey` for * inválida. Usado por `JetimobSource.check()`. */ async probe() { const url = `${this.urlBase}imoveis-ativos?v=4`; await retry(async () => { const response = await fetchWithTimeout(url, { timeoutMs: this.requestTimeoutMs }); if (!response.ok) { throw await this.buildHttpError(response, "probe"); } }, this.retryOptions); } // ======================================== // helpers // ======================================== async buildHttpError(response, context) { const body = await response.text().catch(() => ""); const detail = `${response.status} ${context} ${this.base}: ${body.slice(0, 300)}`; if (response.status === 401 || response.status === 403) { return SyncError.configError( "webserviceKey Jetimob inv\xE1lida ou sem permiss\xE3o", detail ); } if (response.status === 429 || response.status >= 500) { return SyncError.transientError( "API Jetimob temporariamente indispon\xEDvel", detail ); } return SyncError.systemError(`Erro HTTP ${response.status} em ${context}`, detail); } }; function extractAtivosIds(body) { if (Array.isArray(body)) return body; const env = body ?? {}; if (Array.isArray(env.data)) return env.data; const inner = env.data ?? {}; return Array.isArray(inner.result) ? inner.result : []; } function isoToUnix(iso) { const ms = new Date(iso).getTime(); return Number.isFinite(ms) ? Math.floor(ms / 1e3) : null; } // src/source.ts var JetimobSource = class { constructor(options = {}) { this.options = options; this.name = "jetimob"; this.manifest = jetimobManifest; this.spec = jetimobSpec; } async check(config) { if (!config?.webserviceKey || typeof config.webserviceKey !== "string") { return { ok: false, message: "webserviceKey ausente ou inv\xE1lida" }; } try { const stream = new JetimobPropertyStream(config, { baseUrl: this.options.baseUrl }); await stream.probe(); return { ok: true }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { ok: false, message: msg }; } } streams(config) { return [ new JetimobPropertyStream(config, { baseUrl: this.options.baseUrl }) ]; } }; // src/services/PropertyDownloader.ts import { parseResponseDefensive } from "@horizon-js/integrations-core"; function buildStream(config) { return new JetimobPropertyStream(config.credentials, { baseUrl: config.baseUrl, pageSize: config.pageSize }); } async function fetchAll(config) { const stream = buildStream(config); const rawItems = await stream.fetchRaw(); const { valid, errors } = parseResponseDefensive( rawItems, convertJetimobPropertyToHorizon, (raw) => String(raw?.codigo ?? "?") ); return { properties: valid, errors: errors.map((e) => `${e.ref ?? `#${e.index}`}: ${e.message}`) }; } async function getListing(config) { return buildStream(config).getListing(); } async function fetchByRef(config, ref) { return buildStream(config).fetchByRef(ref); } var PropertyDownloader = class { constructor(config) { this.config = config; } fetchAll() { return fetchAll(this.config); } getListing() { return getListing(this.config); } fetchByRef(ref) { return fetchByRef(this.config, ref); } }; // src/schemas/horizon-property-schema-by-jetimob.ts import { horizonPropertySchemaBase } from "@horizon-js/property-domain-schema"; var HorizonPropertySchemaByJetimob = { entity: "property", version: "2.3.0", description: "Schema estendido Jetimob CRM - 86 campos (47 base + 39 extras)", fields: [ // ======================================== // 📦 53 CAMPOS BASE HORIZON V3 (importados) // ======================================== ...horizonPropertySchemaBase.fields, // ======================================== // 🔧 39 CAMPOS ADICIONAIS JETIMOB // ======================================== { key: "tags", type: "String[]", db: { type: "jsonb", index: "gin" }, categories: ["ficha-tecnica"], ui: { label: "Tags" }, audit: { origin: "provider-extra" } }, { key: "numero_pessoas", type: "Number", format: "count", validation: { min: 0 }, categories: ["comercial"], rules: { conditions: ["operacao.contains:temporada"] }, ui: { label: "N\xFAmero de pessoas" }, audit: { origin: "provider-extra" } }, { key: "valor_diaria", type: "Number", format: "currency", unit: "BRL", validation: { min: 0, precision: 2 }, categories: ["valores"], rules: { conditions: ["operacao.contains:temporada"] }, ui: { label: "Valor da di\xE1ria" }, audit: { origin: "provider-extra" } }, { key: "geo_aproximado", type: "Json", db: { type: "jsonb" }, categories: ["localizacao"], ui: { label: "Localiza\xE7\xE3o aproximada" }, audit: { origin: "provider-extra" } }, { key: "id_imovel", type: "String", categories: ["sistema"], ui: { label: "ID interno Jetimob" }, audit: { origin: "provider-extra" } }, { key: "plantas", type: "String[]", db: { type: "jsonb", index: "gin" }, categories: ["ficha-tecnica"], ui: { label: "Plantas" }, audit: { origin: "provider-extra" } }, { key: "caracteristicas", type: "String[]", categories: ["ficha-tecnica"], db: { type: "jsonb", index: "gin" }, ui: { label: "Caracter\xEDsticas" }, audit: { origin: "provider-extra" } }, { key: "finalidade", type: "String", categories: ["ficha-tecnica"], ui: { label: "Finalidade" }, audit: { origin: "provider-extra" } }, { key: "mobiliado", type: "Boolean", categories: ["ficha-tecnica"], ui: { label: "Mobiliado" }, audit: { origin: "provider-extra" } }, { key: "valor_seguro_incendio", type: "Number", format: "currency", unit: "BRL", validation: { min: 0, precision: 2 }, categories: ["valores"], ui: { label: "Valor do seguro inc\xEAndio" }, audit: { origin: "provider-extra" } }, { key: "valor_taxa_limpeza", type: "Number", format: "currency", unit: "BRL", validation: { min: 0, precision: 2 }, categories: ["valores"], ui: { label: "Valor da taxa de limpeza" }, audit: { origin: "provider-extra" } }, { key: "andar", type: "Number", categories: ["endereco"], ui: { label: "Andar" }, audit: { origin: "provider-extra" } }, { key: "financiavel", type: "Boolean", categories: ["comercial"], ui: { label: "Financi\xE1vel" }, audit: { origin: "provider-extra" } }, { key: "exclusividade", type: "Boolean", categories: ["comercial"], ui: { label: "Exclusividade" }, audit: { origin: "provider-extra" } }, { key: "permuta", type: "Boolean", categories: ["comercial"], rules: { conditions: ["operacao.contains:venda"] }, ui: { label: "Aceita permuta" }, audit: { origin: "provider-extra" } }, { key: "seguro_fianca", type: "Boolean", categories: ["comercial"], rules: { conditions: ["operacao.contains:locacao"] }, ui: { label: "Seguro fian\xE7a" }, audit: { origin: "provider-extra" } }, { key: "situacao", type: "String", categories: ["situacoes"], ui: { label: "Situa\xE7\xE3o de ocupa\xE7\xE3o" }, audit: { origin: "provider-extra" } }, { key: "distancia_mar", type: "Number", format: "distance", unit: "m", validation: { min: 0 }, categories: ["localizacao"], ui: { label: "Dist\xE2ncia do mar" }, audit: { origin: "provider-extra" } }, { key: "posicao", type: "String", categories: ["estrutura"], ui: { label: "Posi\xE7\xE3o do im\xF3vel no terreno" }, audit: { origin: "provider-extra" } }, { key: "posicao_solar", type: "String[]", db: { type: "jsonb", index: "gin" }, categories: ["estrutura"], ui: { label: "Posi\xE7\xE3o solar" }, audit: { origin: "provider-extra" } }, { key: "status", type: "String", categories: ["comercial"], db: { default: "ativo" }, ui: { label: "Status do im\xF3vel" }, audit: { origin: "provider-extra" } }, { key: "calendario_temporada", type: "Json", categories: ["comercial"], db: { type: "jsonb" }, rules: { conditions: ["operacao.contains:temporada"] }, ui: { label: "Calend\xE1rio temporada" }, audit: { origin: "provider-extra" } }, { key: "condominio_comodidades", type: "String[]", categories: ["condominio"], db: { type: "jsonb", index: "gin" }, ui: { label: "Comodidades do condom\xEDnio" }, audit: { origin: "provider-extra" } }, { key: "condominio_tipo", type: "String", categories: ["condominio"], ui: { label: "Tipo do condom\xEDnio" }, audit: { origin: "provider-extra" } }, { key: "subcondominio_id", type: "String", categories: ["condominio"], ui: { label: "ID do subcondom\xEDnio" }, audit: { origin: "provider-extra" } }, { key: "condominio_fechado", type: "Boolean", categories: ["condominio"], ui: { label: "Condom\xEDnio fechado" }, audit: { origin: "provider-extra" } }, { key: "entrega_ano", type: "Number", format: "year", categories: ["cronograma"], ui: { label: "Ano de entrega" }, audit: { origin: "provider-extra" } }, { key: "entrega_mes", type: "Number", format: "month", validation: { min: 1, max: 12 }, categories: ["cronograma"], rules: { conditions: ["entrega_ano.exists:true"] }, ui: { label: "M\xEAs de entrega" }, audit: { origin: "provider-extra" } }, { key: "tipo_construcao", type: "String", categories: ["estrutura"], ui: { label: "Tipo de constru\xE7\xE3o" }, audit: { origin: "provider-extra" } }, { key: "tipo_piso", type: "String[]", db: { type: "jsonb", index: "gin" }, categories: ["estrutura"], ui: { label: "Tipo de piso" }, audit: { origin: "provider-extra" } }, { key: "periodicidade_iptu", type: "String", categories: ["valores"], ui: { label: "Periodicidade do IPTU" }, audit: { origin: "provider-extra" } }, { key: "terreno_frente", type: "Number", format: "distance", unit: "m", validation: { min: 0, precision: 2 }, categories: ["medidas"], ui: { label: "Frente do terreno" }, audit: { origin: "provider-extra" } }, { key: "terreno_fundos", type: "Number", format: "distance", unit: "m", validation: { min: 0, precision: 2 }, categories: ["medidas"], ui: { label: "Fundos do terreno" }, audit: { origin: "provider-extra" } }, { key: "terreno_esquerdo", type: "Number", format: "distance", unit: "m", validation: { min: 0, precision: 2 }, categories: ["medidas"], ui: { label: "Lateral esquerda" }, audit: { origin: "provider-extra" } }, { key: "terreno_direita", type: "Number", format: "distance", unit: "m", validation: { min: 0, precision: 2 }, categories: ["medidas"], ui: { label: "Lateral direita" }, audit: { origin: "provider-extra" } }, { key: "terreno_total", type: "Number", format: "area", unit: "m2", validation: { min: 0, precision: 2 }, categories: ["medidas"], ui: { label: "\xC1rea total do terreno" }, audit: { origin: "provider-extra" } }, { key: "rural_area_aravel", type: "Number", format: "area", unit: "hectare", validation: { min: 0, precision: 2 }, categories: ["rural"], ui: { label: "\xC1rea ar\xE1vel" }, audit: { origin: "provider-extra" } }, { key: "rural_sedes", type: "Number", format: "count", validation: { min: 0 }, categories: ["rural"], ui: { label: "N\xFAmero de sedes" }, audit: { origin: "provider-extra" } }, { key: "rural_atividade", type: "String", categories: ["rural"], ui: { label: "Atividade rural" }, audit: { origin: "provider-extra" } } ] }; // src/schemas/horizon-property-schema-by-jetimob.zod.ts import { z as z2 } from "zod"; import { HorizonPropertySchemaBaseZod } from "@horizon-js/property-domain-schema"; var ImageSchema = z2.object({ md: z2.string().optional(), sm: z2.string().optional(), full: z2.string().optional(), cover: z2.boolean().optional() }).passthrough(); var VideoSchema = z2.object({ url: z2.string().optional() }).passthrough(); var VirtualTourSchema = z2.object({ embed_url: z2.string().optional() }).passthrough(); var MainImageSchema = z2.object({ md: z2.string().optional(), sm: z2.string().optional(), full: z2.string().optional() }).passthrough(); var HorizonPropertySchemaByJetimobZod = HorizonPropertySchemaBaseZod.omit({ images: true, videos: true, virtual_tours: true, main_image: true }).extend({ // ======================================== // JETIMOB EXTRAS // ======================================== // Campos que saíram do base (agora são extras) tags: z2.array(z2.string()).describe("Tags do im\xF3vel").optional(), numero_pessoas: z2.number().min(0).describe("N\xFAmero de pessoas (temporada)").optional(), valor_diaria: z2.number().describe("Valor da di\xE1ria (temporada)").optional(), geo_aproximado: z2.object({ lat: z2.number().min(-90).max(90), lng: z2.number().min(-180).max(180) }).describe("Coordenadas aproximadas (offset ~500m)").optional(), // Campos específicos Jetimob id_imovel: z2.string().describe( "ID interno do im\xF3vel na Jetimob (id_imovel). \xC9 o que /imoveis-ativos lista e o que ?id= consome \u2014 pareia com ListingEntry.ref. Distinto de `reference`, que \xE9 o `codigo`." ).optional(), finalidade: z2.string().describe("Finalidade").optional(), mobiliado: z2.boolean().describe("Mobiliado").optional(), caracteristicas: z2.array(z2.string()).describe("Caracter\xEDsticas/Comodidades").optional(), plantas: z2.array(z2.string()).describe("URLs das plantas").optional(), // Valores adicionais valor_seguro_incendio: z2.number().min(0).describe("Valor do seguro inc\xEAndio").optional(), valor_taxa_limpeza: z2.number().min(0).describe("Valor da taxa de limpeza").optional(), // Estrutura andar: z2.number().describe("Andar").optional(), // Comercial financiavel: z2.boolean().describe("Financi\xE1vel").optional(), exclusividade: z2.boolean().describe("Exclusividade").optional(), permuta: z2.boolean().describe("Aceita permuta").optional(), seguro_fianca: z2.boolean().describe("Seguro fian\xE7a").optional(), situacao: z2.string().describe("Situa\xE7\xE3o de ocupa\xE7\xE3o").optional(), status: z2.string().describe("Status do im\xF3vel").optional(), // Localização distancia_mar: z2.number().min(0).describe("Dist\xE2ncia do mar").optional(), posicao: z2.string().describe("Posi\xE7\xE3o do im\xF3vel no terreno").optional(), posicao_solar: z2.array(z2.string()).describe("Posi\xE7\xE3o solar \u2014 multi-valor (Norte, Leste, ...)").optional(), // Temporada calendario_temporada: z2.any().describe("Calend\xE1rio temporada").optional(), // Condomínio estendido condominio_comodidades: z2.array(z2.string()).de