workers/datafeeds/index.ts

/**
 * @module DataFeeds
 * @description
 * Cloudflare Worker providing datafeed-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 * as Papa from "papaparse";
import { DMA } from "../../constants/dma";
import { handleOptions } from "../../helpers/cors";
import { formatEventDatesForIterable } from "../../helpers/date";
import { RetrieveKvJsonData } from "../../helpers/kv";
import { dma } from "../../middleware/dma.middleware";
import { validate } from "../../middleware/validate.middleware";
import EventsService from "../../modules/EventsModule";
import RequestService from "../../modules/RequestModule";
import {
  autoTrackEventsQuerySchema,
  searchMajorLeagueEventsQuerySchema,
} from "../../schemas/datafeeds.schema";
import { DataFeedService } from "../../services/datafeeds.service";
import type {
  DataFeedEventResponse,
  SearchEventsDataFeedResponse,
} from "../../types/api/events";
import type {
  EventCatalogEntry,
  NearbyEventEntry,
} from "../../types/kv/event-catalog";
import {
  League,
  LeaguePerformer,
  LeaguePerformerValue,
} from "../../types/kv/league-performers";

const df = new Hono<{ Bindings: Env }>();

df.use(
  "*",
  cors({
    origin: "*",
    allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  })
);

df.options(
  "*",
  async (ctx: Context): Promise<Response> => handleOptions(ctx.req.raw)
);

df.use(async (c: Context, next: Next) => {
  const bearer = bearerAuth({ token: c.env.DATAFEEDS_ACCESSTOKEN });
  return bearer(c, next);
});

df.use(logger());

/**
 * @memberof module:DataFeeds
 * @function EventSummary
 * @route {GET} /workers/datafeeds/events/:eventId/summary
 * @description
 *
 * Retrieves event summary (stats) data for a specified event. Also includes event data from the event catalog.
 *
 * @example
 * // Request event summary data for event 123456
 * GET /workers/datafeeds/events/123456/summary
 */
df.get("/events/:eventId/summary", async (c: Context) => {
  const { eventId } = c.req.param();

  const kvNamespace = c.env.EVENT_CATALOG;

  const eventData = await RetrieveKvJsonData<EventCatalogEntry>(
    kvNamespace,
    eventId
  );

  if (!eventData) {
    throw new HTTPException(404, {
      message: "Event not found.",
    });
  }

  // Extract the Authorization header from the incoming request
  const authHeader = c.req.raw.headers.get("Authorization");

  // Set up the headers for the fetch request
  const headers = new Headers();
  if (authHeader) {
    headers.set("Authorization", authHeader);
  }

  const eventStatsRes = await fetch(
    `${c.env.API_BASEURL}events/${eventId}/summary`,
    { headers }
  );

  // Check if the response is ok and parse the JSON
  if (!eventStatsRes.ok) {
    throw new HTTPException(404, {
      message: "Event not found.",
    });
  }

  const eventStats = (await eventStatsRes.json()) as any;

  // Check if eventStats is null or empty
  if (eventStats == null || Object.keys(eventStats).length === 0) {
    throw new HTTPException(404, {
      message: "Event not found.",
    });
  }

  return c.json({
    ...eventData?.event_data,
    ...eventStats,
  });
});

/**
 * @memberof module:DataFeeds
 * @function RelatedPerformers
 * @route {GET} /workers/datafeeds/performers/:performerId/related
 * @description
 *
 * Retrieves related performers for a specified performer.
 *
 * @example
 * // Request related performers for performer 123456
 * GET /workers/datafeeds/performers/123456/related
 */
df.get("/performers/:performerId/related", async (c: Context) => {
  const { performerId } = c.req.param();

  // Extract the Authorization header from the incoming request
  const authHeader = c.req.raw.headers.get("Authorization");

  // Set up the headers for the fetch request
  const headers = new Headers();
  if (authHeader) {
    headers.set("Authorization", authHeader);
  }

  return await fetch(
    `${c.env.API_BASEURL}datafeeds/performers/${performerId}/related`,
    { headers }
  );
});

