Initial commit

This commit is contained in:
Tom Butcher 2025-10-11 20:30:13 +01:00
commit 4cf801a488
31 changed files with 10934 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.dev.vars
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
.wrangler/

6896
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "thehideout-api",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy --env production",
"dev": "wrangler dev --test-scheduled --host 0.0.0.0",
"test": "vitest"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.6.4",
"vitest": "~2.1.9",
"wrangler": "^4.38.0"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.80",
"@notionhq/client": "^5.1.0",
"blurhash": "^2.0.5",
"dayjs": "^1.11.18",
"jpeg-js": "^0.4.4",
"lodash": "^4.17.21",
"microdiff": "^1.5.0",
"remove-svg-properties": "^0.3.4",
"sharp": "^0.34.3",
"svgson": "^5.3.1",
"upng-js": "^2.1.0"
}
}

78
src/index.js Normal file
View File

@ -0,0 +1,78 @@
import { handleContactRequest } from "./routes/contact.js";
import { handleContentRequest } from "./routes/content.js";
import { handleNotionHook, handleSmoobuHook } from "./routes/hooks.js";
import { globalHeaders } from "./utils/api.js";
import { handleBlurhashUpdate } from "./utils/imageCache.js";
import { refreshBookingCache } from "./objects/bookings.js";
import { updateAllSmoobuData } from "./routes/hooks.js";
import { importImages } from "./objects/images.js";
async function handleRequest(request, env) {
if (
request.method === "OPTIONS" &&
request.url.split("?")[0].endsWith("/contact")
) {
console.log("Handling contact OPTIONS request...");
return new Response(null, {
status: 204, // No Content
headers: {
"Access-Control-Allow-Origin": env.CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
if (
request.method === "POST" &&
request.url.split("?")[0].endsWith("/contact")
) {
return await handleContactRequest(request, env);
}
if (
request.method === "GET" &&
request.url.split("?")[0].endsWith("/content")
) {
return await handleContentRequest(request, env);
}
if (
request.method === "POST" &&
request.url.split("?")[0].endsWith("/notionHook")
) {
return await handleNotionHook(request, env);
}
if (
request.method === "POST" &&
request.url.split("?")[0].endsWith("/smoobuHook")
) {
return await handleSmoobuHook(request, env);
}
// Return 404 if the route is not found
return new Response("Not Found", { status: 404, headers: globalHeaders });
}
async function handleScheduledEvent(event, env) {
console.log("Scheduled event:", event.cron);
switch (event.cron) {
case "*/5 * * * *":
await updateAllSmoobuData(env);
break;
case "* * * * *":
await refreshBookingCache(env);
break;
default:
break;
}
}
export default {
async fetch(request, env) {
return await handleRequest(request, env);
},
async scheduled(event, env) {
return await handleScheduledEvent(event, env);
},
};

222
src/objects/bookings.js Normal file
View File

@ -0,0 +1,222 @@
import {
transformSmoobuBooking,
transformNotionBooking,
diffBookings,
addBookingToNotion,
updateNotionBooking,
deleteNotionBooking,
} from "../utils/bookings.js";
import { fetchSmoobuBookings } from "../utils/smoobu.js";
import { storeBookingsCache, getBookingsCache } from "../utils/bookings.js";
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
import { getProperties } from "../utils/properties.js";
import { getGuestsCache } from "../utils/guests.js";
import { unionBy } from "lodash";
const PAGE_SIZE = 25;
// Fetch Notion pages (raw data)
export async function importNotionBookings(env, notionId = null) {
console.log("Importing bookings from Notion...");
let bookingsData = [];
if (notionId !== null) {
bookingsData = [await getNotionPage(notionId)];
} else {
const currentDate = new Date().toISOString(); // current date-time in ISO 8601
bookingsData = await queryNotionDataSource(env.BOOKINGS_DB, {
filter: {
or: [
{
property: "Status",
status: {
equals: "Hosting",
},
},
{
property: "Date",
date: {
on_or_after: currentDate,
},
},
],
},
});
}
const propertiesData = await getProperties(env);
const guestsData = await getGuestsCache(env);
const bookings = (
await Promise.all(
bookingsData.map(async (booking) =>
transformNotionBooking(env, booking, propertiesData, guestsData)
)
)
)
.filter(Boolean)
.filter((booking) => booking !== null);
if (bookings.length === 0) {
console.log("No bookings to import.");
return [];
}
// If notionId is not null, use lodash unionBy to replace existing bookings
if (notionId !== null) {
const cachedBookings = (await getBookingsCache(env)) || [];
const mergedBookings = unionBy(bookings, cachedBookings, "notionId");
await storeBookingsCache(env, mergedBookings);
console.log("Bookings imported from Notion and merged with cache.");
return mergedBookings;
}
await storeBookingsCache(env, bookings);
console.log("Bookings imported from Notion.");
return bookings;
}
export async function importSmoobuBookings(env) {
console.log("Importing bookings from Smoobu...");
const bookingsData = await fetchSmoobuBookings(env, {
showCancellation: true,
});
const propertiesData = await getProperties(env);
const guestsData = await getGuestsCache(env);
const smoobuBookings = (
await Promise.all(
bookingsData.map(async (booking) =>
transformSmoobuBooking(env, booking, propertiesData, guestsData)
)
)
)
.filter(Boolean)
.filter((booking) => booking !== null);
// Get the current cache (source of truth)
const cachedBookings = (await getBookingsCache(env)) || [];
// Diff Smoobu bookings with cache
const { toAdd, toUpdate, toDelete } = diffBookings(
smoobuBookings,
cachedBookings
);
console.log("Smoobu bookings:", smoobuBookings.length);
console.log(
"Add:",
toAdd.length,
"Update:",
toUpdate.length,
"Delete:",
toDelete.length
);
// Add new bookings to Notion
const added = [];
for (const booking of toAdd) {
const res = await addBookingToNotion(env, booking);
added.push(res);
}
// Update existing bookings in Notion
const updated = [];
for (const booking of toUpdate) {
const cached = cachedBookings.find((b) => b.smoobuId === booking.smoobuId);
if (!cached?.notionId) continue;
const res = await updateNotionBooking(env, booking, cached.notionId);
updated.push(res);
}
// Delete bookings from Notion (archive)
for (const booking of toDelete) {
if (booking.notionId) {
await deleteNotionBooking(env, booking.notionId);
}
}
// Build new cache
const { buildListCache } = await import("../utils/notion.js");
const newCache = buildListCache(cachedBookings, {
added,
updated,
deleted: toDelete,
});
if (added.length > 0 || updated.length > 0 || toDelete.length > 0) {
await storeBookingsCache(env, newCache);
console.log("Imported bookings from Smoobu.");
} else {
console.log("No changes detected, cache not updated.");
}
return newCache;
}
export async function refreshBookingCache(env) {
console.log("Refreshing booking cache...");
const bookingsData = await getBookingsCache(env, true);
const now = new Date();
const bookings = [];
let hasChanges = false;
console.log("Bookings data:", bookingsData.length);
for (const booking of bookingsData) {
const checkInDate = new Date(booking.checkIn);
const checkOutDate = new Date(booking.checkOut);
const isHosting = now >= checkInDate && now <= checkOutDate;
const isComplete = now >= checkOutDate;
if (isHosting && booking.status == "confirmed") {
console.log(
"Booking state changed (Confirmed -> Hosting):",
booking.notionId
);
const updatedBooking = await updateNotionBooking(
env,
{ ...booking, status: "hosting" },
booking.notionId
);
bookings.push({ ...updatedBooking });
hasChanges = true;
} else if (isComplete && booking.status == "hosting") {
console.log(
"Booking state changed (Hosting -> Complete):",
booking.notionId
);
await updateNotionBooking(
env,
{ ...booking, status: "complete" },
booking.notionId
);
hasChanges = true;
continue;
} else if (
isComplete &&
(booking.status == "cancelled" || booking.status == "complete")
) {
console.log("Flushing old booking:", booking.notionId);
// Skip cancelled bookings that have passed checkout
hasChanges = true;
continue;
} else {
bookings.push({ ...booking });
}
}
// Only update cache if there were changes
if (hasChanges) {
await storeBookingsCache(env, bookings);
console.log("Booking cache refreshed with changes.");
} else {
console.log("No changes detected, cache not updated.");
}
return bookings;
}
export async function deleteNotionBookingFromCache(env, notionId) {
const cachedBookings = await getBookingsCache(env);
const newCache = cachedBookings.filter((b) => b.notionId !== notionId);
await storeBookingsCache(env, newCache);
console.log("Deleted booking from cache.");
return newCache;
}

118
src/objects/guests.js Normal file
View File

@ -0,0 +1,118 @@
import {
transformSmoobuGuest,
transformNotionGuest,
diffGuests,
addGuestToNotion,
updateNotionGuest,
deleteNotionGuest,
} from "../utils/guests.js";
import { fetchSmoobuGuests } from "../utils/smoobu.js";
import { storeGuestsCache, getGuestsCache } from "../utils/guests.js";
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
import { unionBy } from "lodash";
const PAGE_SIZE = 25;
// Fetch Notion pages (raw data)
export async function importNotionGuests(env, notionId = null) {
console.log("Importing guests from Notion...");
let guestsData = [];
if (notionId !== null) {
guestsData = [await getNotionPage(notionId)];
} else {
guestsData = await queryNotionDataSource(env.GUESTS_DB);
}
const guests = (
await Promise.all(
guestsData.map(async (guest) => transformNotionGuest(env, guest))
)
).filter(Boolean);
// If notionId is not null, use lodash unionBy to replace existing guests
if (notionId !== null) {
const cachedGuests = (await getGuestsCache(env)) || [];
const mergedGuests = unionBy(guests, cachedGuests, "notionId");
await storeGuestsCache(env, mergedGuests);
console.log("Guests imported from Notion and merged with cache.");
return mergedGuests;
}
await storeGuestsCache(env, guests);
console.log("Guests imported from Notion.");
return guests;
}
export async function importSmoobuGuests(env) {
console.log("Importing guests from Smoobu...");
const guestsData = await fetchSmoobuGuests(env);
const smoobuGuests = (
await Promise.all(
guestsData.map(async (guest) => transformSmoobuGuest(env, guest))
)
).filter(Boolean);
// Get the current cache (source of truth)
const cachedGuests = (await getGuestsCache(env)) || [];
// Diff Smoobu guests with cache
const { toAdd, toUpdate, toDelete } = diffGuests(smoobuGuests, cachedGuests);
console.log("Smoobu guests:", smoobuGuests.length);
console.log(
"Add:",
toAdd.length,
"Update:",
toUpdate.length,
"Delete:",
toDelete.length
);
// Add new guests to Notion
const added = [];
for (const guest of toAdd) {
const res = await addGuestToNotion(env, guest);
added.push(res);
}
// Update existing guests in Notion
const updated = [];
for (const guest of toUpdate) {
const cached = cachedGuests.find((g) => g.smoobuId === guest.smoobuId);
if (!cached?.notionId) continue;
const res = await updateNotionGuest(env, guest, cached.notionId);
updated.push(res);
}
// Delete guests from Notion (archive)
for (const guest of toDelete) {
if (guest.notionId) {
await deleteNotionGuest(env, guest.notionId);
}
}
// Build new cache
const { buildListCache } = await import("../utils/notion.js");
const newCache = buildListCache(cachedGuests, {
added,
updated,
deleted: toDelete,
});
// Store updated cache
if (added.length > 0 || updated.length > 0 || toDelete.length > 0) {
await storeGuestsCache(env, newCache);
console.log("Imported guests from Smoobu.");
} else {
console.log("No changes detected, cache not updated.");
}
return newCache;
}
export async function deleteNotionGuestFromCache(env, notionId) {
const cachedGuests = await getGuestsCache(env);
const newCache = cachedGuests.filter((g) => g.notionId !== notionId);
await storeGuestsCache(env, newCache);
console.log("Deleted guest from cache.");
return newCache;
}

17
src/objects/images.js Normal file
View File

@ -0,0 +1,17 @@
import { getPages } from "../utils/pages.js";
import { getProperties } from "../utils/properties.js";
import { collectImageUrls, updateImages } from "../utils/imageCache.js";
export async function importImages(env) {
// Fetch caches
console.log("Importing images from Notion...");
const pages = (await getPages(env)) || [];
const properties = (await getProperties(env)) || [];
// Collect all image URLs
const imageUrls = collectImageUrls(pages, properties);
const updatedImages = await updateImages(env, imageUrls);
console.log("Imported images from Notion.");
return updatedImages;
}

24
src/objects/navigation.js Normal file
View File

@ -0,0 +1,24 @@
import { queryNotionDataSource } from "../utils/notion.js";
import { transformNavigation, storeNavigation } from "../utils/navigation.js";
export async function importNotionNavigation(env) {
console.log("Importing navigation from Notion...");
const notionPages = await queryNotionDataSource(env.PAGES_DB);
const notionProperties = await queryNotionDataSource(env.PROPERTIES_DB);
// Transform the incoming pages for the given type
const navigationItems = (
await Promise.all(
notionPages.map(async (item) => transformNavigation(item, "page")),
notionProperties.map(async (item) =>
transformNavigation(item, "property")
)
)
).filter(Boolean);
// Store the updated cache
await storeNavigation(env, navigationItems);
console.log("Navigation imported from Notion.");
return navigationItems;
}

64
src/objects/pages.js Normal file
View File

@ -0,0 +1,64 @@
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
import { transformPageData, storePages, getPages } from "../utils/pages.js";
import { getProperties } from "../utils/properties.js";
import { getSettings } from "../utils/settings.js";
// Fetch Notion pages (raw data)
export async function importNotionPages(env, notionId = null) {
console.log("Importing pages from Notion...");
const propertiesData = await getProperties(env);
const settingsData = await getSettings(env);
let pagesData = [];
if (notionId !== null) {
pagesData = [await getNotionPage(notionId)];
} else {
pagesData = await queryNotionDataSource(env.PAGES_DB, {
sorts: [{ property: "Order", direction: "ascending" }],
});
}
const pages = (
await Promise.all(
pagesData.map(async (page) =>
transformPageData(env, page, propertiesData, settingsData)
)
)
).filter(Boolean);
if (notionId !== null) {
const cachedPages = await getPages(env);
console.log("Cached pages:", cachedPages);
console.log("Pages:", pages);
// Create a map of new pages by notionId for quick lookup
const newPagesMap = new Map(pages.map((page) => [page.notionId, page]));
// Merge: preserve cached order, but replace with new data if available
const mergedPages = cachedPages.map((cachedPage) => {
return newPagesMap.has(cachedPage.notionId)
? newPagesMap.get(cachedPage.notionId)
: cachedPage;
});
// Add any new pages that weren't in cache
const existingNotionIds = new Set(cachedPages.map((page) => page.notionId));
const newPages = pages.filter(
(page) => !existingNotionIds.has(page.notionId)
);
mergedPages.push(...newPages);
await storePages(env, mergedPages);
console.log("Pages imported from Notion and merged with cache.");
return mergedPages;
}
await storePages(env, pages);
console.log("Pages imported from Notion.");
return pages;
}
export async function deleteNotionPageFromCache(env, notionId) {
const cachedPages = await getPages(env);
const newCache = cachedPages.filter((b) => b.notionId !== notionId);
await storePages(env, newCache);
console.log("Deleted page from cache.");
return newCache;
}

134
src/objects/properties.js Normal file
View File

@ -0,0 +1,134 @@
import {
buildListCache,
queryNotionDataSource,
getNotionPage,
} from "../utils/notion.js";
import {
addPropertyToNotion,
updateNotionProperty,
deleteNotionProperty,
getProperties,
storeProperties,
transformSmoobuProperty,
transformNotionProperty,
diffProperties,
} from "../utils/properties.js";
import {
fetchSmoobuProperties,
fetchSmoobuPropertyDetails,
} from "../utils/smoobu.js";
import { unionBy } from "lodash";
export async function importSmoobuProperties(env) {
console.log("Importing properties from Smoobu...");
const propertiesData = await fetchSmoobuProperties(env);
const smoobuProperties = (
await Promise.all(
propertiesData.map(async (property) => {
const propertyData = await fetchSmoobuPropertyDetails(env, property.id);
return transformSmoobuProperty(env, propertyData);
})
)
).filter(Boolean);
// Get the current cache (source of truth)
var cachedProperties = (await getProperties(env)) || [];
// Diff Smoobu properties with cache
const { toAdd, toUpdate, toDelete } = diffProperties(
smoobuProperties,
cachedProperties
);
console.log("Smoobu properties:", smoobuProperties.length);
console.log(
"Add:",
toAdd.length,
"Update:",
toUpdate.length,
"Delete:",
toDelete.length
);
// Add new properties to Notion
const added = [];
for (const property of toAdd) {
const res = await addPropertyToNotion(env, property);
added.push(res);
}
// Update existing properties in Notion
const updated = [];
for (const property of toUpdate) {
const cached = cachedProperties.find(
(g) => g.smoobuId === property.smoobuId
);
if (!cached?.notionId) continue;
const res = await updateNotionProperty(env, property, cached.notionId);
updated.push(res);
}
// Delete properties from Notion (archive)
for (const property of toDelete) {
if (property.notionId) {
await deleteNotionProperty(env, property.notionId);
}
}
// Build new cache
const { buildListCache } = await import("../utils/notion.js");
const newCache = buildListCache(cachedProperties, {
added,
updated,
deleted: toDelete,
});
// Store updated cache
if (added.length > 0 || updated.length > 0 || toDelete.length > 0) {
await storeProperties(env, newCache);
console.log("Imported properties from Smoobu.");
} else {
console.log("No changes detected, cache not updated.");
}
return newCache;
}
export async function importNotionProperties(env, notionId = null) {
console.log("Importing properties from Notion...");
let propertiesData = [];
if (notionId !== null) {
propertiesData = [await getNotionPage(notionId)];
} else {
propertiesData = await queryNotionDataSource(env.PROPERTIES_DB);
}
const properties = (
await Promise.all(
propertiesData.map(async (property) =>
transformNotionProperty(env, property)
)
)
).filter(Boolean);
// If notionId is not null, use lodash unionBy to replace existing properties
if (notionId !== null) {
const cachedProperties = await getProperties(env);
const mergedProperties = unionBy(properties, cachedProperties, "notionId");
await storeProperties(env, mergedProperties);
console.log("Imported properties from Notion and merged with cache.");
return mergedProperties;
}
await storeProperties(env, properties);
console.log("Imported properties from Notion.");
return properties;
}
export async function deleteNotionPropertyFromCache(env, notionId) {
const cachedProperties = await getProperties(env);
const newCache = cachedProperties.filter((b) => b.notionId !== notionId);
await storeProperties(env, newCache);
console.log("Deleted property from cache.");
return newCache;
}

40
src/objects/settings.js Normal file
View File

@ -0,0 +1,40 @@
import { queryNotionDataSource } from "../utils/notion.js";
import {
transformThemes,
transformBranding,
transformRedirectsData,
transformGlobalThemesData,
storeSettings,
} from "../utils/settings.js";
export async function importNotionSettings(env) {
console.log("Importing settings from Notion...");
const [globalThemesData, themesData, redirectsData, brandingData] =
await Promise.all([
queryNotionDataSource(env.GLOBAL_THEMES_DB),
queryNotionDataSource(env.THEMES_DB),
queryNotionDataSource(env.REDIRECTS_DB),
queryNotionDataSource(env.BRANDING_DB),
]);
console.log("Fetched settings from Notion.");
const themes = await transformThemes(themesData);
const globalThemes = transformGlobalThemesData(env, globalThemesData, themes);
const branding = await transformBranding(brandingData);
const redirects = await transformRedirectsData(env, redirectsData);
await storeSettings(env, {
globalThemes,
themes,
branding,
redirects,
});
return {
globalThemes,
themes,
branding,
redirects,
};
}

24
src/routes/blurHash.js Normal file
View File

@ -0,0 +1,24 @@
export async function handleProcessBlurhash(env) {
try {
const imagesData = await getImages(env);
if (imagesData) {
console.log("Found cached content!");
await processBlurhashes(imagesData);
} else {
console.log("No images found in cache!");
}
} catch (error) {
console.error("Error in handleProcessBlurhash:", error);
return new Response(
JSON.stringify({
error: "Failed to process blurhash!",
message: error.message,
}),
{
status: 500,
headers: globalHeaders,
}
);
}
}

85
src/routes/contact.js Normal file
View File

@ -0,0 +1,85 @@
import { globalHeaders } from "../utils/api.js";
import { addToNotionDataSource } from "../utils/notion.js";
import { env } from "cloudflare:workers";
const TURNSTILE_ENABLED = true;
export async function handleContactRequest(request, env) {
const { name, email, message, token } = await request.json();
if (!email || !token || !message || !name) {
return new Response(
JSON.stringify({
message: "Email, message, name and token are required",
code: "missing-fields",
}),
{
status: 400,
headers: globalHeaders,
},
);
}
// Extract the IP address from the request headers
const ip = request.headers.get("CF-Connecting-IP");
if (TURNSTILE_ENABLED) {
// Verify the Turnstile token
const verificationResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: globalHeaders,
body: JSON.stringify({
secret: env.TURNSTILE_AUTH,
response: token,
}),
},
);
const verificationData = await verificationResponse.json();
if (!verificationData.success) {
const code = verificationData["error-codes"][0];
var errorMessage = "Captcha error.";
if (code == "timeout-or-duplicate") {
errorMessage = "Captcha expired.";
}
return new Response(
JSON.stringify({ message: errorMessage, code: `captcha-${code}` }),
{
status: 400,
headers: globalHeaders,
},
);
}
}
// Add the email and location to Notion
try {
await addToNotionDataSource(
{
Name: name,
Email: email,
Message: message,
["IP Address"]: ip,
},
env.MESSAGES_DB,
);
return new Response(
JSON.stringify({
success: true,
message: "Email processed and added to Notion",
}),
{
headers: globalHeaders,
},
);
} catch (error) {
return new Response(
JSON.stringify({ message: "Error storing data.", code: "storage-error" }),
{
status: 500,
headers: globalHeaders,
},
);
}
}

69
src/routes/content.js Normal file
View File

@ -0,0 +1,69 @@
import { globalHeaders } from "../utils/api.js";
import { getCombinedCachedContent } from "../utils/contentCache.js";
import { importNotionProperties } from "../objects/properties.js";
import { importNotionPages } from "../objects/pages.js";
import { importNotionSettings } from "../objects/settings.js";
import { importNotionNavigation } from "../objects/navigation.js";
import { importImages } from "../objects/images.js";
// Fetch or return cached content
export async function getCachedContent(env) {
// Try to get combined cached content
var cachedContent = await getCombinedCachedContent(env);
const noProperties = cachedContent.properties?.length === 0;
const noPages = cachedContent.pages?.length === 0;
const noSettings = cachedContent.settings == {};
if (noProperties || noPages || noSettings) {
await importNotionNavigation(env);
}
if (noProperties) {
cachedContent.properties = await importNotionProperties(env);
cachedContent.images = await importImages(env);
}
if (noPages) {
cachedContent.pages = await importNotionPages(env);
cachedContent.images = await importImages(env);
}
if (noSettings) {
cachedContent.settings = await importNotionSettings(env);
}
if (cachedContent) {
return new Response(
JSON.stringify({
...cachedContent,
properties: cachedContent.properties.filter(
(property) => property.active == true
),
}),
{
headers: globalHeaders,
}
);
}
return new Response(JSON.stringify({}), { headers: globalHeaders });
}
export async function handleContentRequest(request, env) {
try {
return await getCachedContent(env);
} catch (error) {
console.error("Error handling content request:", error);
return new Response(
JSON.stringify({
error: "Failed to fetch content",
message: error.message,
}),
{
status: 500,
headers: globalHeaders,
}
);
}
}

259
src/routes/hooks.js Normal file
View File

@ -0,0 +1,259 @@
import { importNotionNavigation } from "../objects/navigation";
import { importNotionSettings } from "../objects/settings";
import { importNotionPages, deleteNotionPageFromCache } from "../objects/pages";
import {
importNotionProperties,
importSmoobuProperties,
deleteNotionPropertyFromCache,
} from "../objects/properties";
import { globalHeaders } from "../utils/api";
import { importImages } from "../objects/images";
import {
importNotionBookings,
importSmoobuBookings,
deleteNotionBookingFromCache,
} from "../objects/bookings";
import {
importSmoobuGuests,
importNotionGuests,
deleteNotionGuestFromCache,
} from "../objects/guests";
import { handleBlurhashUpdate } from "../utils/imageCache";
async function updateAllNotionData(env) {
await importNotionNavigation(env);
const settings = await importNotionSettings(env);
const pages = await importNotionPages(env);
const properties = await importNotionProperties(env);
const bookings = await importNotionBookings(env);
return { settings, pages, properties, bookings };
}
export async function updateAllSmoobuData(env) {
const properties = await importSmoobuProperties(env);
const guests = await importSmoobuGuests(env);
const bookings = await importSmoobuBookings(env);
return { properties, guests, bookings };
}
export async function handleNotionHook(request, env) {
try {
const body = await request.json();
if (body?.verification_token) {
console.log("Verification token received:", body.verification_token);
return new Response(
JSON.stringify({
status: "gotVerificationToken",
}),
{ headers: globalHeaders }
);
}
console.log("Notion hook received:", body.type);
if (
body.type == "page.properties_updated" ||
body.type == "page.content_updated" ||
(body.type == "page.created" && body.data?.parent?.data_source_id)
) {
const dataSourceId = body.data.parent.data_source_id;
const entityId = body?.entity?.id || null;
console.log("Data source ID:", dataSourceId);
if (dataSourceId === env.PAGES_DB) {
console.log("Importing pages from Notion...");
await importNotionNavigation(env);
const pages = await importNotionPages(env, entityId);
const images = await importImages(env);
await handleBlurhashUpdate(request, env);
return new Response(
JSON.stringify({
status: "OK",
content: { pages, images },
}),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.PROPERTIES_DB) {
await importNotionNavigation(env);
const properties = await importNotionProperties(env, entityId);
const images = await importImages(env);
await handleBlurhashUpdate(request, env);
return new Response(
JSON.stringify({
status: "OK",
content: { properties, images },
}),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.THEMES_DB) {
const settings = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { settings } }),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.REDIRECTS_DB) {
const redirects = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { redirects } }),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.BRANDING_DB) {
const branding = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { branding } }),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.BOOKINGS_DB) {
const bookings = await importNotionBookings(env); // don't include entityId so whole list gets updated with filters.
return new Response(
JSON.stringify({ status: "OK", content: { bookings } }),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.GUESTS_DB) {
const guests = await importNotionGuests(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { guests } }),
{
headers: globalHeaders,
}
);
}
console.log("Page properties with data source:", dataSourceId);
}
if (
body.type == "page.properties_updated" ||
(body.type == "page.deleted" && body.data?.parent?.data_source_id)
) {
const dataSourceId = body.data.parent.data_source_id;
const entityId = body?.entity?.id || null;
console.log("Data source ID:", dataSourceId);
console.log("Entity ID:", entityId);
switch (dataSourceId) {
case env.PAGES_DB:
await importNotionSettings(env);
const pages = await deleteNotionPageFromCache(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { pages } }),
{
headers: globalHeaders,
}
);
case env.PROPERTIES_DB:
await importNotionNavigation(env);
const properties = await deleteNotionPropertyFromCache(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { properties } }),
{
headers: globalHeaders,
}
);
case env.THEMES_DB:
const settings = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { settings } }),
{
headers: globalHeaders,
}
);
case env.REDIRECTS_DB:
const redirects = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { redirects } }),
{
headers: globalHeaders,
}
);
case env.BRANDING_DB:
const branding = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { branding } }),
{
headers: globalHeaders,
}
);
case env.BOOKINGS_DB:
const bookings = await deleteNotionBookingFromCache(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { bookings } }),
{
headers: globalHeaders,
}
);
case env.GUESTS_DB:
const guests = await deleteNotionGuestFromCache(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { guests } }),
{
headers: globalHeaders,
}
);
}
console.log("Page properties with data source:", dataSourceId);
}
console.log("Unknown hook:", body.type);
return new Response(
JSON.stringify({ status: "Unknown hook", type: body.type }),
{
headers: globalHeaders,
}
);
} catch (error) {
throw error;
console.log("No body to parse. Calling both fetch requests...");
const content = await updateAllNotionData(env);
return new Response(JSON.stringify({ status: "OK", content }), {
headers: globalHeaders,
});
}
}
export async function handleSmoobuHook(request, env) {
try {
const body = await request.json();
if (body?.verification_token) {
console.log("Verification token received:", body.verification_token);
return new Response(
JSON.stringify({
status: "gotVerificationToken",
}),
{ headers: globalHeaders }
);
}
console.log("Body received:", body);
if (
body.type == "page.properties_updated" &&
body.data?.parent?.data_source_id
) {
console.log("Got body to parse. Calling updateAllSmoobuData...");
const content = await updateAllSmoobuData(env);
return new Response(JSON.stringify({ status: "OK", content }), {
headers: globalHeaders,
});
}
} catch (_) {
console.log("No body to parse. Calling call fetch requests...");
const content = await updateAllSmoobuData(env);
return new Response(JSON.stringify({ status: "OK", content }), {
headers: globalHeaders,
});
}
}

