TheHideout-API/src/utils/properties.js
Tom Butcher b406165866
Some checks failed
thehideout/TheHideout-API/pipeline/head There was a failure building this commit
Implemented Sync Price
2026-01-03 00:55:06 +00:00

366 lines
12 KiB
JavaScript

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);
}