Ctrl K
ComunidadGuías…

src/features/feeds.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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,
  }),
);