6
src/utils/api.js Normal file
View File

@ -0,0 +1,6 @@
import { env } from "cloudflare:workers";
export const globalHeaders = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": env.CORS_ORIGIN,
};

110
src/utils/blurHash.js Normal file
View File

@ -0,0 +1,110 @@
import { encode } from "blurhash";
import JPEG from "jpeg-js";
import { env } from "cloudflare:workers";
// Function to decode JPEG image data using jpeg-js (Cloudflare Workers compatible)
export function decodeJPEG(arrayBuffer) {
try {
const buffer = new Uint8Array(arrayBuffer);
console.log("Decoding JPG...");
const jpeg = JPEG.decode(buffer, { useTArray: true });
console.log("Decoded JPG.");
if (!jpeg) {
throw new Error("Failed to decode JPEG");
}
return {
data: jpeg.data,
width: jpeg.width,
height: jpeg.height,
};
} catch (error) {
throw new Error(`JPEG decode error: ${error.message}`);
}
}
// Function to detect image type from URL or content (only JPEG)
export function detectImageType(url) {
const urlLower = url.toLowerCase();
if (urlLower.includes(".jpg") || urlLower.includes(".jpeg")) {
return "jpeg";
}
// Default to JPEG for backward compatibility
return "jpeg";
}
// Function to fetch an image and generate a blur hash (no caching, JPEG only)
export async function fetchBlurHash(url) {
try {
if (env.BLUR_HASH === "false") {
console.log("Blur hash disabled");
return "LDMZ?kMaD$?w.9R:NIIU=V?bw[RP";
}
console.log("Fetching URL for blur hash:", url);
const options = {
cf: {
image: {
compression: "fast",
width: 100,
height: 100,
fit: "cover",
quality: 80,
format: "jpeg",
},
},
};
const response = await fetch(url, options);
console.log("Fetch complete.");
if (!response.ok) {
console.log("Failed to fetch image:", response.status);
return null;
}
let arrayBuffer = await response.arrayBuffer();
const transformed = await env.IMAGES.input(arrayBuffer)
.transform({ width: 128, height: 128 }) // can combine width/height
.output({ format: "image/jpeg" });
const compressedArrayBuffer =
(await transformed.arrayBuffer?.()) ||
(await transformed.response().arrayBuffer());
const fileSizeMB = (
compressedArrayBuffer.byteLength /
(1024 * 1024)
).toFixed(2);
console.log(
"File size:",
compressedArrayBuffer.byteLength,
"bytes (",
fileSizeMB,
"MB)",
);
// Decode JPEG
const decodedImage = decodeJPEG(compressedArrayBuffer);
const { data, width, height } = decodedImage;
// Generate blur hash
console.log("Encoding blur hash...");
const blurHash = encode(data, width, height, 4, 3);
console.log("Encoded blur hash.");
// Clear memory
console.log("Clearing memory...");
data.fill(0);
arrayBuffer = null;
return blurHash;
} catch (error) {
console.error(`Error generating blur hash for ${url}:`, error);
return null;
}
}