/**
 * @memberof module:DataFeeds
 * @function SearchEvents
 * @route {GET} /workers/datafeeds/events
 * @description
 *
 * A wrapper of TickPick's __/events/datafeed__ API endpoint that applies various filters to tailor the results for Iterable campaigns.
 *
 * @queryparam {string} [favoritePerformers] - A pipe-delimited list of favorite performer names to filter events by.
 * @queryparam {number} [maxDaysUntilEvent] - When provided, filters events to include only those occurring within the maximum number of days from today.
 * @queryparam {number} [maxFavoriteEvents] - When provided, limits the number of events marked as "is_favorited" to the specified maximum.
 * @queryparam {string} [trackedEvents] - A pipe-delimited list of tracked event IDs to filter events by.
 * @queryparam {number} [minEvents=5] - Specifies the minimum number of events required in the response. Defaults to 5 events. A 400 error is returned if the minimum is not met.
 * @queryparam {number} [maxEvents] - Specifies the maximum number of events to return in the response.
 * @queryparam {string} [eventTags] - A pipe-delimited list of event tags to filter events with. (e.g., "HotEvent|FeaturedEvent")
 * @queryparam {boolean} [uniquePerformerEvents=false] - When set to true, ensures that each event in the response has a unique main performer, filtering out duplicates.
 * @queryparam {boolean} [filterTbdEvents=false] - When set to true, excludes events that are TBD.
 *
 * @example
 * // Request events with a minimum of 5 events, filtered by favorite performers and tracked events
 * GET /workers/datafeeds/events?minEvents=5&favoritePerformers=Artist1|Artist2&uniquePerformerEvents=true
 */
df.get("/events", async (c: Context): Promise<Response> => {
  const reqUrl = new URL(c.req.url);

  const { originSearchParams, reqSearchParamValues } =
    RequestService.ProcessSearchEventsDataFeedParams(reqUrl.searchParams);

  const url = `${
    c.env.API_BASEURL
  }events/datafeed?${originSearchParams.toString()}`;

  // Extract the Authorization header from the incoming request
  const authHeader = c.req.raw.headers.get("Authorization");

  // Set up the headers for the fetch request
  const headers = new Headers();
  if (authHeader) {
    headers.set("Authorization", authHeader);
  }

  const res = await fetch(url, { headers });

  const cacheStatus = res.headers.get("CF-Cache-Status");
  console.log(
    `${
      cacheStatus === "HIT" ? "\x1b[32m" : "\x1b[31m"
    }${url.toString()} => CACHE ${cacheStatus}\x1b[0m`
  );

  let body = await res.json<SearchEventsDataFeedResponse>();

  return await EventsService.ProcessSearchEventsDatafeedResponse(
    c,
    body,
    reqSearchParamValues
  );
});

/**
 * @memberof module:DataFeeds
 * @function NearbyEvents
 * @route    {GET} /workers/datafeeds/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.
 * @queryparam {string}  [startDate]                Start date in YYYY-MM-dd format to filter events.
 *
 * @example
 * // Nearest concert or sports events within the next 10 days, filtered by favourite artists
 * GET /workers/datafeeds/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.
 */
