Initial commit
This commit is contained in:
commit
4cf801a488
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
6896
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
78
src/index.js
Normal 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
222
src/objects/bookings.js
Normal 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
118
src/objects/guests.js
Normal 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
17
src/objects/images.js
Normal 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
24
src/objects/navigation.js
Normal 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
64
src/objects/pages.js
Normal 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
134
src/objects/properties.js
Normal 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
40
src/objects/settings.js
Normal 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
24
src/routes/blurHash.js
Normal 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
85
src/routes/contact.js
Normal 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
69
src/routes/content.js
Normal 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
259
src/routes/hooks.js
Normal 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
6
src/utils/api.js
Normal 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
110
src/utils/blurHash.js
Normal 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
455
src/utils/bookings.js
Normal 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
30
src/utils/contentCache.js
Normal 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
221
src/utils/guests.js
Normal 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
56
src/utils/icon.js
Normal 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
313
src/utils/imageCache.js
Normal 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
90
src/utils/navigation.js
Normal 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
573
src/utils/notion.js
Normal 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
172
src/utils/pages.js
Normal 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
334
src/utils/properties.js
Normal 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
212
src/utils/settings.js
Normal 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
119
src/utils/smoobu.js
Normal 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
BIN
th-api.paw
Normal file
Binary file not shown.
11
vitest.config.js
Normal file
11
vitest.config.js
Normal 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
137
wrangler.jsonc
Normal 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 you’ll 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 you’ll 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user