455
src/utils/bookings.js Normal file
View File

@ -0,0 +1,455 @@
import { getItemByNotionId, toCamelCase } from "./notion.js";
import { getItemBySmoobuId } from "./smoobu.js";
import { updateNotionPage, addToNotionDataSource } from "./notion.js";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import diff from "microdiff";
dayjs.extend(utc);
dayjs.extend(timezone);
function convertBookingDatesToISO(booking, timezone) {
const arrivalISO =
booking.arrival && booking["check-in"]
? dayjs
.tz(`${booking.arrival}T${booking["check-in"]}:00`, timezone || "UTC")
.toISOString()
: null;
const departureISO =
booking.departure && booking["check-out"]
? dayjs
.tz(
`${booking.departure}T${booking["check-out"]}:00`,
timezone || "UTC"
)
.toISOString()
: null;
let finalArrivalISO = arrivalISO;
let finalDepartureISO = departureISO;
if (arrivalISO && departureISO) {
if (dayjs(departureISO).isBefore(dayjs(arrivalISO))) {
// Swap if departure is before arrival
finalArrivalISO = departureISO;
finalDepartureISO = arrivalISO;
}
}
return { arrivalISO: finalArrivalISO, departureISO: finalDepartureISO };
}
function convertISOToOffetTime(iso, timezone) {
const date = dayjs(iso);
if (timezone) {
return date.tz(timezone).format("YYYY-MM-DDTHH:mm:ssZ");
}
return date.format("YYYY-MM-DDTHH:mm:ssZ");
}
export async function getBookingsCache(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"}/bookings`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Bookings 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.BOOKINGS_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"}/bookings`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=180", // 2 minute TTL
ETag: `"bookings-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Bookings 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 bookings cache:", error);
return null;
}
}
export async function storeBookingsCache(env, bookings) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.BOOKINGS_KEY, JSON.stringify(bookings));
console.log("Bookings stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/bookings`
);
await cache.delete(cacheKey);
console.log("Bookings cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing bookings cache:", error);
throw error;
}
}
function convertSmoobuSource(source) {
if (source === "Booking.com") {
return "bookingcom";
} else if (source === "Blocked channel") {
return "blocked";
} else if (source === "Airbnb") {
return "airbnb";
} else {
return "unknown";
}
}
export async function transformSmoobuBooking(
env,
booking,
propertiesData,
guestsData
) {
console.log("Transforming booking:", booking.id);
const bookingSource = convertSmoobuSource(booking.channel.name);
const smoobuPropertyId = booking.apartment.id;
const smoobuGuestId = booking.guestId;
const property = getItemBySmoobuId(propertiesData, smoobuPropertyId);
const guest = getItemBySmoobuId(guestsData, smoobuGuestId);
const status = getBookingStatus(booking, property.timezone);
const checkInDate = new Date(
convertBookingDatesToISO(booking, property.timezone).arrivalISO
);
const checkOutDate = new Date(
convertBookingDatesToISO(booking, property.timezone).departureISO
);
if (status == "complete") {
return null;
}
return {
smoobuId: booking.id,
reference: booking["reference-id"] || null,
checkIn: checkInDate.toISOString(),
checkOut: checkOutDate.toISOString(),
status: status,
adults: booking.adults || 0,
children: booking.children || 0,
price: booking.price || 0,
source: bookingSource,
property: {
notionId: property?.notionId,
smoobuId: property?.smoobuId,
timezone: property?.timezone,
name: property?.name,
},
guest: guest
? {
notionId: guest?.notionId,
smoobuId: guest?.smoobuId,
name: guest?.name,
}
: null,
};
}
export async function transformNotionBooking(
env,
notionBooking,
propertiesData,
guestsData
) {
const properties = notionBooking.properties;
const source = properties["Source"].select.name
.replaceAll(".", "")
.toLowerCase();
const status = properties["Status"].status.name.toLowerCase();
const adults = properties["Adults"]?.number || 0;
const children = properties["Children"]?.number || 0;
const reference = properties["Reference"]?.rich_text?.[0]?.plain_text || null;
const price = properties["Price"]?.number || 0;
const smoobuId = properties["Smoobu ID"]?.number || null;
const checkIn = new Date(properties["Date"]?.date?.start || undefined);
const checkOut = new Date(properties["Date"]?.date?.end || undefined);
const notionPropertyId =
properties["Property"]?.relation?.[0]?.id || undefined;
const notionGuestId = properties["Guest"]?.relation?.[0]?.id || undefined;
const notionProperty = getItemByNotionId(propertiesData, notionPropertyId);
let notionGuest = null;
if (notionGuestId) {
notionGuest = getItemByNotionId(guestsData, notionGuestId);
}
if (status == "complete") {
return null;
}
// Extract slug from formula
return {
notionId: notionBooking.id,
reference: reference,
status: status,
source: source,
adults: adults,
children: children,
price: price,
smoobuId: smoobuId,
checkIn: checkIn.toISOString(),
checkOut: checkOut.toISOString(),
property: {
notionId: notionProperty?.notionId,
smoobuId: notionProperty?.smoobuId,
timezone: notionProperty?.timezone,
name: notionProperty?.name,
},
guest:
notionGuest !== null
? {
notionId: notionGuest?.notionId,
smoobuId: notionGuest?.smoobuId,
name: notionGuest?.name,
}
: null,
};
}
// Diff bookings by smoobuId (or notionId if present)
export function diffBookings(newList, oldList) {
// Keys to check for changes (set to null to check all keys)
const keysToCheck = [
"reference",
"checkIn",
"checkOut",
"status",
"adults",
"children",
"price",
"source",
"property",
"guest",
];
// Helper: index by smoobuId
const oldById = Object.fromEntries(
(oldList || []).map((b) => [b.smoobuId, b])
);
const newById = Object.fromEntries(
(newList || []).map((b) => [b.smoobuId, b])
);
// toAdd: in newList but not in oldList
const toAdd = newList.filter((b) => !oldById[b.smoobuId]);
// toDelete: in oldList but not in newList
const toDelete = oldList.filter((b) => !newById[b.smoobuId]);
// toUpdate: in both, but with different content (using microdiff)
const toUpdate = newList.filter((b) => {
const old = oldById[b.smoobuId];
if (!old) return false;
// Exclude notionId from comparison
const { notionId, ...restNew } = b;
const { notionId: _, ...restOld } = old;
// Use microdiff to get differences
const differences = diff(restOld, restNew);
// 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 };
}
function convertSourceToNotionSource(source) {
if (source === "bookingcom") {
return "Booking.com";
} else if (source === "blocked") {
return "Blocked";
} else if (source === "airbnb") {
return "Airbnb";
} else {
return "Unknown";
}
}
function convertStatusToNotionStatus(status) {
if (status === "cancelled") {
return "Cancelled";
} else if (status === "complete") {
return "Complete";
} else if (status === "confirmed") {
return "Confirmed";
} else if (status === "hosting") {
return "Hosting";
}
}
// Notion helpers for bookings
export async function addBookingToNotion(env, booking) {
console.log("Adding booking to Notion:", booking.smoobuId);
const name = `${dayjs(booking.checkIn).format("DD/MM/YYYY")} - ${
booking?.guest?.name || "Unknown Guest"
}`;
const notionProps = {
Name: name,
"Smoobu ID": booking.smoobuId,
Status: { status: { name: convertStatusToNotionStatus(booking.status) } },
Reference: booking.reference,
Date: {
date: {
start: convertISOToOffetTime(
booking.checkIn,
booking.property.timezone
),
end: convertISOToOffetTime(booking.checkOut, booking.property.timezone),
},
},
Adults: booking.adults,
Children: booking.children,
Price: booking.price,
Source: { select: { name: convertSourceToNotionSource(booking.source) } },
Property: booking.property?.notionId
? { relation: [{ id: booking.property.notionId }] }
: undefined,
Guest: booking.guest?.notionId
? { relation: [{ id: booking.guest.notionId }] }
: undefined,
};
Object.keys(notionProps).forEach(
(k) => notionProps[k] === undefined && delete notionProps[k]
);
const res = await addToNotionDataSource(
notionProps,
env.BOOKINGS_DB,
"https://www.notion.so/icons/book_gray.svg"
);
return { ...booking, notionId: res.id };
}
export async function updateNotionBooking(env, booking, notionId) {
const notionProps = {
Reference: booking.reference,
Status: { status: { name: convertStatusToNotionStatus(booking.status) } },
Date: {
date: {
start: convertISOToOffetTime(
booking.checkIn,
booking.property.timezone
),
end: convertISOToOffetTime(booking.checkOut, booking.property.timezone),
},
},
Adults: booking.adults,
Children: booking.children,
Price: booking.price,
Source: { select: { name: convertSourceToNotionSource(booking.source) } },
Property: booking.property?.notionId
? { relation: [{ id: booking.property.notionId }] }
: undefined,
Guest: booking.guest?.notionId
? { relation: [{ id: booking.guest.notionId }] }
: undefined,
};
Object.keys(notionProps).forEach(
(k) => notionProps[k] === undefined && delete notionProps[k]
);
await updateNotionPage(notionId, notionProps);
return { ...booking, notionId };
}
export async function deleteNotionBooking(env, notionId) {
await updateNotionPage(notionId, {}, true, true);
}
/**
* Checks if the current time is during or after a booking period
* @param {string|Date} checkIn - The check-in time (ISO string or Date object)
* @param {string|Date} checkOut - The check-out time (ISO string or Date object)
* @returns {boolean} - True if current time is during or after the booking period
*/
export function getBookingStatus(smoobuBooking, timezone) {
const now = new Date();
const checkInDate = new Date(
convertBookingDatesToISO(smoobuBooking, timezone).arrivalISO
);
const checkOutDate = new Date(
convertBookingDatesToISO(smoobuBooking, timezone).departureISO
);
// Return true if current time is on or after the check-in date
if (smoobuBooking.type == "cancellation") {
return "cancelled";
}
if (now >= checkInDate) {
return "hosting";
} else if (now >= checkOutDate) {
return "complete";
} else {
return "confirmed";
}
}

30
src/utils/contentCache.js Normal file
View File

@ -0,0 +1,30 @@
import { getImages } from "./imageCache.js";
import { getPages } from "./pages.js";
import { getProperties } from "./properties.js";
import { getSettings } from "./settings.js";
export async function getCombinedCachedContent(env) {
try {
// Get current images from image cache
const cachedImages = await getImages(env, true);
const cachedPages = await getPages(env, true);
const cachedProperties = await getProperties(env, true);
const cachedSettings = await getSettings(env, true);
// Combine content with current images
const content = {
pages: cachedPages,
properties: cachedProperties,
settings: cachedSettings,
images: cachedImages,
};
console.log("Serving content...");
return content;
return null;
} catch (error) {
console.log("Error getting combined cached content:", error);
return null;
}
}

221
src/utils/guests.js Normal file
View File

@ -0,0 +1,221 @@
import { updateNotionPage, addToNotionDataSource } from "./notion.js";
import diff from "microdiff";
export async function getGuestsCache(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"}/guests`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Guests 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.GUESTS_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"}/guests`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=300", // 5 minute TTL
ETag: `"guests-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Guests 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 guests cache:", error);
return null;
}
}
export async function storeGuestsCache(env, guests) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.GUESTS_KEY, JSON.stringify(guests));
console.log("Guests stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/guests`
);
await cache.delete(cacheKey);
console.log("Guests cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing guests cache:", error);
throw error;
}
}
export async function transformSmoobuGuest(env, guest) {
console.log("Transforming guest:", guest.id);
return {
smoobuId: guest.id,
name:
guest.name || guest.firstName + " " + guest.lastName || "Unknown Guest",
email: guest.emails[0] || "",
phone: guest?.telephoneNumbers[0] || "",
firstName: guest.firstName || "",
lastName: guest.lastName || "",
};
}
export async function transformNotionGuest(env, notionGuest) {
const properties = notionGuest.properties;
const name = properties["Name"]?.title?.[0]?.plain_text || "Unknown Guest";
const email = properties["Email"]?.email || "";
const phone = properties["Phone"]?.phone_number || "";
const firstName = properties["First Name"]?.rich_text?.[0]?.plain_text || "";
const lastName = properties["Last Name"]?.rich_text?.[0]?.plain_text || "";
const smoobuId = properties["Smoobu ID"]?.number || undefined;
return {
notionId: notionGuest.id,
smoobuId: smoobuId,
name: name,
email: email,
phone: phone,
firstName: firstName,
lastName: lastName,
};
}
// Diff guests by smoobuId (or notionId if present)
export function diffGuests(newList, oldList) {
// Keys to check for changes (set to null to check all keys)
const keysToCheck = ["name", "email", "phone", "firstName", "lastName"];
// Helper: index by smoobuId
const oldById = Object.fromEntries(
(oldList || []).map((g) => [g.smoobuId, g])
);
const newById = Object.fromEntries(
(newList || []).map((g) => [g.smoobuId, g])
);
// toAdd: in newList but not in oldList
const toAdd = newList.filter((g) => !oldById[g.smoobuId]);
// toDelete: in oldList but not in newList
const toDelete = oldList.filter((g) => !newById[g.smoobuId]);
// toUpdate: in both, but with different content (using microdiff)
const toUpdate = newList.filter((g) => {
const old = oldById[g.smoobuId];
if (!old) return false;
// Exclude notionId from comparison
const { notionId, ...restNew } = g;
const { notionId: _, ...restOld } = old;
// Use microdiff to get differences
const differences = diff(restOld, restNew);
// 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 };
}
// Notion helpers for guests
export async function addGuestToNotion(env, guest) {
console.log("Adding guest to Notion:", guest.smoobuId);
const name =
guest.name ||
`${guest.firstName || ""} ${guest.lastName || ""}`.trim() ||
"Unknown Guest";
const notionProps = {
Name: name,
"Smoobu ID": guest.smoobuId,
Email: guest.email,
Phone: guest.phone
? {
phone_number: guest.phone,
}
: undefined,
"First Name": guest.firstName,
"Last Name": guest.lastName,
};
Object.keys(notionProps).forEach(
(k) => notionProps[k] === undefined && delete notionProps[k]
);
const res = await addToNotionDataSource(
notionProps,
env.GUESTS_DB,
"https://www.notion.so/icons/user_gray.svg"
);
return { ...guest, notionId: res.id };
}
export async function updateNotionGuest(env, guest, notionId) {
const notionProps = {
Email: guest.email,
Phone: guest.phone
? {
phone_number: guest.phone,
}
: undefined,
"First Name": guest.firstName,
"Last Name": guest.lastName,
};
Object.keys(notionProps).forEach(
(k) => notionProps[k] === undefined && delete notionProps[k]
);
await updateNotionPage(notionId, notionProps);
return { ...guest, notionId };
}
export async function deleteNotionGuest(env, notionId) {
await updateNotionPage(notionId, {}, true, true);
}

56
src/utils/icon.js Normal file
View File

@ -0,0 +1,56 @@
import { parse, stringify } from "svgson";
async function processSVG(svgText, cssClass) {
try {
// Parse SVG to JSON
const json = await parse(svgText);
// Remove all fill attributes recursively
const removeFill = (node) => {
if (node.attributes?.fill) delete node.attributes.fill;
if (node.children) node.children.forEach(removeFill);
};
removeFill(json);
// Add class to root <svg>
json.attributes.class = cssClass;
// Convert back to SVG string
return stringify(json);
} catch (err) {
throw new Error(`Error processing SVG: ${err.message}`);
}
}
// processNotionIcon fetches and validates the SVG
export async function processNotionIcon(iconObj, cssClass = "") {
if (iconObj?.external?.url && iconObj?.url) {
return undefined;
}
const url = iconObj?.external?.url || iconObj.url;
const urlObject = new URL(url);
// Fetch the file headers first
console.log("Fetching icon:", urlObject.origin + urlObject.pathname);
const svgResponse = await fetch(url, { method: "GET" });
const contentType = svgResponse.headers.get("content-type") || "";
if (!contentType.includes("image/svg+xml")) {
console.log(`Not an SVG file (content-type was ${contentType}).`);
return `<img src="${url}" alt="Page Icon" class="${cssClass}" />`;
}
console.log("Fetched SVG:", urlObject.origin + urlObject.pathname);
if (!svgResponse.ok) {
throw new Error(
`Failed to fetch SVG: ${svgResponse.status} ${svgResponse.statusText}`,
);
}
const svgText = await svgResponse.text();
const processedSVG = processSVG(svgText, cssClass);
return processedSVG;
}

313
src/utils/imageCache.js Normal file
View File

@ -0,0 +1,313 @@
import { fetchBlurHash } from "./blurHash";
function generateImageId(url) {
const urlObj = new URL(url);
const origin = urlObj.origin;
const pathname = urlObj.pathname;
// Create a consistent hash from origin + pathname
const combined = `${origin}${pathname}`;
// Simple hash function for better distribution
let hash = 0;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Convert to positive number and create readable ID
const positiveHash = Math.abs(hash);
const base36Hash = positiveHash.toString(36);
// Pad with zeros if needed and limit length
return base36Hash.padStart(8, "0").substring(0, 12);
}
// Generate R2 key from image ID and original URL
function generateR2KeyFromImageId(imageId, url) {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || "image";
const extension = filename.includes(".") ? filename.split(".").pop() : "jpg";
const baseFilename = filename.replace(/\.[^/.]+$/, "");
return `images/${imageId}-${baseFilename}.${extension}`;
}
function extractR2KeyFromUrl(mirrorUrl) {
// Extract the key from a mirror URL like "https://r2-domain.com/images/abc123-image.jpg"
const urlObj = new URL(mirrorUrl);
return urlObj.pathname.substring(1); // Remove leading slash
}
export async function getImages(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"}/images`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Images 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.IMAGES_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"}/images`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"images-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Images 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 images cache:", error);
return null;
}
}
export async function updateImages(env, currentImageUrls) {
console.log("Updating image cache...");
// Convert URLs to image objects with IDs
const currentImages = currentImageUrls.map((url) => ({
id: generateImageId(url),
url,
}));
// Get existing image cache
const existingImages = await getImages(env);
const existingImageMap = new Map(existingImages.map((img) => [img.id, img]));
// Find images to add (current images with IDs not in cache)
const imagesToAdd = currentImages.filter(
(img) => !existingImageMap.has(img.id)
);
// Find images to keep (existing images with IDs in current set)
const currentImageMap = new Map(currentImages.map((img) => [img.id, img]));
const imagesToKeep = existingImages
.filter((img) => currentImageMap.has(img.id))
.map((img) => ({
...img,
// Update URL in case it has changed (different query params, etc.)
url: currentImageMap.get(img.id).url,
}));
// Find images to remove (existing images with IDs not in current set)
const imagesToRemove = existingImages.filter(
(img) => !currentImageMap.has(img.id)
);
console.log(
`Images to add: ${imagesToAdd.length}, Images to keep: ${imagesToKeep.length}, Images to remove: ${imagesToRemove.length}`
);
// Delete removed images from R2
for (const image of imagesToRemove) {
if (image.mirrorUrl) {
try {
const key = extractR2KeyFromUrl(image.mirrorUrl);
await env.THD_CONTENT.delete(key);
console.log(`Deleted ${key} from R2 for image ID ${image.id}`);
} catch (error) {
console.error(
`Failed to delete ${image.mirrorUrl} from R2 for image ID ${image.id}:`,
error
);
}
}
}
// Upload new images to R2 and create mirror URLs
const newImages = [];
for (let i = 0; i < imagesToAdd.length; i++) {
const imageData = imagesToAdd[i];
const { id, url } = imageData;
console.log(
`Uploading image to R2 (${i + 1}/${
imagesToAdd.length
}): ${url} (ID: ${id})`
);
try {
// Fetch the image
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
// Generate R2 key from image ID
const key = generateR2KeyFromImageId(id, url);
const result = await env.THD_CONTENT.put(key, response.body);
if (result == null) {
throw new Error(`Failed to upload image to R2: ${key}`);
}
// Create mirror URL
const mirrorUrl = `${env.R2_PUBLIC_URL}/${key}`;
newImages.push({
id,
url,
blurHash: null,
mirrorUrl,
});
console.log(
`Successfully uploaded and mirrored: ${url} -> ${mirrorUrl} (ID: ${id})`
);
} catch (error) {
console.error(`Failed to upload ${url} to R2 (ID: ${id}):`, error);
// Add image without mirror URL if upload fails
newImages.push({
id,
url,
blurHash: null,
mirrorUrl: null,
});
}
// Small delay to avoid overwhelming the system
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Combine kept images with new images
const updatedImages = [...imagesToKeep, ...newImages];
// Store updated image cache
await storeImages(env, updatedImages);
return updatedImages;
}
export async function storeImages(env, images) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.IMAGES_KEY, JSON.stringify(images));
console.log("Images stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/images`
);
await cache.delete(cacheKey);
console.log("Images cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing images cache:", error);
throw error;
}
}
export function collectImageUrls(pages, properties) {
const imageUrls = [];
// Collect from pages
for (const page of pages) {
page.images?.forEach((url) => imageUrls.push(url));
page.content?.forEach(
(block) =>
block.type === "image" && block.url && imageUrls.push(block.url)
);
}
// Collect from properties
for (const property of properties) {
property.images?.forEach((url) => imageUrls.push(url));
property.content?.forEach(
(block) =>
block.type === "image" && block.url && imageUrls.push(block.url)
);
}
return imageUrls;
}
export async function handleBlurhashUpdate(request, env) {
// Read the image cache
const cachedImages = await env.CONTENT_KV.get(env.IMAGES_KEY, {
type: "json",
});
if (!cachedImages) return;
let updated = false;
for (const image of cachedImages) {
if (image.blurHash === null) {
// Try mirrorUrl first
let blurHash = image.mirrorUrl
? await fetchBlurHash(image.mirrorUrl)
: null;
// Fallback to original URL if mirrorUrl fails
if (!blurHash) {
blurHash = await fetchBlurHash(image.url);
}
if (blurHash) {
image.blurHash = blurHash;
updated = true;
console.log(`Updated blurHash for image ID ${image.id} (${image.url})`);
}
}
}
// Store updated cache if anything changed
if (updated) {
await env.CONTENT_KV.put(env.IMAGES_KEY, JSON.stringify(cachedImages));
}
return cachedImages;
}
// Helper function to get image by ID
export function getImageById(cachedImages, imageId) {
return cachedImages.find((img) => img.id === imageId);
}
// Helper function to get image ID from URL
export function getImageIdFromUrl(url) {
return generateImageId(url);
}