df.get("/events/nearby", dma, async (c): Promise<Response> => {
  const reqUrl = new URL(c.req.url);

  const { reqSearchParamValues } =
    RequestService.ProcessSearchEventsDataFeedParams(reqUrl.searchParams);

  const dmaInfo = c.get("dma") as DMA | undefined;

  // Extract and format grandchildCategory if provided
  const grandchildCategory = reqUrl.searchParams.get("grandchildCategory");
  const formattedGrandchildCategory = grandchildCategory
    ? grandchildCategory
        .replace(/^Professional\s*|[()]/g, "") // drop "Professional …", "(" & ")"
        .toLowerCase()
        .trim()
    : undefined;

  // Extract and format startDate if provided
  const startDate = reqUrl.searchParams.get("startDate");
  const formattedStartDate = startDate
    ? startDate.replace(/-/g, "")
    : undefined;

  const key = [
    dmaInfo?.slug ?? reqUrl.searchParams.get("dmaSlug"),
    reqUrl.searchParams.get("parentCategory"),
    formattedGrandchildCategory,
    formattedStartDate,
  ]
    .filter(Boolean)
    .join(":")
    .toLowerCase();

  const entry =
    await c.env.LEGACY_NEARBY_EVENTS.get<SearchEventsDataFeedResponse>(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.",
    });
  }

  return await EventsService.ProcessSearchEventsDatafeedResponse(
    c,
    entry,
    reqSearchParamValues
  );
});

/**
 * @memberof module:DataFeeds
 * @function SearchMajorLeagueEvents
 * @route {GET} /workers/datafeeds/events/leagues
 * @description
 *
 * A wrapper of TickPick's __/events/datafeed__ API endpoint. Retrieves league-specific
 * event data from a specified set of leagues.
 *
 * Given a favoriteTeam or favoritePerformers query param, filters events to include only
 * those that match the provided favorite team or performers.
 *
 * @queryparam {string} league - A professional sports league (NBA, MLB, NFL, NHL, MLS, NCAA).
 * @queryparam {string} favoriteTeam - A performerId to filter events by. Required if favoritePerformers is not provided.
 * @queryparam {string} favoritePerformers - Pipe-delimited list of performer names to filter events by. Required if favoriteTeam is not provided.
 * @queryparam {string} [eventTags] - A pipe-delimited list of event tags to filter events with. (e.g., "HotEvent|FeaturedEvent")
 * @queryparam {number} [maxEvents=10] - The maximum number of events to return in the response.
 * @queryparam {number} [maxDaysUntilEvent=365] - The maximum number of days from today to an event date to filter events by.
 * @queryparam {boolean} [homeStandings=false] - Set to true when requesting MLB events to filter out non-home stand events.
 *
 * @example
 * // Request league events for the NBA, filter by a user's favorite team and limit to 5 events
 * GET /workers/datafeeds/events/leagues?league=NBA&favoriteTeam=Lakers&maxEvents=5
 */
