src/features/feeds.ts
Rama main · réplica de lectura del repositorio original.
/**
* ICS and RSS feed routes for listing syndication (e.g. Mobilizon integration)
* Gated behind the Site feature.
*/
import { decryptAttendees } from "#db/attendees/pii.ts";
import { getAttendeesByListingIds } from "#db/listings/attendees.ts";
import { getAllListings } from "#db/listings/records.ts";
import {
bookingAssignmentKey,
getLogisticsAssignmentsForAttendees,
} from "#db/logistics.ts";
import { getNewsPostSummaries } from "#db/news-posts.ts";
import { settings } from "#db/settings.ts";
import { userAgents } from "#db/user-agents.ts";
import { identity, map, mapById, pipe } from "#fp";
import type { CoreListingFields } from "#routes/api/public-listing.ts";
import { withAuth } from "#routes/auth.ts";
import { isRegistrationClosed } from "#routes/format.ts";
import { classifyForDiscovery } from "#routes/public/discovery.ts";
import {
loadBookablePackages,
publicGroupSummary,
} from "#routes/public/group-liveness.ts";
import {
icsResponse,
notFoundResponse,
rssResponse,
} from "#routes/response.ts";
import { createRouter, defineRoutes } from "#routes/router.ts";
import { getEffectiveDomain } from "#shared/config.ts";
import { isPublicListing } from "#shared/listing-visibility.ts";
import { nowIso } from "#shared/now.ts";
import { requirePublicSite } from "#shared/public-site.ts";
import { replacing } from "#shared/replacements.ts";
import { getRequestPrivateKey } from "#shared/session-private-key.ts";
import {
type ListingWithCount,
loadSortedListings,
} from "#shared/sort-listings.ts";
import { escapeXml } from "#shared/xml.ts";
import type { Attendee, NewsPostSummary } from "#types";
/** Escape text for ICS (RFC 5545): backslash-escape special characters */
export const escapeIcs = replacing(
[/\\/g, "\\\\"],
[/;/g, "\\;"],
[/,/g, "\\,"],
[/\n/g, "\\n"],
);
/** Format a date string as ICS UTC timestamp (YYYYMMDDTHHMMSSZ) */
const formatIcsDate = (dateStr: string): string =>
new Date(dateStr)
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
/** Format a date string as RFC 822 (for RSS pubDate) */
const formatRfc822 = (dateStr: string): string =>
new Date(dateStr).toUTCString();
/** One syndicated item — a standalone listing or a bookable package bundle,
* both linking to their `/ticket/<slug>` page. `uid` keeps listing and package
* ids from colliding in the ICS UID namespace. */
type FeedItem = CoreListingFields & {
uid: string;
location: string;
/** RSS pubDate source; null for packages (groups carry no created stamp),
* which simply omit the optional pubDate element. */
created: string | null;
};
const listingFeedItem = (listing: ListingWithCount): FeedItem => ({
created: listing.created,
date: listing.date || null,
description: listing.description,
location: listing.location,
name: listing.name,
slug: listing.slug,
uid: `listing-${listing.id}`,
});
/** Feed context: items, domain, and title */
type FeedData = { items: FeedItem[]; domain: string; title: string };
/** Load feed data: active open listings plus bookable packages. Children are
* never syndicated (a feed item is a standalone `/ticket/<slug>` link, which a
* booking can't start from), and a parent with no bookable child
* is omitted (it would publish a link the gate rejects as sold out).
* Packages are first-class products, so the bundle itself is syndicated at
* `/ticket/<group-slug>`. */
const loadFeedData = async (): Promise<FeedData> => {
const { listings } = await loadSortedListings(
(e) => isPublicListing(e) && !e.purchase_only && !isRegistrationClosed(e),
);
const { nonStandaloneChildIds, soldOutParentIds } =
await classifyForDiscovery(listings);
const packages = (await loadBookablePackages()).map(
(loaded): FeedItem => ({
created: null,
date: null,
location: "",
...publicGroupSummary(loaded),
uid: `package-${loaded.group.id}`,
}),
);
return {
domain: getEffectiveDomain(),
items: [
...listings
.filter(
(e) =>
!nonStandaloneChildIds.has(e.id) && !soldOutParentIds.has(e.id),
)
.map(listingFeedItem),
...packages,
],
title: settings.websiteTitle || "Listings",
};
};
/** Append the shared DTSTART/LOCATION lines for anything carrying a date and
* location — feed items and (via the admin calendar's VEVENTs) listings. */
const appendItemSchedule = (
lines: string[],
item: { date: string | null; location: string },
): void => {
if (item.date) lines.push(`DTSTART:${formatIcsDate(item.date)}`);
if (item.location) lines.push(`LOCATION:${escapeIcs(item.location)}`);
};
const buildVListing = (
item: FeedItem,
domain: string,
dtstamp: string,
): string => {
const lines = [
"BEGIN:VLISTING",
`UID:${item.uid}@${domain}`,
`DTSTAMP:${dtstamp}`,
`SUMMARY:${escapeIcs(item.name)}`,
`URL:https://${domain}/ticket/${item.slug}`,
];
if (item.description) {
lines.push(`DESCRIPTION:${escapeIcs(item.description)}`);
}
appendItemSchedule(lines, item);
lines.push("END:VLISTING");
return lines.join("\r\n");
};
/** Build the full ICS calendar document */
const buildIcs = ({ items, domain, title }: FeedData): string => {
const dtstamp = formatIcsDate(nowIso());
const vlistings = pipe(
map((e: FeedItem) => buildVListing(e, domain, dtstamp)),
)(items);
return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Chobble Tickets//EN",
`X-WR-CALNAME:${escapeIcs(title)}`,
...vlistings,
"END:VCALENDAR",
].join("\r\n");
};
/** Build a rich description for RSS items, including date and location */
const buildRssDescription = (item: FeedItem): string => {
const parts: string[] = [];
if (item.description) parts.push(item.description);
if (item.date) parts.push(`Date: ${formatRfc822(item.date)}`);
if (item.location) parts.push(`Location: ${item.location}`);
return parts.join("\n");
};
/** One `<item>` of any RSS channel — listings and news share this shape. */
type RssItem = {
title: string;
link: string;
description: string;
/** RSS pubDate source; null omits the optional element. */
pubDate: string | null;
};
/** Build a single RSS item */
const buildRssItem = (item: RssItem): string =>
[
" <item>",
` <title>${escapeXml(item.title)}</title>`,
` <link>${item.link}</link>`,
` <guid isPermaLink="true">${item.link}</guid>`,
` <description>${escapeXml(item.description)}</description>`,
...(item.pubDate
? [` <pubDate>${formatRfc822(item.pubDate)}</pubDate>`]
: []),
" </item>",
].join("\n");
/** Build a full RSS document from its channel header and items. */
const buildRssDocument = (
channel: { title: string; link: string; description: string },
items: RssItem[],
): string =>
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0">',
" <channel>",
` <title>${escapeXml(channel.title)}</title>`,
` <link>${channel.link}</link>`,
` <description>${escapeXml(channel.description)}</description>`,
...pipe(map(buildRssItem))(items),
" </channel>",
"</rss>",
].join("\n");
/** The listings RSS document: every syndicated listing/package as an item. */
const buildRss = ({ items: feedItems, domain, title }: FeedData): string =>
buildRssDocument(
{
description: `Listings from ${title}`,
link: `https://${domain}/listings`,
title,
},
feedItems.map((item) => ({
description: buildRssDescription(item),
link: `https://${domain}/ticket/${item.slug}`,
pubDate: item.created,
title: item.name,
})),
);
/** The news RSS document: every post, newest first, linking to `/news/:slug`
* with the plain-text snippet as the description. */
const buildNewsRss = (posts: NewsPostSummary[], domain: string): string => {
const title = settings.websiteTitle || "News";
return buildRssDocument(
{
description: `News from ${title}`,
link: `https://${domain}/news`,
title,
},
posts.map((post) => ({
description: post.snippet,
link: `https://${domain}/news/${post.slug}`,
pubDate: post.created,
title: post.name,
})),
);
};
const eventUrl = (domain: string, attendee: Attendee): string =>
`https://${domain}/admin/attendees/${attendee.id}`;
const attendeeName = (attendee: Attendee): string =>
attendee.name || `Attendee ${attendee.id}`;
const buildVEvent = (opts: {
attendee: Attendee;
domain: string;
dtstamp: string;
listing: ListingWithCount;
}): string => {
const { attendee, domain, dtstamp, listing } = opts;
const summary =
settings.calendarFeedsGroupBy === "listings"
? listing.name
: attendeeName(attendee);
const description =
settings.calendarFeedsGroupBy === "listings"
? attendeeName(attendee)
: listing.name;
const lines = [
"BEGIN:VEVENT",
`UID:attendee-${attendee.id}-listing-${listing.id}@${domain}`,
`DTSTAMP:${dtstamp}`,
`SUMMARY:${escapeIcs(summary)}`,
`DESCRIPTION:${escapeIcs(`${description} — open the site for details`)}`,
`URL:${eventUrl(domain, attendee)}`,
];
appendItemSchedule(lines, listing);
lines.push("END:VEVENT");
return lines.join("\r\n");
};
const assignmentIncludesUser = (
assignment: { endAgentId: number | null; startAgentId: number | null },
userId: number,
): boolean => [assignment.startAgentId, assignment.endAgentId].includes(userId);
const filterCalendarFeedAttendees = async (
attendees: Attendee[],
session: { adminLevel: string; userId: number },
): Promise<Attendee[]> => {
if (session.adminLevel !== "agent") return attendees;
const [assignments, agentIds] = await Promise.all([
getLogisticsAssignmentsForAttendees(attendees.map((a) => a.id)),
userAgents.getIds(session.userId),
]);
const visible = new Set<string>();
for (const assignment of assignments) {
if (
agentIds.some((agentId) => assignmentIncludesUser(assignment, agentId))
) {
visible.add(
bookingAssignmentKey(assignment.attendeeId, assignment.listingId),
);
}
}
return attendees.filter((a) =>
visible.has(bookingAssignmentKey(a.id, a.listing_id)),
);
};
/* jscpd:ignore-start */
const buildCalendarFeed = async (request: Request): Promise<Response> => {
if (!settings.calendarFeedsEnabled) {
return new Response("Not found", { status: 404 });
}
return withAuth(
request,
{ allowApiKey: true, body: "json", roles: ["owner", "manager", "agent"] },
async (session) => {
const privateKey = await getRequestPrivateKey();
const listings = await getAllListings();
const listingById = mapById(identity<ListingWithCount>)(listings);
const rawAttendees = await getAttendeesByListingIds(
listings.map((l) => l.id),
// Operational ICS feed: exclude no-quantity sentinel lines.
true,
);
const attendees = await filterCalendarFeedAttendees(
await decryptAttendees(rawAttendees, privateKey!),
session,
);
const domain = getEffectiveDomain();
const dtstamp = formatIcsDate(nowIso());
const events = attendees.flatMap((attendee) => {
const listing = listingById.get(attendee.listing_id);
return listing?.date
? [buildVEvent({ attendee, domain, dtstamp, listing })]
: [];
});
return icsResponse(
[
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Chobble Tickets//EN",
`X-WR-CALNAME:${escapeIcs(settings.websiteTitle || "Tickets")}`,
...events,
"END:VCALENDAR",
].join("\r\n"),
);
},
);
};
/* jscpd:ignore-end */
/** A feed route: build the body, then send it, but only on a public site. A
* builder with nothing to syndicate returns `null`, and the route sends the
* site's not-found page instead. */
const feedRoute =
(send: (body: string) => Response, build: () => Promise<string | null>) =>
(): Promise<Response> =>
requirePublicSite(async () => {
const body = await build();
return body === null ? notFoundResponse() : send(body);
}) as Promise<Response>;
/** Handle GET /feeds/listings.ics */
const handleIcs = feedRoute(icsResponse, async () =>
buildIcs(await loadFeedData()),
);
/** Handle GET /feeds/listings.rss */
const handleRss = feedRoute(rssResponse, async () =>
buildRss(await loadFeedData()),
);
/** Handle GET /feeds/news.rss — a site with no posts has no news feed, the
* same answer `/news` gives. */
const handleNewsRss = feedRoute(rssResponse, async () => {
const posts = await getNewsPostSummaries();
return posts.length === 0 ? null : buildNewsRss(posts, getEffectiveDomain());
});
/** Feed routes */
export const routeFeed = createRouter(
defineRoutes({
"GET /caldav/events.ics": buildCalendarFeed,
"GET /feeds/listings.ics": handleIcs,
"GET /feeds/listings.rss": handleRss,
"GET /feeds/news.rss": handleNewsRss,
}),
);