90
src/utils/navigation.js Normal file
View File

@ -0,0 +1,90 @@
import { processNotionIcon } from "./icon";
export async function getNavigation(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.NAVIGATION_CACHE_URL || "cache"}/navigation`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Navigation 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.NAVIGATION_KEY, {
type: "json",
});
return kvData || null;
} catch (error) {
console.log("Error fetching navigation cache:", error);
return null;
}
}
export async function storeNavigation(env, navigation) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.NAVIGATION_KEY, JSON.stringify(navigation));
console.log("Navigation stored in KV.");
// Also update the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.NAVIGATION_CACHE_URL || "cache"}/navigation`
);
// Create a response with appropriate cache headers
const response = new Response(JSON.stringify(navigation), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"navigation-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Navigation stored in Cloudflare cache.");
} catch (cacheError) {
console.warn(
"Error updating Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing navigation cache:", error);
throw error;
}
}
// Transform basic Notion page data to desired format
export async function transformNavigation(notionPage, type) {
var icon = undefined;
if (notionPage.icon) {
icon = await processNotionIcon(notionPage.icon, "th-icon");
}
const properties = notionPage.properties;
const id = notionPage.id;
const slug = properties.Slug?.formula?.string || "unknown";
// Extract name from title
const name = properties.Name?.title?.[0]?.plain_text || "Untitled";
return {
slug: type == "property" ? `properties/${slug}` : slug,
name: name,
icon: icon,
notionId: id,
type: type,
};
}