df.get(
  "/events/leagues",
  validate("query", searchMajorLeagueEventsQuerySchema),
  async (c): Promise<Response> => {
    const { env } = c;

    const {
      league,
      favoriteTeam,
      favoritePerformers,
      eventTags,
      maxEvents,
      maxDaysUntilEvent,
      homeStandings,
      childCategory,
    } = c.get("query");

    const leaguePerformers = await RetrieveKvJsonData<LeaguePerformerValue>(
      env.LEAGUE_PERFORMERS,
      league.toString()
    );

    const leaguePerformer: LeaguePerformer | undefined = favoriteTeam
      ? leaguePerformers?.performers.find(
          (performer) => performer.id === favoriteTeam.toString()
        )
      : favoritePerformers
          .map((favorite) =>
            leaguePerformers?.performers.find((leaguePerformer) =>
              favorite.startsWith(leaguePerformer.name)
            )
          )
          .find((performer) => performer !== undefined);

    if (!leaguePerformer) {
      throw new HTTPException(404, {
        message: `No ${league} performers could be found for the provided favorite team or performers.`,
      });
    }

    // construct origin URL
    const url = new URL("events/datafeed", c.env.API_BASEURL);
    url.searchParams.set("performerName", leaguePerformer.name);
    if (league !== League.NCAA && leaguePerformer.home_venue.name) {
      url.searchParams.set("venueName", leaguePerformer.home_venue.name);
    }

    const authorizationHeader = c.req.raw.headers.get("Authorization") || "";

    // fetch the datafeed
    const res = await fetch(
      new Request(url, {
        headers: {
          Authorization: authorizationHeader,
        },
      })
    );

    const cacheStatus = res.headers.get("CF-Cache-Status");
    console.log(
      `${
        cacheStatus === "HIT" ? "\x1b[32m" : "\x1b[31m"
      }${url.toString()} => CACHE ${cacheStatus}\x1b[0m`
    );

    const body = await res.json<SearchEventsDataFeedResponse>();
    let { events, featured_events } = body;

    events.forEach(formatEventDatesForIterable);
    featured_events.forEach(formatEventDatesForIterable);

    // filter events by maxDaysUntilEvent
    events = EventsService.FilterEventsByMaxDaysUntilEvent(
      events,
      maxDaysUntilEvent
    );
    featured_events = EventsService.FilterEventsByMaxDaysUntilEvent(
      featured_events,
      maxDaysUntilEvent
    );

    // filter out any events that are not home games
    const filterHomeGames = (event: DataFeedEventResponse) =>
      event.main_performer.name.includes(leaguePerformer.name) &&
      event.main_performer.role === "Home Team";

    events = events.filter(filterHomeGames);
    featured_events = featured_events.filter(filterHomeGames);

    // filter out any events that are not home stand events (MLB only)
    if (league === League.MLB) {
      EventsService.flagHomeStandEvents(events);
      if (homeStandings) {
        events = events.filter((e) => e.is_home_standing);
        featured_events = featured_events.filter((e) => e.is_home_standing);
      }
    }

    // filter events by performer categories
    if (childCategory) {
      events = EventsService.filterEventsByPerformerCategory(events, {
        child: childCategory,
      });
    }

    // filter events by event tags
    if (eventTags) {
      events = EventsService.FilterEventsByEventTags(events, eventTags);
    }

    if (events.length === 0) {
      throw new HTTPException(404, {
        message: "No events found matching criteria.",
      });
    }

    body.events = events.slice(0, maxEvents);
    body.featured_events = featured_events;
    // search in both the main performer and secondary performers for the favorite performer
    // NCAA events will have the parent performer in the secondary performers
    // e.g. "Penn State Nittany Lions" vs "Penn State Nittany Lions Football"
    body.favorite_performer =
      events.find(
        (event) =>
          event.main_performer.performer_id.toString() === leaguePerformer.id
      )?.main_performer ??
      events[0].secondary_performers.find(
        (performer) => performer.performer_id.toString() === leaguePerformer.id
      );

    return c.json(body, res);
  }
);

/**
 * @memberof module:DataFeeds
 * @function AutoTrackEvents
 * @route    {GET} /workers/datafeeds/events/track/auto
 * @description
 * Retrieves a curated set of upcoming events—optimised for Iterable's automated
 * event-tracking campaigns—based on the caller's location and interests.
 *
 * Internally the handler pulls a pre-hydrated "nearby events" payload from the
 * Cloudflare KV store, applies TickPick's business-rules (favourites, date
 * windows, weekend logic, tag scoring, etc.) via `DataFeedService`, and returns
 * a normalised structure that Iterable can ingest directly.
 *
 * @queryparam {number}  lat                     Geographic latitude used to resolve the user’s DMA.
 * @queryparam {number}  long                    Geographic longitude used to resolve the user’s DMA.
 * @queryparam {string}  parentCategory          Top-level event category, accepted values: `"concert"` or `"sports"`.
 * @queryparam {number}  [minDaysUntilEvent=1]   Lower-bound (inclusive) on the event date, expressed as days from “today”.
 * @queryparam {number}  [maxDaysUntilEvent]     Upper-bound (inclusive) on the event date, expressed as days from “today”.
 * @queryparam {boolean} [filterTbdEvents=false] When `true`, excludes events whose names contain a “TBD” placeholder.
 * @queryparam {string}  [favoritePerformers]    Pipe-delimited list of performer names used to flag favorite events.
 * @queryparam {number}  [maxEvents=1]           Maximum number of events to return after all ranking and filtering.
 *
 * @returns {Object} JSON payload shaped as
 *                   `{ autoTrackedEvents: [{ event: TrackedEvent }, …] }`,
 *                   where each `TrackedEvent` matches Iterable’s required schema.
 */
