UNPKG

@horizon-integrations/jetimob-crm

Version:

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

1 lines 109 kB
{"version":3,"sources":["../src/manifest.ts","../src/spec.ts","../package.json","../src/streams/JetimobPropertyStream.ts","../src/services/PropertyConverter/index.ts","../src/schemas/jetimob-property-schema.zod.ts","../src/services/PropertyConverter/convertBaseFields.ts","../src/services/PropertyConverter/convertExtendedFields.ts","../src/source.ts","../src/services/PropertyDownloader.ts","../src/schemas/horizon-property-schema-by-jetimob.ts","../src/schemas/horizon-property-schema-by-jetimob.zod.ts"],"sourcesContent":["import { type SourceManifest, SyncMode } from \"@horizon-js/integrations-core\"\nimport type { JetimobCredentials } from \"./types\"\nimport { jetimobSpec } from \"./spec\"\nimport pkg from \"../package.json\"\n\n/**\n * Manifest declarativo do JetimobSource — cartão de identidade completo.\n *\n * Consumer/IA lê daqui pra entender 100% do adapter sem ler código:\n * capabilities da API, issues conhecidos, streams oferecidos.\n *\n * @see {@link https://api.jetimob.com | API Jetimob}\n */\nexport const jetimobManifest: SourceManifest<JetimobCredentials> = {\n metadata: {\n name: \"jetimob\",\n displayName: \"Jetimob\",\n type: \"crm\",\n vendor: {\n company: \"Jetimob\",\n website: \"https://jetimob.com\",\n docsUrl: \"https://api.jetimob.com\",\n },\n supportTier: \"community\",\n adapterVersion: pkg.version,\n },\n\n spec: jetimobSpec,\n\n capabilities: {\n // API v4 pagina de verdade — ?page=&pageSize=, resposta traz totalPages.\n pagination: true,\n // ?start/&end (Unix timestamp), ?codigos, ?id — todos testados e funcionando.\n filters: true,\n // Cada imóvel tem data_update / updated_at — melhor que SI9 (que não tem).\n nativeTimestamp: true,\n // ?start={unix} filtra por data de atualização server-side. Testado: funciona.\n incrementalByTimestamp: true,\n // Busca individual via ?id={id_imovel} (ou ?codigos={codigo}).\n individualFetch: true,\n // /imoveis-ativos devolve todos os id_imovel ativos numa tacada (~0.3s, ~9KB).\n lightweightListing: true,\n webhooks: false,\n rateLimit: null,\n customFields: false,\n writes: {\n // API de leads existe (par chave-pública/privada) — writer ainda não implementado.\n sendLead: false,\n createProperty: false,\n updateProperty: false,\n deleteProperty: false,\n },\n softDelete: { enabled: false },\n },\n\n streams: [\n {\n name: \"properties\",\n displayName: \"Imóveis\",\n resourceType: \"property\",\n supportedSyncModes: [SyncMode.FULL_REFRESH, SyncMode.INCREMENTAL],\n cursorField: \"source_updated_at\",\n primaryKey: [\"reference\"],\n hasListing: true,\n hasIndividualFetch: true,\n },\n ],\n\n writers: [],\n\n knownIssues: [\n {\n id: \"JET-001\",\n severity: \"medium\",\n summary:\n \"Doc interna antiga afirmava que GET /imoveis retorna array direto — na verdade retorna envelope { total, page, pageSize, totalPages, data: [...] }\",\n workaround:\n \"JetimobPropertyStream.parseResponse() lê o envelope `data`. Doc API_JETIMOB.md corrigida com base na doc oficial (docs.jetimob.com).\",\n category: \"documentation\",\n reportedToVendor: false,\n discoveredAt: \"2026-05-14\",\n },\n {\n id: \"JET-002\",\n severity: \"low\",\n summary:\n \"/imoveis-ativos e /imoveis usam identificadores diferentes: /imoveis-ativos devolve `id_imovel`; ?codigos= filtra por `codigo` (que NÃO é o id). ?id= filtra por `id_imovel`.\",\n workaround:\n \"getListing() usa /imoveis-ativos → ListingEntry.ref = id_imovel. fetchByRef() tenta ?id= primeiro e cai pra ?codigos= como fallback, aceitando os dois. O converter expõe `id_imovel` como campo extra pra correlação (reference continua sendo o `codigo`).\",\n category: \"documentation\",\n reportedToVendor: false,\n discoveredAt: \"2026-05-14\",\n },\n {\n id: \"JET-003\",\n severity: \"low\",\n summary:\n \"Medidas de terreno (terreno_total, terreno_frente, etc.) às vezes vêm como string em vez de number\",\n workaround:\n \"Schema raw (jetimob-property-schema.zod.ts) aceita string|number nesses campos; o conversor faz Number().\",\n category: \"api_bug\",\n reportedToVendor: false,\n discoveredAt: \"2026-05-14\",\n },\n {\n id: \"JET-004\",\n severity: \"low\",\n summary:\n \"API retorna null (não undefined) em praticamente todos os campos opcionais\",\n workaround:\n \"Schema raw usa .nullish() em todos os campos opcionais (aceita null | undefined).\",\n category: \"api_bug\",\n reportedToVendor: false,\n discoveredAt: \"2026-05-14\",\n },\n {\n id: \"JET-005\",\n severity: \"low\",\n summary:\n \"/imoveis não tem parâmetro de seleção de campos (testado: fields, campos, field, select, only — todos ignorados). Toda resposta de /imoveis traz os ~96 campos completos (~9.5KB por imóvel).\",\n workaround:\n \"Pra listagem leve usar /imoveis-ativos (só id_imovel). Pra delta eficiente usar ?start={unix} (incremental server-side). Não há como pedir menos campos do /imoveis.\",\n category: \"missing_feature\",\n reportedToVendor: false,\n discoveredAt: \"2026-05-14\",\n },\n {\n id: \"JET-006\",\n severity: \"low\",\n summary:\n \"API Jetimob devolve 502 Bad Gateway transiente esporádico (nginx). Em paginação de muitas páginas, um 502 isolado derrubaria o sync inteiro.\",\n workaround:\n \"JetimobPropertyStream usa retryOptions (httpRetryPolicy, 4 tentativas, backoff exponencial). 5xx/429/timeout são re-tentados automaticamente.\",\n category: \"performance\",\n reportedToVendor: false,\n discoveredAt: \"2026-05-14\",\n },\n {\n id: \"JET-007\",\n severity: \"high\",\n summary:\n \"`generateApproximateCoords` usava Math.random() → o campo geo_aproximado mudava a cada conversão → sync_hash instável → o delta re-upsertava ~60% dos imóveis (os com geoposicionamento_visivel=2) em TODO sync.\",\n workaround:\n \"Corrigido em 3.1.0: o offset agora é determinístico (FNV-1a hash do codigo/id do imóvel). Mesmo imóvel → mesmo geo_aproximado → sync_hash estável.\",\n category: \"breaking_change\",\n reportedToVendor: false,\n discoveredAt: \"2026-05-14\",\n resolvedAt: \"2026-05-14\",\n },\n ],\n\n client: {\n packageName: \"@horizon-integrations/jetimob-crm-client\",\n className: \"JetimobClient\",\n apiVersion: \"v1\",\n endpoints: {\n properties: \"/api/providers/jetimob/v1/properties\",\n \"properties.index\": \"/api/providers/jetimob/v1/properties/index\",\n \"properties.byRef\": \"/api/providers/jetimob/v1/properties/{ref}\",\n },\n },\n}\n","import type { SourceSpec } from \"@horizon-js/integrations-core\"\nimport type { JetimobCredentials } from \"./types\"\n\n/**\n * Spec da configuração do JetimobSource — declara o shape das credenciais\n * aceitas. JSON Schema draft-07, inspirado no `spec.json` do Airbyte.\n *\n * Usado pelo hub (UI de setup) e pela validação em runtime.\n */\nexport const jetimobSpec: SourceSpec<JetimobCredentials> = {\n name: \"jetimob\",\n title: \"Jetimob\",\n documentationUrl: \"https://api.jetimob.com\",\n configSchema: {\n type: \"object\",\n required: [\"webserviceKey\"],\n properties: {\n webserviceKey: {\n type: \"string\",\n title: \"Webservice Key\",\n description:\n \"Chave webservice da conta Jetimob. Vai no path da URL (/webservice/{key}/imoveis). Obtida no painel Jetimob da imobiliária.\",\n airbyteSecret: true,\n },\n },\n },\n}\n","{\n \"name\": \"@horizon-integrations/jetimob-crm\",\n \"version\": \"3.1.4\",\n \"description\": \"Adapter de integração com o CRM Jetimob — padrão Airbyte (@horizon-js/integrations-core)\",\n \"main\": \"dist/index.js\",\n \"module\": \"dist/index.mjs\",\n \"types\": \"dist/index.d.ts\",\n \"files\": [\n \"dist\",\n \"docs\",\n \"examples\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest\",\n \"test:coverage\": \"vitest --coverage\",\n \"typecheck\": \"tsc --noEmit\"\n },\n \"keywords\": [\n \"imobiliario\",\n \"jetimob\",\n \"crm\",\n \"integration\",\n \"airbyte\",\n \"converter\",\n \"horizon\"\n ],\n \"author\": \"Horizon Modules\",\n \"license\": \"MIT\",\n \"dependencies\": {\n \"@horizon-js/integrations-core\": \"^0.5.1\",\n \"@horizon-js/property-domain-schema\": \"^3.17.0\",\n \"zod\": \"^3.25.67\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^20.0.0\",\n \"tsup\": \"^8.0.0\",\n \"tsx\": \"^4.0.0\",\n \"typescript\": \"^5.0.0\",\n \"vitest\": \"^1.0.0\"\n },\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.mjs\",\n \"require\": \"./dist/index.js\",\n \"default\": \"./dist/index.js\"\n }\n },\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/imobland/horizon-integrations.git\",\n \"directory\": \"horizon-integrations/jetimob\"\n },\n \"publishConfig\": {\n \"access\": \"public\"\n }\n}\n","import {\n HttpStream,\n SyncMode,\n SyncError,\n fetchWithTimeout,\n retry,\n httpRetryPolicy,\n type RetryOptions,\n type IncrementalStream,\n type SyncState,\n type ListingEntry,\n} from \"@horizon-js/integrations-core\"\nimport type { HorizonPropertySchemaByJetimobType } from \"../schemas/horizon-property-schema-by-jetimob.zod\"\nimport type { JetimobCredentials } from \"../types\"\nimport { convertJetimobPropertyToHorizon } from \"../services/PropertyConverter\"\n\nconst DEFAULT_BASE_URL = \"https://api.jetimob.com\"\nconst DEFAULT_PAGE_SIZE = 200\n\n/** Envelope da resposta da API Jetimob v4 (JET-001). */\ninterface JetimobEnvelope {\n total?: number\n page?: number\n pageSize?: number\n totalPages?: number\n data?: unknown\n}\n\nexport interface JetimobPropertyStreamOptions {\n /** URL base da API. Default: `https://api.jetimob.com`. */\n readonly baseUrl?: string\n /** Timeout por request HTTP em ms. Default: `30_000`. */\n readonly requestTimeoutMs?: number\n /** Tamanho de página pro endpoint /imoveis. Default: `200`. */\n readonly pageSize?: number\n}\n\n/**\n * Stream de imóveis da Jetimob.\n *\n * Estende `HttpStream<T>` do `@horizon-js/integrations-core` — a API Jetimob v4\n * pagina de verdade (`?page=&pageSize=`, resposta traz `totalPages`), então o\n * loop de paginação automático do `HttpStream` se aplica.\n *\n * ## A API Jetimob — o que foi testado e confirmado\n *\n * - **`/imoveis`** — endpoint pesado: cada imóvel traz ~96 campos (~9.5KB).\n * **Não tem** parâmetro de seleção de campos (`fields`/`campos`/etc — JET-005).\n * - **`/imoveis-ativos`** — endpoint LEVE: devolve só os `id_imovel` ativos\n * numa tacada (~0.3s, ~9KB pros 1000+ imóveis). `getListing()` usa este.\n * - **`?start={unix}`** — filtra `/imoveis` por data de atualização server-side.\n * `readRecords(INCREMENTAL)` usa este → traz só o delta, sem baixar tudo.\n * - **`?id=` vs `?codigos=`** — `id` filtra por `id_imovel`; `codigos` filtra\n * por `codigo` (são diferentes — JET-002). `fetchByRef` aceita ambos.\n *\n * ## Peculiaridades de implementação (ver `jetimobManifest.knownIssues`)\n *\n * - **Auth no PATH**: a `webserviceKey` vai embutida na URL\n * (`/webservice/{key}/imoveis`), não em header nem query — por isso entra no\n * `urlBase`, e não há `AuthStrategy` dedicada.\n * - **Envelope `{ data }`** (JET-001): `parseResponse` extrai `body.data`.\n * - **`readRecords` defensivo**: o `readRecords` default do `HttpStream` não\n * trata erro de conversão — um record inválido derrubaria o batch. Aqui é\n * sobrescrito pra pular records que falham validação Zod (loga warning),\n * na filosofia Airbyte (bad record ≠ falha de sync).\n * - **Retry** (JET-006): a API devolve 502 transiente; `retryOptions` re-tenta.\n */\nexport class JetimobPropertyStream\n extends HttpStream<HorizonPropertySchemaByJetimobType>\n implements IncrementalStream<HorizonPropertySchemaByJetimobType>\n{\n readonly name = \"properties\"\n readonly supportedSyncModes = [SyncMode.FULL_REFRESH, SyncMode.INCREMENTAL]\n readonly cursorField = \"source_updated_at\"\n readonly primaryKey = [\"reference\"]\n override readonly requestTimeoutMs: number\n\n /**\n * Retry com backoff exponencial pra erros transientes (5xx/429/timeout).\n * A API Jetimob devolve 502 Bad Gateway esporádico (JET-006). `HttpStream.doRequest`\n * aplica este policy automaticamente; `getListing`/`fetchByRef`/`probe` aplicam via `retry()`.\n */\n override readonly retryOptions: RetryOptions\n\n private readonly webserviceKey: string\n private readonly base: string\n private readonly pageSize: number\n\n constructor(\n credentials: JetimobCredentials,\n options: JetimobPropertyStreamOptions = {},\n ) {\n super()\n this.webserviceKey = credentials.webserviceKey\n this.base = options.baseUrl ?? DEFAULT_BASE_URL\n this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000\n this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE\n this.retryOptions = httpRetryPolicy({ maxAttempts: 4 })\n }\n\n // ========================================\n // HOOKS DO HttpStream\n // ========================================\n\n /** A `webserviceKey` é a auth — vai no path da URL base. */\n get urlBase(): string {\n return `${this.base}/webservice/${this.webserviceKey}/`\n }\n\n path(): string {\n return \"imoveis\"\n }\n\n override requestParams(context: {\n state?: SyncState\n nextPageToken?: unknown\n }): Record<string, string> {\n const page = context.nextPageToken != null ? Number(context.nextPageToken) : 1\n const params: Record<string, string> = {\n v: \"4\",\n page: String(page),\n pageSize: String(this.pageSize),\n }\n // INCREMENTAL: ?start={unix} filtra por data de atualização server-side.\n // Testado contra a API real — devolve só o delta. Bem mais leve que baixar tudo.\n const cursor = context.state?.cursor\n if (cursor != null) {\n const unix = isoToUnix(String(cursor))\n if (unix != null) params.start = String(unix)\n }\n return params\n }\n\n /** API v4 retorna `{ total, page, pageSize, totalPages, data: [...] }` (JET-001). */\n parseResponse(body: unknown): unknown[] {\n if (Array.isArray(body)) return body\n const env = (body ?? {}) as JetimobEnvelope\n return Array.isArray(env.data) ? env.data : []\n }\n\n /** Próxima página via metadados do envelope. `undefined` encerra o loop. */\n nextPageToken(response: {\n body: unknown\n headers: Record<string, string>\n status: number\n }): unknown {\n const env = (response.body ?? {}) as JetimobEnvelope\n if (typeof env.page === \"number\" && typeof env.totalPages === \"number\") {\n return env.page < env.totalPages ? env.page + 1 : undefined\n }\n return undefined\n }\n\n override convertRecord(raw: unknown): HorizonPropertySchemaByJetimobType {\n return convertJetimobPropertyToHorizon(raw)\n }\n\n getJsonSchema(): Record<string, unknown> {\n // Shape mínimo descritivo — o schema completo está em\n // horizon-property-schema-by-jetimob.zod.ts (validação real é via Zod).\n return {\n type: \"object\",\n required: [\"reference\", \"source_key\", \"sync_hash\"],\n properties: {\n reference: { type: \"string\" },\n source_key: { type: \"string\" },\n source_updated_at: { type: [\"string\", \"null\"] },\n sync_hash: { type: \"string\" },\n },\n }\n }\n\n /** Mapeia status HTTP da Jetimob pros FailureType corretos. */\n protected override async onHttpError(response: Response): Promise<Error> {\n return this.buildHttpError(response, \"request\")\n }\n\n // ========================================\n // iterateRaw — paginação automática (fonte única de verdade)\n // ========================================\n\n /**\n * Itera os imóveis **crus** (sem conversão) página por página, seguindo a\n * paginação automática do `HttpStream`. Base de `readRecords` e `fetchRaw`.\n *\n * Quando `state` é passado (INCREMENTAL), `requestParams` injeta `?start={unix}`\n * e a própria API devolve só o delta — a paginação fica curta.\n */\n private async *iterateRaw(\n state?: SyncState,\n ): AsyncGenerator<unknown, void, undefined> {\n let nextPageToken: unknown = undefined\n do {\n const url = this.buildUrl({ state, nextPageToken })\n const init = this.buildRequestInit({ state, nextPageToken })\n const { body, headers, status } = await this.doRequest(url, init)\n\n for (const raw of this.parseResponse(body)) {\n yield raw\n }\n\n nextPageToken = this.nextPageToken({ body, headers, status })\n } while (nextPageToken !== undefined)\n }\n\n /**\n * Retorna todos os imóveis **crus** (array, sem conversão). Útil pra\n * profiling, debug, ou pra consumers que querem converter em lote com\n * `parseResponseDefensive` do core. Espelha `Si9PropertyStream.fetchRaw()`.\n */\n async fetchRaw(): Promise<unknown[]> {\n const all: unknown[] = []\n for await (const raw of this.iterateRaw()) all.push(raw)\n return all\n }\n\n // ========================================\n // readRecords — DEFENSIVO + INCREMENTAL server-side via ?start\n // ========================================\n\n override async *readRecords(\n syncMode: SyncMode,\n state?: SyncState,\n ): AsyncGenerator<HorizonPropertySchemaByJetimobType, void, undefined> {\n const incremental = syncMode === SyncMode.INCREMENTAL\n const cursor = incremental ? state?.cursor ?? undefined : undefined\n\n // INCREMENTAL: passa o state adiante → requestParams injeta ?start={unix}.\n // A API filtra server-side; aqui só convertemos o delta que já veio enxuto.\n for await (const raw of this.iterateRaw(incremental ? state : undefined)) {\n let record: HorizonPropertySchemaByJetimobType\n try {\n record = this.convertRecord(raw)\n } catch (err) {\n // Record inválido NÃO derruba o batch — loga e pula (filosofia Airbyte).\n const msg = err instanceof Error ? err.message : String(err)\n const codigo = (raw as { codigo?: unknown })?.codigo\n console.warn(\n `[JetimobPropertyStream] skipped record codigo=${String(codigo ?? \"?\")}: ${msg}`,\n )\n continue\n }\n\n // Cinto de segurança: ?start já filtrou server-side, mas a API é inclusiva\n // no boundary — descarta o record exatamente igual ao cursor.\n if (\n cursor &&\n record.source_updated_at &&\n String(record.source_updated_at) <= String(cursor)\n ) {\n continue\n }\n\n yield record\n }\n }\n\n /**\n * Avança o cursor incremental a partir de um record lido.\n * Cursor = maior `source_updated_at` visto.\n */\n getUpdatedState(\n currentState: SyncState,\n record: HorizonPropertySchemaByJetimobType,\n ): SyncState {\n const value = record.source_updated_at\n if (!value) return currentState\n const current = currentState.cursor\n if (current != null && String(current) >= value) return currentState\n return { ...currentState, cursor: value }\n }\n\n // ========================================\n // getListing — lista leve via /imoveis-ativos\n // ========================================\n\n /**\n * Lista leve `[{ ref, updatedAt }]`. Usa o endpoint **`/imoveis-ativos`**, que\n * devolve só os `id_imovel` ativos numa única resposta (~0.3s, ~9KB).\n *\n * Notas (JET-002):\n * - `ref` = `id_imovel` (não o `codigo`). É o que `/imoveis-ativos` fornece e\n * o que `?id=` consome. O converter expõe `id_imovel` como campo extra.\n * - `updatedAt` é sempre `null` — `/imoveis-ativos` não traz timestamp. Pra\n * delta com data, use `readRecords(INCREMENTAL)` (que filtra via `?start`).\n */\n async getListing(): Promise<ListingEntry[]> {\n const url = `${this.urlBase}imoveis-ativos?v=4`\n\n const ids = await retry(async () => {\n const response = await fetchWithTimeout(url, {\n timeoutMs: this.requestTimeoutMs,\n })\n if (!response.ok) {\n throw await this.buildHttpError(response, \"getListing(/imoveis-ativos)\")\n }\n return extractAtivosIds(await response.json())\n }, this.retryOptions)\n\n return ids.map((id) => ({ ref: String(id), updatedAt: null }))\n }\n\n // ========================================\n // fetchByRef — busca individual (?id= com fallback ?codigos=)\n // ========================================\n\n /**\n * Busca UM imóvel pelo `ref`. A Jetimob não tem endpoint individual — usa o\n * filtro no endpoint de lista. Tenta `?id={ref}` primeiro (pareia com o\n * `id_imovel` que `getListing` devolve) e, se não achar, tenta `?codigos={ref}`.\n * Retorna `null` se nenhum dos dois encontrar.\n */\n async fetchByRef(ref: string): Promise<HorizonPropertySchemaByJetimobType | null> {\n const byId = await this.fetchOne(\n `id=${encodeURIComponent(ref)}`,\n `fetchByRef(id=${ref})`,\n )\n if (byId) return byId\n return this.fetchOne(\n `codigos=${encodeURIComponent(ref)}`,\n `fetchByRef(codigos=${ref})`,\n )\n }\n\n /** Busca 1 imóvel por um filtro de query (`id=...` ou `codigos=...`). */\n private async fetchOne(\n filterParam: string,\n context: string,\n ): Promise<HorizonPropertySchemaByJetimobType | null> {\n const url = `${this.urlBase}imoveis?v=4&${filterParam}&pageSize=1`\n\n const items = await retry(async () => {\n const response = await fetchWithTimeout(url, {\n timeoutMs: this.requestTimeoutMs,\n })\n if (!response.ok) {\n throw await this.buildHttpError(response, context)\n }\n return this.parseResponse(await response.json())\n }, this.retryOptions)\n\n if (items.length === 0) return null\n return this.convertRecord(items[0])\n }\n\n /**\n * Valida a credencial fazendo o request mais barato possível\n * (`/imoveis-ativos`, ~9KB). Lança `SyncError` se a `webserviceKey` for\n * inválida. Usado por `JetimobSource.check()`.\n */\n async probe(): Promise<void> {\n const url = `${this.urlBase}imoveis-ativos?v=4`\n await retry(async () => {\n const response = await fetchWithTimeout(url, {\n timeoutMs: this.requestTimeoutMs,\n })\n if (!response.ok) {\n throw await this.buildHttpError(response, \"probe\")\n }\n }, this.retryOptions)\n }\n\n // ========================================\n // helpers\n // ========================================\n\n private async buildHttpError(\n response: Response,\n context: string,\n ): Promise<Error> {\n const body = await response.text().catch(() => \"\")\n const detail = `${response.status} ${context} ${this.base}: ${body.slice(0, 300)}`\n\n if (response.status === 401 || response.status === 403) {\n return SyncError.configError(\n \"webserviceKey Jetimob inválida ou sem permissão\",\n detail,\n )\n }\n if (response.status === 429 || response.status >= 500) {\n return SyncError.transientError(\n \"API Jetimob temporariamente indisponível\",\n detail,\n )\n }\n return SyncError.systemError(`Erro HTTP ${response.status} em ${context}`, detail)\n }\n}\n\n/**\n * Extrai a lista de `id_imovel` da resposta do `/imoveis-ativos`.\n * Envelope observado: `{ total, page, pageSize, totalPages, data: { total, result: [...ids] } }`.\n * Aceita também `data` como array direto, por segurança.\n */\nfunction extractAtivosIds(body: unknown): unknown[] {\n if (Array.isArray(body)) return body\n const env = (body ?? {}) as { data?: unknown }\n if (Array.isArray(env.data)) return env.data\n const inner = (env.data ?? {}) as { result?: unknown }\n return Array.isArray(inner.result) ? inner.result : []\n}\n\n/** Converte ISO 8601 pra Unix timestamp (segundos). `null` se inválido. */\nfunction isoToUnix(iso: string): number | null {\n const ms = new Date(iso).getTime()\n return Number.isFinite(ms) ? Math.floor(ms / 1000) : null\n}\n","import { hashObject } from \"@horizon-js/integrations-core\"\nimport { safeValidateJetimobPropertySchema } from '../../schemas/jetimob-property-schema.zod'\nimport type { HorizonPropertySchemaByJetimobType } from '../../schemas/horizon-property-schema-by-jetimob.zod'\nimport { convertBaseFields } from './convertBaseFields'\nimport { convertExtendedFields } from './convertExtendedFields'\n\n/**\n * Converte um imóvel bruto do formato Jetimob para o formato Horizon Property.\n *\n * Valida a entrada com Zod (safe parse) e lança erro se inválida.\n * Adiciona `sync_hash` determinístico (SHA-256 64 bits) — usado pelo consumer\n * pra detectar mudanças entre syncs.\n */\nexport function convertJetimobPropertyToHorizon(rawImovel: unknown): HorizonPropertySchemaByJetimobType {\n const validation = safeValidateJetimobPropertySchema(rawImovel)\n\n if (!validation.success) {\n const errors = validation.error.issues\n .map((i) => `${i.path.join(\".\")}: ${i.message}`)\n .join(\"; \")\n throw new Error(`Validação Jetimob falhou: ${errors}`)\n }\n\n const imovel = validation.data\n\n const baseFields = convertBaseFields(imovel)\n const extendedFields = convertExtendedFields(imovel)\n\n const payload = { ...baseFields, ...extendedFields }\n\n return {\n ...payload,\n sync_hash: hashObject(payload),\n } as HorizonPropertySchemaByJetimobType\n}\n\nexport { convertBaseFields } from './convertBaseFields'\nexport { convertExtendedFields } from './convertExtendedFields'","import { z } from \"zod\"\n\n// A API Jetimob retorna null em campos opcionais — todos usam .nullish() (null | undefined)\n\nconst ruralSchema = z.object({\n atividade_rural: z.string().nullish(),\n rural_sedes: z.union([z.string(), z.number()]).nullish(),\n rural_area_aravel: z.number().nullish(),\n medida_total_area_aravel: z.string().nullish(),\n}).nullish()\n\nconst imagemSchema = z.object({\n link: z.string(),\n titulo: z.string().nullish(),\n link_thumb: z.string().nullish(),\n}).passthrough()\n\nexport const JetimobPropertySchemaSchema = z.object({\n codigo: z.string(),\n titulo_anuncio: z.string().max(200).nullish(),\n observacoes: z.string().nullish(),\n descricao_anuncio: z.string().nullish(),\n contrato: z.string().nullish(),\n tipo: z.string().nullish(),\n subtipo: z.string().nullish(),\n status: z.string().nullish(),\n situacao: z.string().nullish(),\n destaque: z.string().nullish(),\n destaque_fim: z.string().nullish(),\n exclusividade: z.union([z.boolean(), z.number()]).nullish(),\n financiavel: z.union([z.boolean(), z.number()]).nullish(),\n permuta: z.union([z.boolean(), z.number()]).nullish(),\n seguro_fianca: z.union([z.boolean(), z.number()]).nullish(),\n mobiliado: z.union([z.boolean(), z.number()]).nullish(),\n\n // Valores monetários\n valor_venda: z.number().nullish(),\n valor_venda_visivel: z.union([z.boolean(), z.number()]).nullish(),\n valor_locacao: z.number().nullish(),\n valor_locacao_visivel: z.union([z.boolean(), z.number()]).nullish(),\n valor_temporada: z.number().nullish(),\n valor_temporada_visivel: z.union([z.boolean(), z.number()]).nullish(),\n valor_condominio: z.number().nullish(),\n valor_condominio_visivel: z.union([z.boolean(), z.number()]).nullish(),\n valor_iptu: z.number().nullish(),\n valor_iptu_visivel: z.union([z.boolean(), z.number()]).nullish(),\n valor_seguro_incendio: z.number().nullish(),\n valor_taxa_limpeza: z.number().nullish(),\n\n // Características físicas\n dormitorios: z.number().nullish(),\n suites: z.number().nullish(),\n banheiros: z.number().nullish(),\n garagens: z.number().nullish(),\n area_total: z.number().nullish(),\n area_privativa: z.number().nullish(),\n area_util: z.number().nullish(),\n andar: z.number().nullish(),\n andar_visivel: z.union([z.boolean(), z.number()]).nullish(),\n\n // Endereço\n endereco_cep: z.string().max(9).nullish(),\n endereco_estado: z.string().nullish(),\n endereco_estado_visivel: z.union([z.boolean(), z.number()]).nullish(),\n endereco_cidade: z.string().nullish(),\n endereco_cidade_visivel: z.union([z.boolean(), z.number()]).nullish(),\n endereco_bairro: z.string().nullish(),\n endereco_bairro_visivel: z.union([z.boolean(), z.number()]).nullish(),\n endereco_zona: z.string().nullish(),\n endereco_logradouro: z.string().nullish(),\n endereco_logradouro_visivel: z.union([z.boolean(), z.number()]).nullish(),\n endereco_numero: z.union([z.string(), z.number()]).nullish(),\n endereco_numero_visivel: z.union([z.boolean(), z.number()]).nullish(),\n endereco_complemento: z.string().nullish(),\n endereco_complemento_visivel: z.union([z.boolean(), z.number()]).nullish(),\n endereco_referencia: z.string().nullish(),\n endereco_referencia_visivel: z.union([z.boolean(), z.number()]).nullish(),\n\n // Coordenadas e geolocalização\n latitude: z.union([z.number(), z.string()]).nullish(),\n longitude: z.union([z.number(), z.string()]).nullish(),\n geoposicionamento_visivel: z.number().nullish(),\n\n // IDs internos\n id_imovel: z.number().nullish(),\n id_estado: z.number().nullish(),\n id_cidade: z.number().nullish(),\n id_bairro: z.number().nullish(),\n id_condominio: z.number().nullish(),\n id_subcondominio: z.number().nullish(),\n id_corretor: z.number().nullish(),\n\n // Campos específicos\n distancia_mar: z.number().nullish(),\n tipo_construcao: z.string().nullish(),\n tipo_piso: z.string().nullish(),\n entrega_ano: z.union([z.number(), z.string()]).nullish(),\n entrega_mes: z.union([z.number(), z.string()]).nullish(),\n posicao: z.string().nullish(),\n posicao_solar: z.string().nullish(),\n tags: z.string().nullish(),\n\n // Terreno — API às vezes envia medidas como string; converter faz Number()\n terreno_frente: z.union([z.number(), z.string()]).nullish(),\n terreno_fundos: z.union([z.number(), z.string()]).nullish(),\n terreno_esquerdo: z.union([z.number(), z.string()]).nullish(),\n terreno_direita: z.union([z.number(), z.string()]).nullish(),\n terreno_total: z.union([z.number(), z.string()]).nullish(),\n medida_terreno_total: z.string().nullish(),\n\n // Pessoas e períodos\n numero_pessoas: z.number().nullish(),\n periodicidade_iptu: z.string().nullish(),\n\n // Condomínio\n condominio_fechado: z.union([z.boolean(), z.number()]).nullish(),\n condominio_nome: z.string().nullish(),\n condominio_tipo: z.string().nullish(),\n condominio_comodidades: z.union([z.string(), z.array(z.string())]).nullish(),\n\n // Características\n imovel_comodidades: z.string().nullish(),\n\n // Rural\n rural: ruralSchema,\n\n // Calendário\n calendario_temporada: z.union([z.string(), z.array(z.any())]).nullish(),\n\n // SEO\n meta_description: z.string().nullish(),\n\n // Mídia\n imagens: z.array(imagemSchema).nullish(),\n videos: z.array(z.any()).nullish(),\n plantas: z.array(z.any()).nullish(),\n tour360: z.array(z.any()).nullish(),\n\n // Configurações\n medida: z.string().nullish(),\n\n // Datas\n data_cadastro: z.string().nullish(),\n data_update: z.string().nullish(),\n data_atualizacao: z.string().nullish(),\n updated_at: z.string().nullish(),\n}).passthrough()\n\nexport type JetimobPropertySchema = z.infer<typeof JetimobPropertySchemaSchema>\n\nexport const validateJetimobPropertySchema = (data: unknown): JetimobPropertySchema => {\n return JetimobPropertySchemaSchema.parse(data)\n}\n\nexport const safeValidateJetimobPropertySchema = (data: unknown) => {\n return JetimobPropertySchemaSchema.safeParse(data)\n}\n","import { type JetimobPropertySchema as JetimobImovel } from '../../schemas/jetimob-property-schema.zod'\nimport { type HorizonPropertySchemaByJetimobType as JetimobPropertyV3 } from '../../schemas/horizon-property-schema-by-jetimob.zod'\n\n/**\n * Converte data do formato \"YYYY-MM-DD HH:mm:ss\" para ISO.\n * Retorna null se não fornecida — NUNCA inventar datas (regra do schema base).\n */\nfunction convertToISO(dateStr?: string): string | null {\n if (!dateStr) return null\n if (dateStr.includes('T')) return dateStr\n return dateStr.replace(' ', 'T') + '.000Z'\n}\n\n/**\n * Hash determinístico (FNV-1a 32-bit) de uma string. Mesmo input → mesmo output.\n */\nfunction hashSeed(seed: string): number {\n let h = 0x811c9dc5\n for (let i = 0; i < seed.length; i++) {\n h ^= seed.charCodeAt(i)\n h = Math.imul(h, 0x01000193)\n }\n return h >>> 0\n}\n\n/**\n * Gera coordenadas aproximadas com offset de ~500m, **determinístico** por imóvel.\n *\n * CRÍTICO: o offset é derivado de um `seed` estável (codigo/id do imóvel), NÃO de\n * `Math.random()`. Se fosse aleatório, cada conversão produziria coordenadas\n * diferentes → `sync_hash` instável → o delta acharia que TODO imóvel com\n * `geoposicionamento_visivel === 2` mudou, re-upsertando em todo sync. (JET-007)\n *\n * O resultado é \"aproximado\" no sentido de ofuscar a localização exata — não\n * precisa ser aleatório a cada chamada, só precisa ser deslocado e estável.\n */\nfunction generateApproximateCoords(\n lat: number,\n lng: number,\n seed: string,\n): { lat: number; lng: number } {\n // 1 grau ≈ 111km, então 500m ≈ 0.0045 graus\n const offsetDegrees = 500 / 111000\n\n const h = hashSeed(seed)\n // Dois \"canais\" do hash → ângulo (0–2π) e distância (0–offsetDegrees), estáveis.\n const angle = (h % 3600) / 3600 * 2 * Math.PI\n const distance = ((h >>> 12) % 1000) / 1000 * offsetDegrees\n\n return {\n lat: lat + Math.sin(angle) * distance,\n lng: lng + Math.cos(angle) * distance,\n }\n}\n\n/**\n * Converte campos do schema base (horizon-property-schema-base.json)\n * Esses são os campos padrão que existem em todos os property models\n */\nexport function convertBaseFields(imovel: JetimobImovel): Partial<JetimobPropertyV3> {\n const result: Partial<JetimobPropertyV3> = {}\n\n // ========== IDENTIFICAÇÃO BÁSICA ==========\n result.source_key = \"jetimob-api-imoveis\"\n result.reference = imovel.codigo\n result.title = imovel.titulo_anuncio || \"Sem título\"\n result.description = imovel.observacoes || \"Sem descrição\"\n\n // ========== DATAS — vêm do CRM, nunca inventadas ==========\n // source_published_at: Jetimob não fornece data de publicação original\n result.source_updated_at = convertToISO(imovel.data_update ?? imovel.updated_at ?? undefined)\n\n // ========== SEO ==========\n // seo_title: Jetimob não tem campo específico - deixar vazio\n if (imovel.meta_description) result.seo_description = imovel.meta_description\n\n // ========== CONFIGURAÇÕES ==========\n result.currency = \"BRL\"\n result.unit_area = (imovel.medida === \"m²\" || imovel.medida === \"m2\") ? \"m2\" : \"m2\"\n result.unit_distance = \"meters\"\n\n // ========== OPERAÇÃO (obrigatório - sem fallback) ==========\n if (imovel.contrato) {\n const partes = imovel.contrato.split(\",\").map((s) => s.trim().toLowerCase())\n const operacoes: Array<\"venda\" | \"locacao\" | \"temporada\"> = []\n\n for (const parte of partes) {\n if (parte === \"compra\" || parte === \"venda\") operacoes.push(\"venda\")\n else if (parte === \"locação\" || parte === \"locacao\" || parte === \"aluguel\") operacoes.push(\"locacao\")\n else if (parte === \"temporada\") operacoes.push(\"temporada\")\n }\n\n if (operacoes.length > 0) result.operacao = operacoes\n }\n // Se não tiver contrato, operacao fica undefined e validação vai falhar (obrigatório)\n\n // ========== VALORES MONETÁRIOS COM VISIBILIDADE ==========\n if (imovel.valor_venda && imovel.valor_venda_visivel) {\n const valor = Number(imovel.valor_venda)\n if (!isNaN(valor) && valor > 0) result.valor_venda = valor\n }\n \n if (imovel.valor_locacao && imovel.valor_locacao_visivel) {\n const valor = Number(imovel.valor_locacao)\n if (!isNaN(valor) && valor > 0) result.valor_locacao = valor\n }\n \n // valor_temporada SÓ se valor_temporada_visivel for true\n if (imovel.valor_temporada && imovel.valor_temporada_visivel) {\n const valor = Number(imovel.valor_temporada)\n if (!isNaN(valor) && valor > 0) result.valor_diaria = valor\n }\n \n // valor_condominio SÓ se valor_condominio_visivel for true\n if (imovel.valor_condominio && imovel.valor_condominio_visivel) {\n const valor = Number(imovel.valor_condominio)\n if (!isNaN(valor) && valor >= 0) result.valor_condominio = valor\n }\n \n // valor_iptu SÓ se valor_iptu_visivel for true\n if (imovel.valor_iptu && imovel.valor_iptu_visivel) {\n const valor = Number(imovel.valor_iptu)\n if (!isNaN(valor) && valor >= 0) result.valor_iptu = valor\n }\n\n // ========== ÁREAS COM VALIDAÇÃO ==========\n if (imovel.area_total) {\n const area = Number(imovel.area_total)\n if (!isNaN(area) && area > 0) result.area_total = area\n }\n if (imovel.area_privativa) {\n const area = Number(imovel.area_privativa)\n if (!isNaN(area) && area > 0) result.area_privativa = area\n }\n if (imovel.area_util) {\n const area = Number(imovel.area_util)\n if (!isNaN(area) && area > 0) result.area_util = area\n }\n\n // ========== DEPENDÊNCIAS ==========\n if (imovel.dormitorios) result.dormitorios = Number(imovel.dormitorios)\n if (imovel.suites) result.suites = Number(imovel.suites)\n if (imovel.banheiros) result.banheiros = Number(imovel.banheiros)\n if (imovel.garagens) result.vagas_garagem = Number(imovel.garagens)\n\n // ========== TIPO ==========\n if (imovel.subtipo) result.tipo = imovel.subtipo\n // Se não tiver subtipo, tipo fica undefined e validação vai falhar (obrigatório)\n\n // ========== LOCALIZAÇÃO (sempre mapear endereços) ==========\n if (imovel.endereco_cep) {\n // Formatar CEP para 00000-000 (9 caracteres com traço)\n const cepLimpo = imovel.endereco_cep.replace(/\\D/g, '') // Remove não-dígitos\n if (cepLimpo.length === 8) {\n result.endereco_cep = `${cepLimpo.slice(0, 5)}-${cepLimpo.slice(5)}`\n } else {\n result.endereco_cep = imovel.endereco_cep\n }\n }\n // Cada campo de endereço respeita sua flag `*_visivel` da Jetimob.\n // A imobiliária marca no CRM o que é privado (padrão do mercado: ocultar\n // logradouro/número/bairro pra não furar o corretor). `*_visivel` vem\n // `true|false` ou `1|0` — truthy = mostra, falsy/ausente = oculta (privacy-first).\n // `endereco_cep` e `endereco_zona` NÃO têm flag na Jetimob → mapeados direto.\n if (imovel.endereco_estado && imovel.endereco_estado_visivel) result.endereco_estado = imovel.endereco_estado\n if (imovel.endereco_cidade && imovel.endereco_cidade_visivel) result.endereco_cidade = imovel.endereco_cidade\n if (imovel.endereco_bairro && imovel.endereco_bairro_visivel) result.endereco_bairro = imovel.endereco_bairro\n if (imovel.endereco_logradouro && imovel.endereco_logradouro_visivel) result.endereco_logradouro = imovel.endereco_logradouro\n if (imovel.endereco_numero && imovel.endereco_numero_visivel) result.endereco_numero = String(imovel.endereco_numero).slice(0, 20)\n if (imovel.endereco_complemento && imovel.endereco_complemento_visivel) result.endereco_complemento = imovel.endereco_complemento\n if (imovel.endereco_referencia && imovel.endereco_referencia_visivel) result.endereco_referencia = imovel.endereco_referencia\n if (imovel.endereco_zona) result.endereco_zona = imovel.endereco_zona\n\n // ========== COORDENADAS COM GEOPOSICIONAMENTO ==========\n // geoposicionamento_visivel: 0 = Não visível, 1 = Exato, 2 = Aproximado\n if (imovel.latitude && imovel.longitude) {\n const lat = Number(imovel.latitude)\n const lng = Number(imovel.longitude)\n\n if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) {\n const geoVisivel = Number(imovel.geoposicionamento_visivel)\n\n if (geoVisivel === 1) {\n // Mostrar localização exata\n result.lat = lat\n result.lng = lng\n } else if (geoVisivel === 2) {\n // Mostrar localização aproximada (offset ~500m, determinístico por imóvel)\n result.geo_aproximado = generateApproximateCoords(\n lat,\n lng,\n String(imovel.codigo ?? imovel.id_imovel ?? `${lat},${lng}`),\n )\n }\n // geoVisivel === 0: não mostra nada (lat/lng e geo_aproximado ficam undefined)\n }\n }\n\n\n // ========== DESTAQUE ==========\n if (imovel.destaque && imovel.destaque !== \"Sem destaque\") {\n if (imovel.destaque === \"Destaque\" || imovel.destaque === \"true\" || String(imovel.destaque) === \"true\") {\n result.destaque = true\n }\n }\n\n // ========== CORRETOR ==========\n if (imovel.id_corretor) {\n result.corretor_key = String(imovel.id_corretor)\n }\n\n // ========== CONDOMÍNIO BÁSICO ==========\n if (imovel.id_condominio) {\n result.condominio_key = String(imovel.id_condominio)\n }\n if (imovel.condominio_nome) {\n result.condominio_nome = imovel.condominio_nome\n }\n\n // ========== TAGS ==========\n if (imovel.tags) {\n result.tags = imovel.tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0)\n }\n\n // ========== PESSOAS ==========\n if (imovel.numero_pessoas) {\n const pessoas = Number(imovel.numero_pessoas)\n if (!isNaN(pessoas) && pessoas > 0) result.numero_pessoas = pessoas\n }\n\n // ========== MÍDIA (discoverMedias) ==========\n // Imagens (array vazio se não tiver)\n if (imovel.imagens && imovel.imagens.length > 0) {\n result.images = imovel.imagens.map((imagem, index) => ({\n full: imagem.link,\n md: imagem.link_thumb || imagem.link,\n sm: imagem.link_thumb || imagem.link,\n cover: index === 0 // primeira imagem como cover\n }))\n\n // Definir main_image como a imagem principal (cover)\n const imagemPrincipal = imovel.imagens[0]\n if (imagemPrincipal) {\n result.main_image = {\n full: imagemPrincipal.link,\n md: imagemPrincipal.link_thumb || imagemPrincipal.link,\n sm: imagemPrincipal.link_thumb || imagemPrincipal.link\n }\n }\n } else {\n result.images = []\n }\n\n // Vídeos — caso exotic: Jetimob armazena só ID YouTube (sem URL completa).\n // Conversor reconstrói URL completa pra cumprir contrato universal do schema\n // base (v3.18+) que pede { url: string } válido. Frontend Horizon usa\n // react-player que detecta o formato e renderiza.\n // Se Jetimob expandir pra outros providers no futuro, ajustar aqui (mapa local\n // dentro deste conversor — peculiaridade desta integration, não shared).\n if (imovel.videos && imovel.videos.length > 0) {\n result.videos = imovel.videos\n .map((video: { link?: string; titulo?: string }) => ({\n url: video.link ? `https://youtube.com/watch?v=${video.link}` : null,\n }))\n .filter((v): v is { url: string } => Boolean(v.url))\n } else {\n result.videos = []\n }\n\n // Tours virtuais (obrigatório - array vazio se não tiver)\n if (imovel.tour360 && imovel.tour360.length > 0) {\n result.virtual_tours = imovel.tour360.map((tour: { link?: string; url?: string } | string) => {\n // Pode vir como objeto { link: \"url\" } ou string direta\n const embedUrl = typeof tour === 'string' ? tour : (tour.link || tour.url || '')\n return { embed_url: embedUrl }\n }).filter(t => t.embed_url)\n } else {\n result.virtual_tours = []\n }\n\n return result\n}","import { type JetimobPropertySchema as JetimobImovel } from '../../schemas/jetimob-property-schema.zod'\nimport { type HorizonPropertySchemaByJetimobType } from '../../schemas/horizon-property-schema-by-jetimob.zod'\n\n/**\n * Converte campos específicos do Jetimob (não existem no schema base)\n * Esses são os campos extras que foram adicionados especificamente para o Jetimob\n */\nexport function convertExtendedFields(imovel: JetimobImovel): Partial<HorizonPropertySchemaByJetimobType> {\n const result: Partial<HorizonPropertySchemaByJetimobType> = {}\n\n // ========== VALORES ADICIONAIS JETIMOB ==========\n if (imovel.valor_seguro_incendio) {\n const valor = Number(imovel.valor_seguro_incendio)\n if (!isNaN(valor) && valor >= 0) result.valor_seguro_incendio = valor\n }\n if (imovel.valor_taxa_limpeza) {\n const valor = Number(imovel.valor_taxa_limpeza)\n if (!isNaN(valor) && valor >= 0) result.valor_taxa_limpeza = valor\n }\n\n // ========== ANDAR ==========\n // Respeita `andar_visivel` — a imobiliária pode marcar o andar como privado\n // no CRM. `truthy` = mostra; `falsy`/ausente = oculta (privacy-first, JET-009).\n if (imovel.andar && imovel.andar_visivel) result.andar = Number(imovel.andar)\n\n // ========== CARACTERÍSTICAS BOOLEANAS ESPECÍFICAS ==========\n if (String(imovel.financiavel) === \"1\" || String(imovel.financiavel) === \"true\") {\n result.financiavel = true\n }\n if (String(imovel.exclusividade) === \"true\" || String(imovel.exclusividade) === \"1\") {\n result.exclusividade = true\n }\n if (String(imovel.permuta) === \"true\" || String(imovel.permuta) === \"1\") {\n result.permuta = true\n }\n if (String(imovel.seguro_fianca) === \"true\" || String(imovel.seguro_fianca) === \"1\") {\n result.seguro_fianca = true\n }\n\n // ========== MOBILIADO ==========\n if (String(imovel.mobiliado) === \"1\" || String(imovel.mobiliado) === \"true\") {\n result.mobiliado = true\n }\n\n // ========== COMODIDADES/CARACTERÍSTICAS ==========\n if (imovel.imovel_comodidades) {\n const comodidadesArray = String(imovel.imovel_comodidades)\n .split(',')\n .map(c => c.trim())\n .filter(c => c.length > 0)\n if (comodidadesArray.length > 0) {\n result.caracteristicas = comodidadesArray\n }\n }\n\n // ========== PLANTAS ==========\n if (imovel.plantas && imovel.plantas.length > 0) {\n result.plantas = imovel.plantas.map((planta: { link?: string } | string) => {\n // Pode vir como objeto { link: \"url\" } ou string direta\n return typeof planta === 'string' ? planta : (planta.link || '')\n }).filter(Boolean)\n }\n\n // ========== CAMPOS ESPECÍFICOS JETIMOB ==========\n // id_imovel: ID interno da Jetimob. Pareia com ListingEntry.ref (/imoveis-ativos)\n // e com o filtro ?id=. Distinto de `reference` (que é o `codigo`).\n if (imovel.id_imovel != null) result.id_imovel = String(imovel.id_imovel)\n if (imovel.tipo) result.finalidade = imovel.tipo\n if (imovel.situacao) result.situacao = imovel.situacao\n if (imovel.distancia_mar) result.distancia_mar = Number(imovel.distancia_mar)\n if (imovel.status) result.status = imovel.status\n if (imovel.posicao) result.posicao = imovel.posicao\n\n // posicao_solar é multi-valor — a Jetimob serializa \"Sul,Leste\" numa string.\n // Vira array (igual caracteristicas/condominio_comodidades) → pesquisável no banco.\n if (imovel.posicao_solar) {\n const arr = String(imovel.posicao_solar)\n .split(',')\n .map(p => p.trim())\n .filter(p => p.length > 0)\n if (arr.length > 0) result.posicao_solar = arr\n }\n\n // ========== CALENDÁRIO TEMPORADA ==========\n if (imovel.calendario_temporada) {\n result.calendario_temporada = Array.isArray(imovel.calendario_temporada) \n ? imovel.calendario_temporada \n : [imovel.calendario_temporada]\n }\n\n // ========== CONDOMÍNIO ESTENDIDO ==========\n if (imovel.condominio_comodidades) {\n const comodidadesCondArray = String(imovel.condominio_comodidades)\n .split(',')\n .map(c => c.trim())\n .filter(c => c.length > 0)\n if (comodidadesCondArray.length > 0) {\n result.condominio_comodidades = comodidadesCondArray\n }\n }\n if (imovel.condominio_tipo) result.condominio_tipo = imovel.condominio_tipo\n if (imovel.id_subcondominio) result.subcondominio_id = String(imovel.id_subcondominio)\n if (String(imovel.condominio_fechado) === \"1\" || String(imovel.condominio_fechado) === \"true\") {\n result.condominio_fechado = true\n }\n\n // ========== CONSTRUÇÃO ==========\n if (imovel.entrega_ano) {\n const ano = Number(imovel.entrega_ano)\n if (!isNaN(ano) && ano > 0) result.entrega_ano = ano\n }\n if (imovel.entrega_mes) {\n const mes = Number(imovel.entrega_mes)\n if (!isNaN(mes) && mes > 0 && mes <= 12) result.entrega_mes = mes\n }\n if (imovel.tipo_construcao) result.tipo_construcao = imovel.tipo_construcao\n\n // tipo_piso é multi-valor — \"Porcelanato,Mármore\" numa string. Vira array.\n if (imovel.tipo_piso) {\n const arr = String(imovel.tipo_piso)\n .split(',')\n .map(p => p.trim())\n .filter(p => p.length > 0)\n if (arr.length > 0) result.tipo_piso = arr\n }\n\n // ========== PERÍODOS ==========\n if (imovel.periodicidade_iptu) result.periodicidade_iptu = imovel.periodicidade_iptu\n\n // ========== TERRENO ==========\n if (imovel.terreno_frente) {\n const medida = Number(imovel.terreno_frente)\n if (!isNaN(medida) && medida > 0) result.terreno_frente = medida\n }\n if (imovel.terreno_fundos) {\n const medida = Number(imovel.terreno_fundos)\n if (!isNaN(medida) && medida > 0) result.terreno_fundos = medida\n }\n if (imovel.terreno_esquerdo) {\n const medida = Number(imovel.terreno_esquerdo)\n if (!isNaN(medida) && medida > 0) result.terreno_esquerdo = medida\n }\n if (imovel.terreno_direita) {\n const medida = Number(imovel.terreno_direita)\n if (!isNaN(medida) && medida > 0) result.terreno_direita = medida\n }\n if (imovel.terreno_total) {\n const area = Number(imovel.terreno_total)\n if (!isNaN(area) && area > 0) res