workers/catalogs/index.ts

/**
 * @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;