Web — Properties (público)
Feed plano de casas publicadas en la web de marketing. Pensado para que otros servicios backend (vivla-api, vivla-mobile, Windmill) lean este endpoint en vez de consultar Webflow. Vive bajo/api/v1/web/ para mantenerse coherente con el resto del módulo web (CMS, locations, amenities) — misma fuente de datos, distinta superficie. La diferencia con /web/properties (sin v1) es que ésa es la cara CMS y devuelve la shape interna PropertyForWeb; esta es la cara pública con shape v1 plano (PropertyPublicDto).
Por defecto la lista devuelve sólo filas con property_web_data.status_web = 'listed' — el mismo conjunto que renderiza vivla.com en /listings — pero acepta un filtro ?status= para traer también coming_soon / sold_out (ver Query params). hidden (el estado CMS “no está en web”) nunca es seleccionable ni sale al wire. Los campos editoriales internos (SEO, drafts, audit metadata) se descartan en el wire.
Base path y auth
tools.vivla.com a secas es el frontend SPA y no sirve la API):
| Entorno | Base URL |
|---|---|
| Producción | https://api.tools.vivla.com/api |
| Desarrollo | https://api.tools.vivla.dev/api |
<base>/docs (p.ej. api.tools.vivla.com/api/docs, tag Web — Properties (public)), con los schemas exactos de request/response.
Auth via el global Auth0OrApiKeyGuard:
| Header | Quién |
|---|---|
x-api-key: $API_KEY | Servicios backend (M2M). El api-key sintetiza permisos tool-web admin internamente. |
Authorization: Bearer <jwt> | Usuarios humanos con permiso tool-web ≥ viewer. |
401 authentication_required / authentication_invalid. El valor de $API_KEY lo comparte el equipo de tools por canal seguro — no está en ningún repo.
Query params
| Param | Tipo | Default | Notas |
|---|---|---|---|
type | enum | — | beach o ski. Filtra por properties.type. |
status | CSV | listed | Lista separada por comas de listed, coming_soon, sold_out (ej. status=listed,coming_soon). Filtra por property_web_data.status_web. Omitirlo mantiene el comportamiento original (sólo listed). Un valor inválido o hidden → 400 validation_error. |
location_id | UUID | — | Filtra por property_web_data.location_id. |
limit | integer | 25 | Máx 100. Valores fuera de rango son 400 validation_error. |
cursor | string | — | Opaco. Pasar verbatim el next_cursor recibido. |
Shape de la lista
Sigue la convención v1 (docs/wiki/api/conventions.mdx):
Shape del item — PropertyPublicDto
Notas de campos
descriptionse almacena enproperty_web_data.descriptioncomo árbol Lexical i18n. Este endpoint lo serializa a texto plano por locale; los párrafos se separan con\n\n. Si necesitás formato rich-text, abrí ticket — podemos exponer tambiéndescription_htmlen una versión futura sin breaking change.hero_imageresuelveproperty_web_data.hero_photo_id. Si no hay heroPhotoId set, cae a la primera foto conweb_section='hero'(web_sort_orderascendente). Devuelvenullcuando no hay ninguna candidata.gallerytrae todas las fotos conis_visible_on_web=true, ordenadas porsort_order. Elsectionpermite agrupar client-side (“hero”, “studio”, etc.).statuses elproperty_web_data.status_web:listed,coming_soonosold_out.hiddennunca sale (ni en lista ni en detalle). En el detalle por id/slug sirve cualquiera de los tres — usa este campo para decidir (una casa referenciada en un lead de MyInvestor puede haber pasado asold_out).hides elproperties.hid(home id de Firebase/vivla-api). Permite mapear el feed contra los homes del consumidor.nullcuando la casa no está sincronizada desde v1.video_urlresuelveproperty_videosvisibles en web (is_visible_on_web=true): prefiere el detype='hero', si no hay usa el de menorsort_order.nullcuando no hay ninguno.location_textes elproperty_web_data.location_text(blurb libre de ubicación por locale). Objeto vacío{}cuando no está seteado.typees el delproperties.typereal (beach/ski). No se hardcodea como en Webflow.urlse construye desde elslug(https://www.vivla.com/listings/<slug>). Cambialo en consumidores si el dominio público se mueve.
Ejemplos
Primera página
Siguiente página
has_more: true. Cuando has_more: false el next_cursor es null.
Detalle por slug o id
PropertyPublicDto (sin envelope object: list). El path param se matchea como properties.id cuando es un UUID, si no como property_web_data.slug. Sirve cualquier status_web excepto hidden — para saber el estado real mirá el campo status. 404 resource_missing si no existe ninguna casa con ese slug/id o si está hidden.
Es la vía recomendada para resolver el nombre (u otros datos) de la casa de un lead de MyInvestor a partir de su id: GET /api/v1/web/properties/:id.
Filtrar por tipo
Filtrar por estado
Errores
| HTTP | error.code | Causa |
|---|---|---|
| 400 | validation_error | limit fuera de rango, location_id no es UUID, type no es beach/ski, status con un valor no permitido (o hidden). |
| 400 | invalid_cursor | El cursor está corrupto. Reiniciar paginación desde la primera página. |
| 401 | authentication_required | Sin header Authorization ni x-api-key. |
| 401 | authentication_invalid | Credenciales enviadas pero inválidas. |
| 404 | resource_missing | /properties/:slug-o-id cuando no matchea ninguna casa, o la casa está hidden. |
| 429 | rate_limit_exceeded | Excedido el límite global (100 req/min por IP por default). Respetar Retry-After. |
| 500 | internal_error | Falla inesperada del servidor. Reintentar con backoff. |
Patrón de cliente recomendado
Migración desde Webflow
Este endpoint reemplaza la lectura directa devivla-api contra Webflow (WEBFLOW_URL + Bearer token). Equivalencias para el adaptador del lado consumidor:
| Webflow item (vivla-api hoy) | PropertyPublicDto | Notas |
|---|---|---|
fieldData.name | name | — |
fieldData.slug | slug | — |
fieldData['main-image'].url | hero_image.url | objeto con alt |
fieldData.description (HTML plano) | description.en (o el locale que el consumidor prefiera) | i18n; texto plano |
fieldData.bedrooms / bathrooms | bedrooms / bathrooms | — |
fieldData.sqm | size | rename |
fieldData['fraction-price'] | price_from (+ fraction_from, weeks_per_year) | shape más rica |
fieldData.gallery[].url | gallery[].url | + alt, section, sort_order |
fieldData.address | address + city | desglosado |
fieldData.coordinates ("lat,lon") | latitude + longitude (numbers) | mejor tipado |
HomeType.BEACH (hardcoded en vivla-api) | type: 'beach' | 'ski' | real |
issold / iscomingsoon | status | enum único (listed/coming_soon/sold_out) en vez de dos booleanos. Ojo: el slug real del switch en Webflow es iscomingsoon (sin guiones) — el DTO v2 de vivla-api lee is-coming-soon y nunca lo detecta. |
fieldData.video.url | video_url | vídeo web principal (hero) |
fieldData['location-text'] | location_text | i18n |
fieldData.amenities (ids → resueltos contra la colección Amenities) | amenities[].slug | ya resueltos; además traen label i18n |
fieldData['destination-data'].fieldData.name | location.name | destino resuelto (id + slug + nombre i18n) |
El
id de cada item deja de ser el id de Webflow y pasa a ser el UUID de tools (properties.id). Hay que avisar a MyInvestor: los ids que guarden/referencien cambian. Para resolver el nombre (u otros datos) de la casa de un lead a partir de su id, GET /api/v1/web/properties/:id (sirve cualquier estado salvo hidden).Transición Webflow → tools. Mientras dure la migración, un sync diario de Windmill (
f/vivla_tools/sync/sync_web_webflow, 07:00 Madrid) mantiene status / precio / coordenadas de tools en paridad con Webflow, de modo que ambas fuentes coinciden hasta que MyInvestor corte del todo con Webflow.Implementación
- Controller:
apps/backend/src/web/controllers/public-properties.controller.ts(la ruta de detalle detecta UUID vs slug conisUUID) - Servicio:
WebPropertiesService.listPublic()+getPublicBySlug()+getPublicById()enapps/backend/src/web/services/web-properties.service.ts - Serializer:
apps/backend/src/web/serializers/property-public.serializer.ts - DTOs:
apps/backend/src/web/dto/property-public.dto.ts
WebPropertiesService.hydrateOne() — el mismo que usa el CMS — y luego aplica toPropertyPublicDto() para producir la shape pública. El detalle por slug/id comparte el gate toPublicDetail() que rechaza hidden con 404.