df.get(
  "/events/track/auto",
  validate("query", autoTrackEventsQuerySchema),
  dma,
  async (c): Promise<Response> => {
    const {
      parentCategory,
      minDaysUntilEvent,
      maxDaysUntilEvent,
      favoritePerformers,
      maxEvents,
      filterTbdEvents,
    } = c.get("query");

    const now = new Date();
    const startDate = new Date(now);
    startDate.setDate(startDate.getDate() + minDaysUntilEvent);
    const startDateString = startDate
      .toISOString()
      .split("T")[0]
      .replace(/-/g, "");

    const dmaInfo = c.get("dma");

    const key = [dmaInfo?.slug ?? "", parentCategory, startDateString]
      .filter(Boolean)
      .join(":")
      .toLowerCase();

    const entry = await c.env.NEARBY_EVENTS.get<NearbyEventEntry>(key, {
      type: "json",
    });

    if (!entry || entry.events.length === 0) {
      throw new HTTPException(404, { message: "No events found." });
    }

    const service = new DataFeedService();
    const result = service.autoTrackEvents(entry, {
      parentCategory,
      minDaysUntilEvent,
      maxDaysUntilEvent,
      favoritePerformers,
      filterTbdEvents,
      maxEvents,
    });

    return c.json(result);
  }
);

/**
 * @memberof module:DataFeeds
 * @function GetBrokerData
 * @route {GET} /workers/datafeeds/brokers/:email
 * @description
 *
 * Retrieves broker data from the broker CSV file for a specified email address.
 * Returns the total tickets and total broker payout for the matching broker.
 *
 * @example
 * // Request broker data for a specific email
 * GET /workers/datafeeds/brokers/orders@714tickets.com?template=super_bowl_metrics
 */
df.get("/brokers/:email", async (c) => {
  const { email } = c.req.param();
  const template = c.req.query("template");

  if (!template) {
    throw new HTTPException(400, {
      message: "Missing required query parameter 'template'.",
    });
  }

  const csvContent = await c.env.BROKER_DATAFEEDS.get(template, "text");

  if (!csvContent) {
    throw new HTTPException(404, {
      message: `No data for template "${template}" could be found.`,
    });
  }

  const parsedData: Papa.ParseResult<Record<string, string | number>> =
    Papa.parse(csvContent, {
      header: true,
      skipEmptyLines: true,
      transformHeader: (header: string): string => header.trim(),
      transform: (value: string): string | number => {
        if (value.includes("$")) {
          return parseFloat(value.replace(/[$,]/g, ""));
        }
        return value.trim();
      },
    });

  // Find the row where any column value matches the email
  const brokerData = parsedData.data.find(
    (row: Record<string, string | number>) => {
      return Object.values(row).some(
        (value) =>
          value !== undefined &&
          value !== null &&
          value.toString().toLowerCase() === email.toLowerCase()
      );
    }
  );

  if (!brokerData) {
    throw new HTTPException(404, {
      message: "Broker not found.",
    });
  }

  // Transform the data object keys to lowercase with underscores
  const formattedData = Object.entries(brokerData).reduce<
    Record<string, string | number>
  >((acc, [key, value]) => {
    const formattedKey = key.toLowerCase().replace(/\s+/g, "_");
    acc[formattedKey] = value;
    return acc;
  }, {});

  return c.json(formattedData);
});

const app = new Hono();

app.route("/workers/datafeeds", df);
app.route("/1.0/workers/datafeeds", df);

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;