/**
* @module Catalogs
* @description
* Cloudflare Worker providing catalog-related routes
*/
import { Context, Hono, Next } from "hono";
import { bearerAuth } from "hono/bearer-auth";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { logger } from "hono/logger";
import ipRangeCheck from "ip-range-check";
import { DMA } from "../../constants/dma";
import { iterableIps } from "../../constants/iterable-ips";
import { handleOptions } from "../../helpers/cors";
import { RetrieveKvJsonData } from "../../helpers/kv";
import { mapper } from "../../mappings/mapper";
import { authorize } from "../../middleware/auth.middleware";
import { dma } from "../../middleware/dma.middleware";
import { validate } from "../../middleware/validate.middleware";
import {
eventCatalogUpdateSchema,
inventoryCatalogUpdateSchema,
searchInventorySchema,
searchNearbyEventsQuerySchema,
} from "../../schemas/catalogs.schema";
import { CatalogsService } from "../../services/catalogs.service";
import { DataFeedService } from "../../services/datafeeds.service";
import { SearchEventsDataFeedResponse } from "../../types/api/events";
import { InventoryDto, InventoryResponse } from "../../types/d1/inventory";
import {
EventCatalogEntry,
NearbyEventEntry,
} from "../../types/kv/event-catalog";
const catalogs = new Hono<{ Bindings: Env }>();
catalogs.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
);
catalogs.options(
"*",
async (ctx: Context): Promise<Response> => handleOptions(ctx.req.raw)
);
catalogs.use(logger());
/**
* @memberof module:Catalogs
* @function NearbyEvents
* @route {GET} /workers/catalogs/events/nearby
* @description
*
* Returns a filtered list of events occurring near a user-supplied location.
*
* @queryparam {number} lat Geographic latitude.
* @queryparam {number} long Geographic longitude.
* @queryparam {string} [favoritePerformers] Pipe-delimited list of performer names.
* Used to flag or prune events by artist affinity.
* @queryparam {number} [maxDaysUntilEvent] Upper-bound (inclusive) on event dates—expressed in days from "today".
* @queryparam {number} [maxFavoriteEvents] Caps the number of `is_favorited === true` events returned.
* @queryparam {string} [trackedEvents] Pipe-delimited list of event IDs to exclude (already tracked by the user).
* @queryparam {number} [minEvents=5] Minimum number of events required for a successful payload.
* Fewer than this triggers a **400**.
* @queryparam {number} [maxEvents] Hard cap on total events after all processing.
* @queryparam {string} [eventTags] Pipe-delimited list of event-tag types (e.g. `"HotEvent|FeaturedEvent"`).
* @queryparam {boolean} [uniquePerformerEvents=false] When `true`, ensures each main performer appears only once.
* @queryparam {boolean} [filterTbdEvents=false] When `true`, removes events whose names contain a "TBD" placeholder.
*
* @example
* // Nearest concert or sports events within the next 10 days, filtered by favourite artists
* GET /workers/catalogs/events/nearby?lat=40.7128&long=-74.0060&maxDaysUntilEvent=10&favoritePerformers=Artist1|Artist2
*
* @returns {SearchEventsDataFeedResponse} JSON payload identical to the
* TickPick `/events/nearby/datafeed` response schema but post-filtered.
*/
catalogs.use("/events/nearby", async (c: Context, next: Next) => {
const bearer = bearerAuth({ token: c.env.DATAFEEDS_ACCESSTOKEN });
return bearer(c, next);
});
catalogs.get(
"/events/nearby",
validate("query", searchNearbyEventsQuerySchema),
dma,
async (c): Promise<Response> => {
const query = c.get("query");
const dmaInfo = c.get("dma") as DMA | undefined;
const key = [
dmaInfo?.slug ?? query.dmaSlug,
query.parentCategory,
query.grandchildCategory,
query.startDate,
]
.filter(Boolean)
.join(":")
.toLowerCase();
const entry = await c.env.NEARBY_EVENTS.get<NearbyEventEntry>(key, {
type: "json",
});
console.log(`Found ${entry?.events.length} events for key: ${key}`);
if (!entry || entry.events.length === 0) {
throw new HTTPException(404, {
message: "No events found matching criteria.",
});
}
const service = new DataFeedService();
const responseBody = service.processNearbyEventEntry(entry, {
favoritePerformers: query.favoritePerformers,
maxDaysUntilEvent: query.maxDaysUntilEvent,
maxFavoriteEvents: query.maxFavoriteEvents,
trackedEvents: query.trackedEvents,
minEvents: query.minEvents,
maxEvents: query.maxEvents,
uniquePerformerEvents: query.uniquePerformerEvents,
eventTags: query.eventTags,
filterTbdEvents: query.filterTbdEvents,
});
return c.json(responseBody);
}
);
/**
* @memberof module:Catalogs
* @function EventDetails
* @route {GET} /workers/catalogs/events/:eventId
* @description
*
* Retrieves event details for a specified event.
*
* @example
* // Request event details for event 123456
* GET /workers/catalogs/events/123456
*/
catalogs.get("/events/:eventId", async (c: Context) => {
try {
const { eventId } = c.req.param();
const kvNamespace = c.env.EVENT_CATALOG;
const eventData = await RetrieveKvJsonData<EventCatalogEntry>(
kvNamespace,
eventId
);
if (!eventData?.event_data) {
throw new Error("Event data not found");
}
return c.json(eventData);
} catch (error) {
if (error instanceof Error) {
console.error("Error in EventDetails: ", error.message);
} else {
console.error("Unexpected error in EventDetails: ", error);
}
// we still want to return a 200 status code even if there is an error
// since this DF is used for transactional campaigns in Iterable, which should not be blocked
return c.json(
{
message: "Event not found.",
},
200
);
}
});
/**
* @memberof module:Catalogs
* @function UpdateEvent
* @route {PATCH} /workers/catalogs/events/:eventId
* @description Updates an event catalog entry, including probabilities and event data.
* If the event has probabilities, they will be enriched with timestamps.
*
* @param {string} eventId - The unique identifier of the event
* @param {Object} requestBody - The update payload
* @param {Object} [requestBody.event_data] - Updated event information
* @param {Object} [requestBody.probabilities] - Event probability updates
*
* @returns {Object} 200 - Success response
* @returns {string} 200.message - Success message
* @returns {Object} 200.data - Updated event data
* @returns {Object} 200.metadata - Metadata including lastUpdated and expiresAt timestamps
* @throws {HTTPException} 401 - Unauthorized
* @throws {HTTPException} 404 - Event not found
* @throws {HTTPException} 500 - Internal server error
*/
catalogs.patch(
"/events/:eventId",
authorize(["Admin", "Manager"]),
validate("json", eventCatalogUpdateSchema),
async (c) => {
const { DB, EVENT_CATALOG } = c.env;
const { eventId } = c.req.param();
const updatePayload = c.get("json");
const catalogsService = new CatalogsService(DB, EVENT_CATALOG);
const { data, metadata } = await catalogsService.updateEvent(
eventId,
updatePayload
);
return c.json(
{
message: "Event updated successfully",
data,
metadata,
},
200
);
}
);
/**
* @memberof module:Catalogs
* @function SearchInventory
* @route {GET} /workers/catalogs/inventory
* @description Searches inventory records based on provided criteria.
* Optionally includes detailed event information for each inventory record.
*
* @param {Object} queryParams - The search criteria
* @param {number} [queryParams.id] - Filter by numeric ID
* @param {string} [queryParams.inventoryId] - Filter by inventory ID
* @param {string} [queryParams.userEmail] - Filter by user email
* @param {string} [queryParams.eventId] - Filter by event ID
* @param {number} [queryParams.seasonTicketId] - Filter by season ticket ID
* @param {string|string[]} [queryParams.select] - Comma-separated fields to include in results
* Fields are determined dynamically from the InventoryRecord class
* @param {string|string[]} [queryParams.required] - Comma-separated fields that must not be null
* Fields are determined dynamically from the InventoryRecord class
* @param {string|string[]} [queryParams.orderBy] - Field and direction to sort by
* Format: "field:ASC" or "field:DESC"
* Sortable fields: "price", "suggested_price"
* @param {number} [queryParams.limit=25] - Maximum number of records to return
* @param {number} [queryParams.offset] - Number of records to skip
* @param {boolean} [queryParams.includeEventDetails=false] - Whether to include event details
*
* @returns {Object} 200 - Search results
* @throws {HTTPException} 400 - Invalid query parameters
* @throws {HTTPException} 404 - Inventory not found
* @throws {HTTPException} 500 - Internal server error
*/
catalogs.get(
"/inventory",
authorize(["Admin", "Manager"]),
validate("query", searchInventorySchema),
async (c): Promise<Response> => {
const clientIp =
c.req.header("x-forwarded-for") ||
c.req.header("cf-connecting-ip") ||
c.req.header("x-real-ip") ||
"0.0.0.0";
const { DB, EVENT_CATALOG } = c.env;
const { search, filter, includeEventDetails } = c.get("query");
const catalogsService = new CatalogsService(DB, EVENT_CATALOG);
const searchResult = await catalogsService.searchInventory(search, filter);
const inventory = mapper.mapArray<InventoryDto, InventoryResponse>(
searchResult,
"InventoryDto",
"InventoryResponse"
);
if (inventory.length === 0 && !ipRangeCheck(clientIp, iterableIps)) {
throw new HTTPException(404, { message: "Inventory not found" });
}
if (includeEventDetails) {
for (const record of inventory) {
var kvResult = await RetrieveKvJsonData<EventCatalogEntry>(
EVENT_CATALOG,
record.event_id
);
if (kvResult?.event_data) {
record.event = kvResult.event_data;
}
}
}
return c.json({ inventory });
}
);
/**
* @memberof module:Catalogs
* @function UpdateInventory
* @route {PATCH} /workers/catalogs/inventory/:inventoryId
* @description Updates probability values for a specific inventory record.
* Automatically enriches probability values with timestamps.
* Uses JSON patch to merge with existing probability data.
*
* @param {string} inventoryId - The unique identifier of the inventory record
* @param {Object} requestBody - The update payload
* @param {Object} [requestBody.probabilities] - Mapping of probability types to values
* @param {number} requestBody.probabilities[].value - Probability value between 0 and 1
* @param {string} [requestBody.probabilities[].model_version] - Model version that generated the probability
* @param {('control'|'treatment')} requestBody.probabilities[].variant - A/B test variant
*
* @returns {Object} 200 - Success response
* @returns {string} 200.message - Success message
* @returns {InventoryResponse} 200.data - Updated inventory record
* @throws {HTTPException} 401 - Unauthorized
* @throws {HTTPException} 404 - Inventory not found
* @throws {HTTPException} 500 - Internal server error
*/
catalogs.patch(
"/inventory/:inventoryId",
authorize(["Admin", "Manager"]),
validate("json", inventoryCatalogUpdateSchema),
async (c) => {
const { DB, EVENT_CATALOG } = c.env;
const { inventoryId } = c.req.param();
const request = c.get("json");
const catalogsService = new CatalogsService(DB, EVENT_CATALOG);
const result = await catalogsService.updateInventory(inventoryId, request);
const data = mapper.map<InventoryDto, InventoryResponse>(
result as any,
"InventoryDto",
"InventoryResponse"
);
return c.json(
{
message: "Inventory updated successfully",
data,
},
200
);
}
);
catalogs.all("*", async (c) => {
const clientIp =
c.req.header("x-forwarded-for") ||
c.req.header("cf-connecting-ip") ||
c.req.header("x-real-ip") ||
"0.0.0.0";
if (ipRangeCheck(clientIp, iterableIps)) {
// Return 200 for Iterable IPs to use as health check
return c.json({ message: "Healthy" });
}
return c.json({ message: "Not Found" }, 404);
});
const app = new Hono();
app.route("/workers/catalogs", catalogs);
app.route("/1.0/workers/catalogs", catalogs);
app.onError((err: Error, c: Context) => {
if (err instanceof HTTPException) {
let message = err.message;
if (err.status === 401) {
message = "Unauthorized";
}
return c.json(
{
message: message,
},
err.status
);
}
console.error("Caught unexpected error: ", err.message);
return c.text("Internal Worker Error", 500);
});
export default app;