249 lines
8.2 KiB
TypeScript
249 lines
8.2 KiB
TypeScript
/**
|
|
* 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<DigestCadence, CommunityStats>();
|
|
|
|
/** 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<EventRow> | 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,
|
|
},
|
|
},
|
|
};
|
|
}
|