/** * Gathers what the whole site did over a period, for the `/community` page * and the digests' shared sections. The digests ask {@link communityStatsOf} * for each reader's own period. The page reads the figures of the week and * the month ending at the last refresh, which {@link communityJob} redoes * every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query. * * It also gathers the community feed ({@link getCommunityFeed}), the latest * things done in public, which is read from the tables on each request: it * has to show a dump the moment it is posted. * * @module */ import { db } from "../db/connection.ts"; import { COMMUNITY_REFRESH_MINUTES } from "../config.ts"; import type { CommunityEvent, CommunityFeed, CommunityItem, CommunityStats, } from "../../shared/community.ts"; import { DIGEST_CADENCES, type DigestCadence, PERIOD_MS, } from "../../shared/settings.ts"; import type { Job } from "../lib/scheduler.ts"; import { parseRows } from "../model/rows.ts"; import { listTopDumps } from "./dump-service.ts"; import { listNewPublicPlaylists } from "./playlist-service.ts"; import { listNewUsers } from "./user-service.ts"; /** How many top dumps a period keeps, as many as the monthly recap shows. */ const TOP_MAX = 10; /** Returns how many rows of `table` were created from `from` to `to`. */ function countOf( table: string, from: string, to: string, where = "1", ): number { return (db.prepare( `SELECT COUNT(*) AS n FROM ${table} WHERE ${where} AND created_at >= ? AND created_at < ?;`, ).get(from, to) as { n: number }).n; } /** * Returns what the site did from `from` to `to`: its most upvoted public * dumps, who joined, the new public playlists, the chat's messages and the * totals. */ export function communityStatsOf(from: Date, to: Date): CommunityStats { const [f, t] = [from.toISOString(), to.toISOString()]; return { from, to, top: listTopDumps(from, to, TOP_MAX), members: listNewUsers(from, to), playlists: listNewPublicPlaylists(from, to), chatMessages: countOf("chat_messages", f, t), totals: { dumps: countOf("dumps", f, t, "is_private = 0"), upvotes: countOf("votes", f, t), comments: countOf("comments", f, t, "deleted = 0"), }, }; } /** The figures {@link getCommunityStats} serves, by period. */ const latest = new Map(); /** Recomputes the figures of each period, ending at `now`. */ export function refreshCommunityStats(now: Date = new Date()): void { for (const period of DIGEST_CADENCES) { latest.set( period, communityStatsOf(new Date(now.getTime() - PERIOD_MS[period]), now), ); } } /** * Returns the figures of `period` as of the last refresh, for * `GET /api/community`, refreshing them first if there are none yet. */ export function getCommunityStats(period: DigestCadence): CommunityStats { if (!latest.has(period)) refreshCommunityStats(); return latest.get(period)!; } /** * Refreshes the figures at startup, then every * {@link COMMUNITY_REFRESH_MINUTES}. `main.ts` starts it. */ export const communityJob: Job = { name: "community", everyMs: COMMUNITY_REFRESH_MINUTES * 60_000, firstAfterMs: 0, run: () => refreshCommunityStats(), }; /** How many events the feed holds, and how many of each kind it reads. */ const FEED_MAX = 12; /** * A row of {@link listCommunityEvents}'s query, whose five parts share these * columns. `title`, `slug` and `owner_username` name the dump or the playlist * of every event but a `user_joined`. */ interface EventRow { type: CommunityEvent["type"]; /** The dump, the playlist or the new user. */ subject_id: string; at: string; username: string; title: string | null; slug: string | null; owner_username: string | null; comment_id: string | null; invited_by: string | null; } /** Checks `row`'s columns that every event has. */ function isEventRow(row: unknown): row is EventRow { const r = row as Partial | null; return !!r && typeof r.type === "string" && typeof r.subject_id === "string" && typeof r.at === "string" && typeof r.username === "string" && (r.type === "user_joined" || (typeof r.title === "string" && typeof r.slug === "string" && typeof r.owner_username === "string")); } function eventOf(row: EventRow): CommunityEvent { const base = { id: `${row.type}:${row.subject_id}`, at: new Date(row.at), username: row.username, }; // `isEventRow` has checked the three on every row that reads `item`. const item: CommunityItem = { title: row.title!, slug: row.slug!, ownerUsername: row.owner_username!, }; switch (row.type) { case "dump_posted": case "dump_upvoted": return { ...base, type: row.type, dump: item }; case "dump_commented": return { ...base, type: row.type, dump: item, commentId: row.comment_id!, }; case "playlist_created": return { ...base, type: row.type, playlist: item }; case "user_joined": return { ...base, type: row.type, invitedByUsername: row.invited_by ?? undefined, }; } } /** * Returns the latest things done in public, newest first, {@link FEED_MAX} at * most: the public dumps posted and playlists created, the comments and * upvotes on public dumps, and who joined. A dump has one comment event and * one upvote event at most, its latest, so that a thread or a popular dump * doesn't fill the feed. * * Each part reads its {@link FEED_MAX} latest, which is all the whole may * keep of it. The comments and the upvotes are grouped by dump first, and * with `MAX()` SQLite fills a group's other columns from the row that holds * the maximum: the latest comment's author and id, the latest voter. * * @throws {APIException} 500 when a row doesn't parse as an event. */ export function listCommunityEvents(): CommunityEvent[] { const rows = db.prepare( `SELECT * FROM ( SELECT 'dump_posted' AS type, d.id AS subject_id, d.created_at AS at, u.username AS username, d.title AS title, d.slug AS slug, u.username AS owner_username, NULL AS comment_id, NULL AS invited_by FROM dumps d JOIN users u ON u.id = d.user_id WHERE d.is_private = 0 ORDER BY d.created_at DESC LIMIT ${FEED_MAX}) UNION ALL SELECT * FROM ( SELECT 'playlist_created', p.id, p.created_at, u.username, p.title, p.slug, u.username, NULL, NULL FROM playlists p JOIN users u ON u.id = p.user_id WHERE p.is_public = 1 ORDER BY p.created_at DESC LIMIT ${FEED_MAX}) UNION ALL SELECT * FROM ( SELECT 'dump_commented', d.id, MAX(c.created_at) AS at, cu.username, d.title, d.slug, ou.username, c.id, NULL FROM comments c JOIN dumps d ON d.id = c.dump_id JOIN users cu ON cu.id = c.user_id JOIN users ou ON ou.id = d.user_id WHERE c.deleted = 0 AND d.is_private = 0 GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX}) UNION ALL SELECT * FROM ( SELECT 'dump_upvoted', d.id, MAX(v.created_at) AS at, vu.username, d.title, d.slug, ou.username, NULL, NULL FROM votes v JOIN dumps d ON d.id = v.dump_id JOIN users vu ON vu.id = v.user_id JOIN users ou ON ou.id = d.user_id WHERE d.is_private = 0 GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX}) UNION ALL SELECT * FROM ( SELECT 'user_joined', u.id, u.created_at, u.username, NULL, NULL, NULL, NULL, i.username FROM users u LEFT JOIN users i ON i.id = u.invited_by ORDER BY u.created_at DESC LIMIT ${FEED_MAX}) ORDER BY at DESC LIMIT ${FEED_MAX};`, ).all(); return parseRows(rows, isEventRow, eventOf); } /** * Returns the community feed for `GET /api/community/feed`: the latest * events, read now, and the last week's figures as of the last refresh. */ export function getCommunityFeed(): CommunityFeed { const { totals, chatMessages, top } = getCommunityStats("weekly"); const [best] = top; return { events: listCommunityEvents(), week: { totals, chatMessages, top: best && { title: best.title, slug: best.slug, ownerUsername: best.ownerUsername, }, }, }; }