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