573
src/utils/notion.js Normal file
View File

@ -0,0 +1,573 @@
import { processNotionIcon } from "./icon";
import { env } from "cloudflare:workers";
import { getNavigation } from "./navigation";
const NOTION_API_BASE = "https://api.notion.com/v1";
const NOTION_VERSION = "2025-09-03"; // or your preferred version
const iconColorSubstitutions = [
["#D44C47", "#FF453A"],
["#55534E", "#FFFFFF"],
["#448361", "#32D74B"],
["#337ea9", "#0A84FF"],
["#9065B0", "#BF5AF2"],
["#CB912F", "#FFD60A"],
["#C14C8A", "#FF375F"],
["#d9730d", "#FF9F0A"],
];
async function notionFetch(endpoint, options = {}) {
const res = await fetch(`${NOTION_API_BASE}${endpoint}`, {
headers: {
Authorization: `Bearer ${env.NOTION_AUTH}`,
"Notion-Version": NOTION_VERSION,
"Content-Type": "application/json",
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Notion API error: ${res.status} ${res.statusText} - ${text}`
);
}
return res.json();
}
export async function addToNotionDataSource(
properties,
dataSourceId,
icon = null
) {
console.log("Adding to Notion data source...");
console.log("Properties:", properties);
const pageParams = {};
const notionProperties = {};
if (icon != null) {
pageParams["icon"] = {
type: "external",
external: {
url: icon,
},
};
}
for (const [key, value] of Object.entries(properties)) {
if (key === "Email" && value != "") {
notionProperties[key] = { email: value };
} else if (key === "Name" && value != "") {
notionProperties[key] = { title: [{ text: { content: value } }] };
} else if (key === "Features") {
notionProperties[key] = {
multi_select: value.map((feature) => ({
name: feature,
})),
};
} else if (key === "Message" && value != "") {
pageParams["children"] = [
{
object: "block",
type: "paragraph",
paragraph: {
rich_text: [
{
type: "text",
text: {
content: value,
},
},
],
},
},
];
} else if (typeof value === "string" && value != "") {
notionProperties[key] = {
rich_text: [{ text: { content: value } }],
};
} else if (typeof value === "number" && value != undefined) {
notionProperties[key] = { number: value };
} else if (value && typeof value === "object" && !Array.isArray(value)) {
notionProperties[key] = value;
}
}
try {
const response = await notionFetch("/pages", {
method: "POST",
body: JSON.stringify({
parent: { data_source_id: dataSourceId },
...pageParams,
properties: notionProperties,
}),
});
console.log("Added to Notion DB!");
return response;
} catch (error) {
console.error("Failed to add to Notion:", error);
throw new Error("Failed to add to Notion");
}
}
export async function queryNotionDataSource(dataSourceId, queryParams = {}) {
console.log(`Fetching data source: ${dataSourceId} from Notion API...`);
try {
const body = JSON.stringify(queryParams);
const result = await notionFetch(`/data_sources/${dataSourceId}/query`, {
method: "POST",
body,
});
console.log("Fetched data source:", dataSourceId);
return result.results;
} catch (error) {
console.error(`Error fetching Notion data source ${dataSourceId}:`, error);
throw error;
}
}
export async function getNotionDatabase(databaseId) {
console.log(`Fetching database: ${databaseId} from Notion API...`);
try {
const result = await notionFetch(`/databases/${databaseId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion database ${databaseId}:`, error);
throw error;
}
}
export async function getNotionDataSource(dataSourceId) {
console.log(`Fetching data source: ${dataSourceId} from Notion API...`);
try {
const result = await notionFetch(`/data_sources/${dataSourceId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion data source ${dataSourceId}:`, error);
throw error;
}
}
export async function getNotionPage(pageId) {
console.log(`Fetching page: ${pageId} from Notion API...`);
try {
const result = await notionFetch(`/pages/${pageId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion page ${pageId}:`, error);
throw error;
}
}
export async function updateNotionPage(
pageId,
properties,
archive = false,
trash = false
) {
console.log(`Updating page: ${pageId} in Notion API...`);
const notionProperties = {};
for (const [key, value] of Object.entries(properties)) {
if (key === "Email" && value != "") {
notionProperties[key] = { email: value };
} else if (key === "Name") {
notionProperties[key] = { title: [{ text: { content: value } }] };
} else if (key === "Features") {
notionProperties[key] = {
multi_select: value.map((feature) => ({
name: feature,
})),
};
} else if (typeof value === "string" && value != "") {
notionProperties[key] = {
rich_text: [{ text: { content: value } }],
};
} else if (typeof value === "number" && value != undefined) {
notionProperties[key] = { number: value };
} else if (value && typeof value === "object" && !Array.isArray(value)) {
notionProperties[key] = value;
}
}
try {
const result = await notionFetch(`/pages/${pageId}`, {
method: "PATCH",
body: JSON.stringify({
properties: notionProperties,
archived: archive,
in_trash: trash,
}),
});
return result;
} catch (error) {
console.error(`Error updating Notion page ${pageId}:`, error);
throw error;
}
}
export async function getNotionBlocks(pageId, queryParams = {}) {
console.log(`Fetching blocks for page: ${pageId} from Notion API...`);
try {
const result = await notionFetch(
`/blocks/${pageId}/children?${new URLSearchParams(queryParams)}`
);
return result.results;
} catch (error) {
console.error(`Error fetching Notion page ${pageId}:`, error);
throw error;
}
}
export async function getNotionSVGIcon(url) {
try {
console.log(`Fetching SVG icon from: ${url}`);
const response = await fetch(url);
if (!response.ok)
throw new Error(`Failed to fetch SVG: ${response.statusText}`);
let svgText = await response.text();
iconColorSubstitutions.forEach(([searchColor, replaceColor]) => {
svgText = svgText.replaceAll(searchColor, replaceColor);
});
return svgText;
} catch (error) {
console.error(error);
return null;
}
}
// Transform Notion blocks to content format
export async function transformContent(env, pageId) {
try {
console.log(`Transforming content for page: ${pageId}`);
const navigationItems = await getNavigation(env);
// Fetch blocks from Notion
const blocks = await getNotionBlocks(pageId);
const content = [];
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const blockType = block.type;
// Group consecutive list items
if (
blockType === "bulleted_list_item" ||
blockType === "numbered_list_item"
) {
const isNumbered = blockType === "numbered_list_item";
const children = [];
// Collect consecutive list items of the same type
while (
i < blocks.length &&
blocks[i].type ===
(isNumbered ? "numbered_list_item" : "bulleted_list_item")
) {
children.push({
type: "listItem",
text: await extractRichText(
isNumbered
? blocks[i].numbered_list_item.rich_text
: blocks[i].bulleted_list_item.rich_text,
navigationItems
),
});
i++;
}
// Step back one index because the for loop will increment it
i--;
content.push({
type: "list",
ordered: isNumbered,
children,
});
continue;
}
switch (blockType) {
case "heading_1":
content.push({
type: "title1",
text: await extractRichText(
block.heading_1.rich_text,
navigationItems
),
});
break;
case "heading_2":
content.push({
type: "title2",
text: await extractRichText(
block.heading_2.rich_text,
navigationItems
),
});
break;
case "heading_3":
content.push({
type: "title3",
text: await extractRichText(
block.heading_3.rich_text,
navigationItems
),
});
break;
case "paragraph":
const paragraphText = await extractRichText(
block.paragraph.rich_text,
navigationItems
);
if (paragraphText.trim()) {
content.push({
type: "paragraph",
text: paragraphText,
});
}
break;
case "divider":
content.push({
type: "divider",
});
break;
case "image":
content.push({
type: "image",
url: block.image.external?.url || block.image.file?.url,
caption: block.image.caption
? await extractRichText(block.image.caption, navigationItems)
: null,
});
break;
case "quote":
content.push({
type: "quote",
text: await extractRichText(block.quote.rich_text, navigationItems),
});
break;
case "code":
content.push({
type: "code",
text: await extractRichText(block.code.rich_text, navigationItems),
language: block.code.language,
});
break;
case "bookmark":
content.push({
type: "button",
text: await extractRichText(
block.bookmark.caption,
navigationItems
),
url: block.bookmark.url,
});
break;
case "callout":
content.push({
type: "callout",
text: await extractRichText(
block.callout.rich_text,
navigationItems
),
icon: block.callout.icon?.emoji || null,
});
break;
case "link_to_page":
const linkedNavigationItem = getItemByNotionId(
navigationItems,
block.link_to_page.page_id
);
content.push({
type: "button",
url: `/${linkedNavigationItem.slug}`,
text: linkedNavigationItem.name,
icon: linkedNavigationItem.icon,
});
break;
default:
// For unsupported block types, try to extract text if available
console.log(`Unsupported block type: ${blockType}`);
console.log(JSON.stringify(block));
break;
}
}
return content;
} catch (error) {
console.error(`Error transforming page content for ${pageId}:`, error);
// Return fallback content if transformation fails
return [
{
type: "paragraph",
text: "Content could not be loaded at this time.",
},
];
}
}
function processLink(text, href, navigationItems) {
const icon = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<path d="M56.855,1.184L56.816,1.184L9.356,1.184C6.044,1.184 3.356,3.872 3.356,7.184C3.356,10.495 6.044,13.184 9.356,13.184L42.331,13.184L2.941,52.574C0.599,54.915 0.599,58.718 2.941,61.059C5.282,63.401 9.085,63.401 11.426,61.059L50.816,21.669L50.816,54.644C50.816,57.956 53.505,60.644 56.816,60.644C60.128,60.644 62.816,57.956 62.816,54.644L62.816,7.184C62.816,6.374 62.656,5.601 62.364,4.896L62.357,4.879C62.065,4.174 61.632,3.514 61.059,2.941C60.486,2.368 59.826,1.935 59.121,1.643L59.104,1.636C58.827,1.521 58.539,1.427 58.243,1.354L58.204,1.345L58.166,1.336L58.128,1.328L58.09,1.319L58.052,1.311L58.014,1.303L57.988,1.298L57.952,1.291L57.915,1.284L57.878,1.277L57.841,1.271L57.804,1.265L57.767,1.259L57.73,1.253L57.692,1.247L57.655,1.242L57.618,1.237L57.58,1.232L57.543,1.227L57.505,1.223L57.467,1.218L57.43,1.215L57.392,1.211L57.354,1.207L57.316,1.204L57.278,1.201L57.24,1.198L57.202,1.196L57.163,1.193L57.125,1.191L57.087,1.19L57.048,1.188L57.01,1.187L56.971,1.185L56.933,1.185L56.894,1.184L56.855,1.184Z"/>
</g>
</svg>
`;
var pageIdNoDashes = null;
if (href.startsWith("/")) {
pageIdNoDashes = href.split("/").pop().replaceAll("#", "");
}
if (href.includes("notion.so/")) {
if (href.includes("-")) {
pageIdNoDashes = href.split("-").pop().replaceAll("#", "");
} else {
pageIdNoDashes = href.split("/").pop().replaceAll("#", "");
}
}
const textSpan = `<span class="th-link-text">${text}</span>`;
const iconSpan = `<span class="th-link-icon">${icon}</span>`;
if (pageIdNoDashes != null) {
const linkedNavigationItem = getItemByNotionId(
navigationItems,
pageIdNoDashes
);
return `<a href="/${linkedNavigationItem.slug}" class="th-link">${textSpan}</a>`;
} else {
return `<a href="${href}" class="th-link">${textSpan}${iconSpan}</a>`;
}
}
// Helper function to extract rich text content
export async function extractRichText(richTextArray, navigationItems) {
if (!richTextArray || !Array.isArray(richTextArray)) {
return "";
}
const textPieces = await Promise.all(
richTextArray.map(async (textObj) => {
// Handle custom emoji mentions
if (
textObj.type === "mention" &&
textObj.mention?.type === "custom_emoji" &&
textObj.mention.custom_emoji?.url
) {
const emoji = await processNotionIcon(
textObj.mention.custom_emoji,
"th-inline-emoji"
);
return emoji;
}
let text = textObj.plain_text || "";
// Handle formatting
if (textObj.annotations) {
const annotations = textObj.annotations;
if (annotations.bold) {
text = `<strong>${text}</strong>`;
}
if (annotations.italic) {
text = `<i>${text}</i>`;
}
if (annotations.underline) {
text = `<u>${text}</u>`;
}
if (annotations.strikethrough) {
text = `<s>${text}</s>`;
}
if (annotations.code) {
text = `<code>${text}</code>`;
}
}
// Handle links
if (textObj.href) {
text = processLink(text, textObj.href, navigationItems);
}
return text;
})
);
return textPieces.join("");
}
// Utility: safely extract plain text from a Notion title or rich_text field
export function getPlainText(field) {
if (!field) return "";
if (field.type === "title" || field.type === "rich_text") {
return field[field.type].map((t) => t.plain_text).join("") || "";
}
return "";
}
// Utility: convert a string to camelCase
export function toCamelCase(str = "") {
return str
.replace(/[^a-zA-Z0-9 ]/g, " ") // remove non-alphanumeric except spaces
.trim()
.split(/\s+/) // split on spaces
.map((word, index) => {
if (index === 0) return word.toLowerCase();
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join("");
}
// Moved from properties.js: buildNewCache, now buildListCache
export function buildListCache(
cachedList,
{ added = [], updated = [], deleted = [] }
) {
// Remove deleted and items that will be updated
const filtered = cachedList.filter(
(p) =>
!deleted.some((d) => d.notionId === p.notionId) &&
!updated.some((u) => u.notionId === p.notionId)
);
return [...filtered, ...updated, ...added];
}
export function getItemByNotionId(items, notionId) {
const itemWithDashes = items.find((item) => item.notionId === notionId);
if (itemWithDashes) {
return itemWithDashes;
}
const itemWithoutDashes = items.find(
(item) => item.notionId.replaceAll("-", "") === notionId.replaceAll("-", "")
);
if (itemWithoutDashes) {
return itemWithoutDashes;
}
return null;
}

172
src/utils/pages.js Normal file
View File

@ -0,0 +1,172 @@
import {
transformContent,
toCamelCase,
getItemByNotionId,
} from "../utils/notion.js";
import { getProperties } from "./properties";
import { getSettings } from "./settings";
// Transform Notion page data to desired format
export async function getPages(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"}/pages`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Pages 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.PAGES_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"}/pages`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"pages-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Pages 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 pages cache:", error);
return null;
}
}
export async function storePages(env, pages) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.PAGES_KEY, JSON.stringify(pages));
console.log("Pages stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(`https://${env.CACHE_URL || "cache"}/pages`);
await cache.delete(cacheKey);
console.log("Pages cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing pages cache:", error);
throw error;
}
}
export async function transformPageData(
env,
notionPage,
propertiesData,
settingsData
) {
const properties = notionPage.properties;
// Extract theme information
let theme = "light"; // default
if (properties.Theme?.relation?.[0]?.id) {
const themeId = properties.Theme.relation[0].id;
const themeData = getItemByNotionId(settingsData.themes, themeId);
if (themeData?.name) {
theme = themeData.name;
}
}
const published = properties["Published"];
// Check if page is published - if not, return null to filter it out
if (published?.checkbox == false) {
return null;
}
// Extract image URLs (handle both external and file images)
const images = (properties["Images"]?.files || [])
.map((f) => f?.external?.url || f?.file?.url)
.filter(Boolean);
const pageType = toCamelCase(properties["Page Type"].select.name);
// Extract slug from formula
const slug = properties.Slug?.formula?.string || "unknown";
// 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, notionPage.id);
const showProperties = properties["Show Properties"]?.checkbox || false;
const showContactForm = properties["Show Contact Form"]?.checkbox || false;
const hideMobileImage = properties["Hide Mobile Image"]?.checkbox || false;
const invertHeader = properties["Invert Header"]?.checkbox || false;
const showScroll = properties["Show Scroll Icon"]?.checkbox || false;
if (showProperties == true) {
content.push({
type: "properties",
properties: propertiesData
.filter((property) => property.active == true)
.map((property) => ({
name: property.name,
address: property.address,
features: property.features,
images: property.images,
slug: property.slug,
})),
});
}
if (showContactForm == true) {
content.push({
type: "contactForm",
});
}
return {
notionId: notionPage.id,
slug: slug,
name: name,
pageType: pageType,
content: content,
theme: theme,
images: images,
showScroll: showScroll,
showProperties: showProperties,
showContactForm: showContactForm,
hideMobileImage: hideMobileImage,
invertHeader: invertHeader,
};
}

334
src/utils/properties.js Normal file
View File

@ -0,0 +1,334 @@
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 async function transformSmoobuProperty(env, property) {
console.log("Transforming property:", property.id);
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
? `${property.location.street || ""}\n${property.location.city || ""}\n${
property.location.zip || ""
}`.trim()
: "Unknown Address",
features:
property.amenities.map((feature) => feature.replaceAll(",", ";")) || [],
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
const { smoobuId, notionId, name, ...restNew } = p;
const { smoobuId: _, notionId: __, name: oldName, ...restOld } = old;
// Prepare objects for comparison
let newObj, oldObj;
if (old.syncName === false) {
// Don't compare names when syncName is false
newObj = restNew;
oldObj = restOld;
} else {
// Include name in comparison when syncName is true or undefined
newObj = { name, ...restNew };
oldObj = { name: oldName, ...restOld };
}
// 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 features = properties["Features"]?.["multi_select"].map((feature) =>
feature.name.replaceAll(",", ";")
);
// 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,
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);
const details = await fetchSmoobuPropertyDetails(env, property.smoobuId);
const notionProps = {
...(property.syncName ? { 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),
};
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);
}

212
src/utils/settings.js Normal file
View File

@ -0,0 +1,212 @@
import { processNotionIcon } from "./icon";
import { getNavigation } from "./navigation";
import { toCamelCase, getPlainText, getItemByNotionId } from "./notion";
export async function getSettings(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"}/settings`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Settings 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.SETTINGS_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"}/settings`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"settings-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Settings 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 settings cache:", error);
return null;
}
}
export async function storeSettings(env, settings) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.SETTINGS_KEY, JSON.stringify(settings));
console.log("Settings stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/settings`
);
await cache.delete(cacheKey);
console.log("Settings cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing settings cache:", error);
throw error;
}
}
export async function transformRedirectsData(env, redirectsData = []) {
const redirects = {};
const navigationItems = await getNavigation(env);
// Iterate over redirects and build object
for (const redirect of redirectsData) {
const redirectName = getPlainText(redirect.properties?.Name);
const relationIds =
redirect.properties?.Page?.relation?.map((r) => r.id) || [];
// Map relations to full basicData entries
const relatedItem = relationIds
.map((notionId) => getItemByNotionId(navigationItems, notionId))
.filter(Boolean)[0];
const key = toCamelCase(redirectName);
redirects[key] = relatedItem.slug;
}
return redirects;
}
// Transform Notion themes data to desired format
export async function transformThemes(themesData) {
if (!themesData || !Array.isArray(themesData)) {
return [];
}
const transformed = await Promise.all(
themesData.map(async (theme) => {
const properties = theme.properties;
// Extract theme name
const name = properties.Name?.title?.[0]?.plain_text || "unknown";
// Extract background color
const backgroundColor =
properties["Background Color"]?.rich_text?.[0]?.plain_text || "#ffffff";
// Extract text color
const textColor =
properties["Text Color"]?.rich_text?.[0]?.plain_text || "#000000";
return {
notionId: theme.id,
name: toCamelCase(name),
backgroundColor,
textColor,
};
})
);
return transformed;
}
// Transform Notion branding data to desired format
export async function transformBranding(brandingData) {
if (!brandingData || !Array.isArray(brandingData)) {
return [];
}
const transformed = await Promise.all(
brandingData.map(async (theme) => {
const properties = theme.properties;
// Extract theme name
const name = properties.Name?.title?.[0]?.plain_text || "Unknown";
// Extract background color
const size = properties["Size"].select.name || "Unknown";
const type = properties["Type"].select.name || "Unknown";
const content = await processNotionIcon(properties["File"].files[0].file);
return {
name: toCamelCase(name),
size: toCamelCase(size),
type: toCamelCase(type),
content: content,
};
})
);
return transformed;
}
// Transform Notion page data to desired format
export async function transformSettingsData(
env,
globalThemesData = [],
redirectsData = [],
themesData = [],
brandingData = []
) {
const redirects = await transformRedirectsData(env, redirectsData);
const themes = await transformThemes(themesData);
const branding = await transformBranding(brandingData);
const globalThemes = transformGlobalThemesData(env, globalThemesData, themes);
return { redirects, themes, branding, globalThemes };
}
export function transformGlobalThemesData(
env,
globalThemesData = [],
themes = []
) {
const globalThemes = {};
// Iterate over redirects and build object
for (const globalTheme of globalThemesData) {
const globalThemeName = getPlainText(globalTheme.properties?.Name);
const relationIds =
globalTheme.properties?.Theme?.relation?.map((r) => r.id) || [];
// Map relations to full basicData entries
const relatedTheme = relationIds
.map((notionId) => getItemByNotionId(themes, notionId))
.filter(Boolean)[0];
const key = toCamelCase(globalThemeName);
globalThemes[key] = relatedTheme;
}
return globalThemes;
}

119
src/utils/smoobu.js Normal file
View File

@ -0,0 +1,119 @@
// Smoobu API fetchers
export async function fetchSmoobuProperties(env) {
const response = await fetch(`${env.SMOOBU_API_URL}/apartments`, {
headers: { "Api-key": env.SMOOBU_API_KEY },
});
if (!response.ok) {
throw new Error(`Failed to fetch properties: ${response.statusText}`);
}
const data = await response.json();
return data.apartments;
}
export async function fetchSmoobuPropertyDetails(env, id) {
const res = await fetch(`${env.SMOOBU_API_URL}/apartments/${id}`, {
headers: { "Api-key": env.SMOOBU_API_KEY },
});
if (!res.ok) {
throw new Error(`Failed to fetch details for apartment ${id}`);
}
return res.json();
}
const PAGE_SIZE = 25;
function buildQueryString(params) {
const queryParams = new URLSearchParams();
// Add pagination params
queryParams.append("page", params.page || 1);
queryParams.append("pageSize", params.pageSize || PAGE_SIZE);
// Add other params if they exist
Object.keys(params).forEach((key) => {
if (
key !== "page" &&
key !== "pageSize" &&
params[key] !== undefined &&
params[key] !== null
) {
queryParams.append(key, params[key]);
}
});
return queryParams.toString();
}
export async function fetchSmoobuBookings(env, params = {}) {
let page = 1;
let allBookings = [];
let hasMore = true;
while (hasMore) {
// Build query string with current page and other params
const currentParams = { ...params, page, pageSize: PAGE_SIZE };
const queryString = buildQueryString(currentParams);
const response = await fetch(
`${env.SMOOBU_API_URL}/reservations?${queryString}`,
{
headers: {
"Api-key": env.SMOOBU_API_KEY,
},
}
);
if (!response.ok) {
console.error(`Failed to fetch page ${page}: ${response.statusText}`);
break;
}
const data = await response.json();
allBookings.push(...data.bookings);
if (data.bookings.length < PAGE_SIZE) {
hasMore = false;
} else {
page++;
}
}
return allBookings;
}
export async function fetchSmoobuGuests(env) {
let page = 1;
let allGuests = [];
let hasMore = true;
while (hasMore) {
const response = await fetch(
`${env.SMOOBU_API_URL}/guests?page=${page}&pageSize=${PAGE_SIZE}`,
{
headers: {
"Api-key": env.SMOOBU_API_KEY,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
console.error(
`Failed to fetch guests page ${page}: ${response.statusText}`
);
break;
}
const data = await response.json();
allGuests.push(...data.guests);
if (data.guests.length < PAGE_SIZE) {
hasMore = false;
} else {
page++;
}
}
return allGuests;
}
export function getItemBySmoobuId(items, smoobuId) {
const item = items.find((item) => item.smoobuId === smoobuId);
if (item) {
return item;
}
return null;
}

BIN
th-api.paw Normal file

Binary file not shown.

11
vitest.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
});

137
wrangler.jsonc Normal file
View File

@ -0,0 +1,137 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "thehideout-api",
"main": "src/index.js",
"compatibility_date": "2025-02-24",
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
"triggers": {
"crons": ["*/5 * * * *", "* * * * *"]
},
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
"vars": {
"THEMES_DB": "26d4d3a4-6a6f-8052-966a-000bc1c836c1",
"GLOBAL_THEMES_DB": "26d4d3a4-6a6f-80a8-b5f8-000b1b040695",
"PAGES_DB": "26d4d3a4-6a6f-80f1-a0fc-000b3c4c5013",
"PROPERTIES_DB": "26e4d3a4-6a6f-80b8-ac80-000b7b236ebc",
"BOOKINGS_DB": "26e4d3a4-6a6f-80fe-960a-000be56680fd",
"GUESTS_DB": "2764d3a4-6a6f-808b-af87-000b072894d7",
"MESSAGES_DB": "2754d3a4-6a6f-80b5-8f8e-000b2ab6ae7a",
"REDIRECTS_DB": "2754d3a4-6a6f-80ca-b30f-000b828310d2",
"BRANDING_DB": "2764d3a4-6a6f-80f6-8354-000bf2304e00",
"NAVIGATION_KEY": "th-navigation-cache",
"PAGES_KEY": "th-pages-cache",
"IMAGES_KEY": "th-images-cache",
"BOOKINGS_KEY": "th-bookings-cache",
"GUESTS_KEY": "th-guests-cache",
"PROPERTIES_KEY": "th-properties-cache",
"SETTINGS_KEY": "th-settings-cache",
"CACHE_URL": "https://thehideout.tombutcher.work/api/cache",
"R2_PUBLIC_URL": "https://pub-1fbfc6d5593a4a2a8e58813f4718d6bf.r2.dev",
"BLUR_HASH": "true",
"SMOOBU_API_URL": "https://login.smoobu.com/api"
},
"kv_namespaces": [
{
"binding": "CONTENT_KV", // the variable youll use in the Worker
"id": "05c5283d5b74488da7f90297ea350f9c" // ID from Cloudflare dashboard
}
],
"r2_buckets": [
{
"binding": "THD_CONTENT",
"bucket_name": "thd-storage"
}
],
"images": {
"binding": "IMAGES" // i.e. available in your Worker on env.IMAGES
},
"env": {
"production": {
"kv_namespaces": [
{
"binding": "CONTENT_KV", // the variable youll use in the Worker
"id": "05c5283d5b74488da7f90297ea350f9c" // ID from Cloudflare dashboard
}
],
"r2_buckets": [
{
"binding": "THD_CONTENT",
"bucket_name": "thd-storage"
}
],
"images": {
"binding": "IMAGES" // i.e. available in your Worker on env.IMAGES
},
"vars": {
"THEMES_DB": "26d4d3a4-6a6f-8052-966a-000bc1c836c1",
"GLOBAL_THEMES_DB": "26d4d3a4-6a6f-80a8-b5f8-000b1b040695",
"PAGES_DB": "26d4d3a4-6a6f-80f1-a0fc-000b3c4c5013",
"PROPERTIES_DB": "26e4d3a4-6a6f-80b8-ac80-000b7b236ebc",
"BOOKINGS_DB": "26e4d3a4-6a6f-80fe-960a-000be56680fd",
"GUESTS_DB": "2764d3a4-6a6f-808b-af87-000b072894d7",
"MESSAGES_DB": "2754d3a4-6a6f-80b5-8f8e-000b2ab6ae7a",
"REDIRECTS_DB": "2754d3a4-6a6f-80ca-b30f-000b828310d2",
"BRANDING_DB": "2764d3a4-6a6f-80f6-8354-000bf2304e00",
"NAVIGATION_KEY": "th-navigation-cache",
"PAGES_KEY": "th-pages-cache",
"IMAGES_KEY": "th-images-cache",
"BOOKINGS_KEY": "th-bookings-cache",
"GUESTS_KEY": "th-guests-cache",
"PROPERTIES_KEY": "th-properties-cache",
"SETTINGS_KEY": "th-settings-cache",
"CACHE_URL": "https://api.thehideoutltd.com/cache",
"R2_PUBLIC_URL": "https://cdn.thehideoutltd.com",
"BLUR_HASH": "true",
"CORS_ORIGIN": "https://thehideoutltd.com",
"SMOOBU_API_URL": "https://login.smoobu.com/api"
}
}
},
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
"dev": {
"ip": "0.0.0.0",
"port": 8787
}
}