import { extractRichText, transformContent } from "../utils/notion.js"; import { addToNotionDataSource, updateNotionPage } from "../utils/notion.js"; import { fetchSmoobuProperties, fetchSmoobuPropertyDetails, } from "../utils/smoobu.js"; import _ from "lodash"; import diff from "microdiff"; export async function getProperties(env, cached = false) { try { // If cached=true, try to get from Cloudflare Cache API first if (cached) { try { const cache = caches.default; const cacheKey = new Request( `https://${env.CACHE_URL || "cache"}/properties` ); const cachedResponse = await cache.match(cacheKey); if (cachedResponse) { const cachedData = await cachedResponse.json(); console.log("Properties retrieved from Cloudflare cache"); return cachedData; } } catch (cacheError) { console.log("Cache miss or error, falling back to KV:", cacheError); } } // Fall back to KV storage const kvData = await env.CONTENT_KV.get(env.PROPERTIES_KEY, { type: "json", }); // If we were trying to use cache but it failed, store the KV data in cache if (cached && kvData) { try { const cache = caches.default; const cacheKey = new Request( `https://${env.CACHE_URL || "cache"}/properties` ); const response = new Response(JSON.stringify(kvData), { headers: { "Content-Type": "application/json", "Cache-Control": "max-age=60", // 1 minute TTL ETag: `"properties-${Date.now()}"`, // Add ETag for cache validation }, }); await cache.put(cacheKey, response); console.log("Properties stored in Cloudflare cache after KV fallback"); } catch (cacheError) { console.warn("Error storing in cache after KV fallback:", cacheError); } } return kvData || []; } catch (error) { console.log("Error fetching properties cache:", error); return null; } } export async function storeProperties(env, properties) { try { // Always store in KV first await env.CONTENT_KV.put(env.PROPERTIES_KEY, JSON.stringify(properties)); console.log("Properties stored in KV."); // Purge the Cloudflare Cache API try { const cache = caches.default; const cacheKey = new Request( `https://${env.CACHE_URL || "cache"}/properties` ); await cache.delete(cacheKey); console.log("Properties cache purged successfully."); } catch (cacheError) { console.warn( "Error purging Cloudflare cache, but KV was updated successfully:", cacheError ); } } catch (error) { console.error("Error storing properties cache:", error); throw error; } } export function transformSmoobuProperty(env, property) { console.log("Transforming property:", property.id); let street = property.location?.street || ""; // Remove leading number and whitespace if present street = street.replace(/^\d+\s*/, ""); // Split by commas and join with newlines street = street .split(",") .map((s) => s.trim()) .join("\n"); console.log( "Transformed property:", property.amenities.map((feature) => feature.replace(/,/g, ";")) || [] ); return { smoobuId: property.id, name: property.name || "Unknown Property", maxOccupancy: property.rooms?.maxOccupancy || 0, timezone: property.timeZone || "UTC", bedrooms: property.rooms?.bedrooms || 0, bathrooms: property.rooms?.bathrooms || 0, doubleBeds: property.rooms?.doubleBeds || 0, singleBeds: property.rooms?.singleBeds || 0, sofaBeds: property.rooms?.sofaBeds || 0, sofas: property.rooms?.couches || 0, childBeds: property.rooms?.childBeds || 0, queenSizeBeds: property.rooms?.queenSizeBeds || 0, kingSizeBeds: property.rooms?.kingSizeBeds || 0, address: property.location ? `${street || ""}\n${property.location.city || ""}\n${ property.location.zip || "" }`.trim() : "Unknown Address", features: property.amenities.map((feature) => feature.replace(/,/g, ";")) || [], minPrice: property.price?.minimal ? parseFloat(property.price.minimal) : 0, maxPrice: property.price?.maximal ? parseFloat(property.price.maximal) : 0, }; } export function diffProperties(newList, oldList) { // Keys to check for changes (set to null to check all keys) const keysToCheck = [ "name", "maxOccupancy", "bedrooms", "bathrooms", "address", "features", "minPrice", "maxPrice", "timezone", "doubleBeds", "singleBeds", "sofaBeds", "couches", "childBeds", "queenSizeBeds", "kingSizeBeds", ]; // Helper: index by smoobuId using lodash const oldById = _.keyBy(oldList || [], "smoobuId"); const newById = _.keyBy(newList || [], "smoobuId"); // toAdd: in newList but not in oldList const toAdd = newList.filter((p) => !oldById[p.smoobuId]); // toDelete: in oldList but not in newList const toDelete = oldList.filter((p) => !newById[p.smoobuId]); // toUpdate: in both, but with different content (using microdiff) const toUpdate = newList.filter((p) => { const old = oldById[p.smoobuId]; if (!old) return false; // If syncName is false, exclude name from comparison // If syncPrice is false, exclude prices from comparison const { smoobuId, notionId, name, minPrice, maxPrice, ...restNew } = p; const { smoobuId: _, notionId: __, name: oldName, minPrice: oldMinPrice, maxPrice: oldMaxPrice, ...restOld } = old; // Prepare objects for comparison let newObj, oldObj; // Build objects conditionally based on sync flags newObj = { ...restNew }; oldObj = { ...restOld }; if (old.syncName !== false) { // Include name in comparison when syncName is true or undefined newObj.name = name; oldObj.name = oldName; } if (old.syncPrice !== false) { // Include prices in comparison when syncPrice is true or undefined newObj.minPrice = minPrice; newObj.maxPrice = maxPrice; oldObj.minPrice = oldMinPrice; oldObj.maxPrice = oldMaxPrice; } // Use microdiff to get differences const differences = diff(oldObj, newObj); // If no keysToCheck specified, return true if any differences found if (!keysToCheck) { return differences.length > 0; } // Filter differences to only include keys we care about const relevantDifferences = differences.filter((change) => { // Check if the changed path starts with any of our keysToCheck const path = change.path.join("."); return keysToCheck.some((key) => path.startsWith(key)); }); return relevantDifferences.length > 0; }); return { toAdd, toUpdate, toDelete }; } // Transform Notion property data to desired format export async function transformNotionProperty(env, notionProperty) { const properties = notionProperty.properties; const active = properties["Active"].checkbox || false; // Extract image URLs (handle both external and file images) const images = (properties["Images"]?.files || []) .map((f) => f?.external?.url || f?.file?.url) .filter(Boolean); // Extract slug from formula const slug = properties.Slug?.formula?.string || "unknown"; const maxPrice = properties["Max Price"]?.number || 0; const minPrice = properties["Min Price"]?.number || 0; const maxOccupancy = properties["Max Occupancy"]?.number || 0; const bedrooms = properties["Bedrooms"]?.number || 0; const bathrooms = properties["Bathrooms"]?.number || 0; const doubleBeds = properties["Double Beds"]?.number || 0; const singleBeds = properties["Single Beds"]?.number || 0; const sofaBeds = properties["Sofa Beds"]?.number || 0; const sofas = properties["Sofas"]?.number || 0; const childBeds = properties["Child Beds"]?.number || 0; const queenSizeBeds = properties["Queen Size Beds"]?.number || 0; const kingSizeBeds = properties["King Size Beds"]?.number || 0; const timezone = properties["Timezone"]?.select?.name || "UTC"; const bookingcomLink = properties["Booking.com Link"]?.url || "#"; const airbnbLink = properties["Airbnb Link"]?.url || "#"; const address = await extractRichText( properties["Address"]?.rich_text || "unknown" ); const smoobuId = properties["Smoobu ID"]?.number || null; const syncName = properties["Sync Name"]?.checkbox || false; const syncPrice = properties["Sync Price"]?.checkbox || false; const features = properties["Features"]?.["multi_select"].map((feature) => feature.name.replace(/,/g, ";") ); // Extract name from title const name = properties.Name?.title?.[0]?.plain_text || "Untitled"; // Fetch and transform the actual page content from Notion blocks const content = await transformContent(env, notionProperty.id); return { notionId: notionProperty.id, name: name, images: images, slug: slug, maxPrice: maxPrice, minPrice: minPrice, maxOccupancy: maxOccupancy, bedrooms: bedrooms, bathrooms: bathrooms, doubleBeds: doubleBeds, singleBeds: singleBeds, sofaBeds: sofaBeds, sofas: sofas, childBeds: childBeds, queenSizeBeds: queenSizeBeds, kingSizeBeds: kingSizeBeds, timezone: timezone, address: address, features: features, bookingcomLink: bookingcomLink, airbnbLink: airbnbLink, content, smoobuId, syncName, syncPrice, active, }; } // --- Property Utilities moved from routes/properties.js --- export async function addPropertyToNotion(env, property) { console.log("Adding property to Notion:", property.smoobuId); const details = await fetchSmoobuPropertyDetails(env, property.smoobuId); const notionProps = { Name: details.name, "Max Occupancy": details.rooms.maxOccupancy, Bedrooms: details.rooms.bedrooms, Bathrooms: details.rooms.bathrooms, "Double Beds": details.rooms.doubleBeds, "Single Beds": details.rooms.singleBeds, "Sofa Beds": details.rooms.sofaBeds, Sofas: details.rooms.couches, "Child Beds": details.rooms.childBeds, "Queen Size Beds": details.rooms.queenSizeBeds, "King Size Beds": details.rooms.kingSizeBeds, Timezone: { select: { name: details.timeZone || "UTC" } }, Address: `${details.location.street}\n${details.location.city}\n${details.location.zip}`, Features: details.amenities, "Min Price": parseFloat(details.price.minimal), "Max Price": parseFloat(details.price.maximal), "Smoobu ID": details.id, }; Object.keys(notionProps).forEach( (k) => notionProps[k] === undefined && delete notionProps[k] ); const res = await addToNotionDataSource(notionProps, env.PROPERTIES_DB); return { ...property, notionId: res.id }; } export async function updateNotionProperty(env, property, notionId) { console.log("Updating property in Notion:", property.smoobuId); // Use the transformed property data directly instead of fetching from Smoobu again const notionProps = { ...(property.syncName ? { Name: property.name } : {}), "Max Occupancy": property.maxOccupancy, Bedrooms: property.bedrooms, Bathrooms: property.bathrooms, "Double Beds": property.doubleBeds, "Single Beds": property.singleBeds, "Sofa Beds": property.sofaBeds, Sofas: property.sofas, "Child Beds": property.childBeds, "Queen Size Beds": property.queenSizeBeds, "King Size Beds": property.kingSizeBeds, Timezone: { select: { name: property.timezone || "UTC" } }, Address: property.address, Features: property.features, ...(property.syncPrice ? { "Min Price": property.minPrice, "Max Price": property.maxPrice } : {}), }; Object.keys(notionProps).forEach( (k) => notionProps[k] === undefined && delete notionProps[k] ); await updateNotionPage(notionId, notionProps); return { ...property, notionId }; } export async function deleteNotionProperty(env, notionId) { console.log("Deleting property from Notion:", notionId); await updateNotionPage(notionId, {}, true, true); }