jevstrudel.git / worker / src / activity.ts
1// What is happening on the site, newest first: the jev panel's activity
2// tab (website/src/jev/ActivityTab.jsx), from GET /jev/data/activity
3// (data.ts). Every source is a bounded query, newest first, from before a
4// cursor; the merge takes the newest `limit` of them all and answers the
5// cursor for the next page (`next`, the oldest item's time), so paging
6// never skips or repeats more than items sharing one millisecond.
7//
8//   revision    a listener published a song or its next revision, with
9//               Jev's verdict as it stands (pending, fine, spam, abuse)
10//   scored      the art critic scored a fine revision
11//   comment     a comment on a song, with its verdict
12//   pitch       a song idea, with its verdict
13//   take        someone played a song as Jev arranged it (a stored
14//               performance): who (null when signed out), the song, how
15//               many sections it played and how it ended, and the take's
16//               id, for its replay link
17//
18// Held and pending text is not in the feed: an item says
19// Jev held it and why, and the data tab has its text; the feed carries the
20// body of fine comments and pitches only.
21export const ACTIVITY_MAX = 50;
22export const ACTIVITY_DEFAULT = 30;
23
24type Author = { id: string; name: string };
25export type Activity =
26  | { kind: 'revision'; at: number; song: string; rev: number; title: string; author: Author; screen: string; art: number | null }
27  | { kind: 'scored'; at: number; song: string; rev: number; title: string; author: Author; art: number }
28  | {
29      kind: 'comment';
30      at: number;
31      id: string;
32      author: Author;
33      on: { site: string } | { listener: string; title: string | null };
34      screen: string;
35      body: string | null;
36    }
37  | { kind: 'pitch'; at: number; id: string; author: Author; screen: string; body: string | null }
38  | {
39      kind: 'take';
40      at: number;
41      id: string;
42      song: string;
43      // a listener song's newest title (a site song's the page has)
44      title: string | null;
45      player: Author | null;
46      sections: number;
47      ended: string;
48    };
49
50type Row = Record<string, unknown>;
51const author = (r: Row): Author => ({ id: r.author_id as string, name: r.author as string });
52
53// The newest `limit` of every source, before `before`.
54export function merge(sources: Activity[][], limit: number): { items: Activity[]; next: number | null } {
55  const items = sources
56    .flat()
57    .sort((a, b) => b.at - a.at)
58    .slice(0, limit);
59  return { items, next: items.length === limit ? items[items.length - 1].at : null };
60}
61
62export async function activity(db: D1Database, before: number, limit: number) {
63  const [revisions, scored, comments, pitches, played] = (await db.batch([
64    db
65      .prepare(
66        `SELECT r.song, r.rev, r.title, r.created_at AS at, r.screen, r.art, u.id AS author_id, u.display_name AS author
67         FROM song_revisions r JOIN listener_songs s ON s.id = r.song JOIN users u ON u.id = s.user_id
68         WHERE r.created_at < ? ORDER BY r.created_at DESC LIMIT ?`,
69      )
70      .bind(before, limit),
71    db
72      .prepare(
73        `SELECT r.song, r.rev, r.title, r.scored_at AS at, r.art, u.id AS author_id, u.display_name AS author
74         FROM song_revisions r JOIN listener_songs s ON s.id = r.song JOIN users u ON u.id = s.user_id
75         WHERE r.scored_at IS NOT NULL AND r.scored_at < ? ORDER BY r.scored_at DESC LIMIT ?`,
76      )
77      .bind(before, limit),
78    db
79      .prepare(
80        `SELECT c.id, c.site_song, c.listener_song, c.body, c.created_at AS at, c.screen,
81           u.id AS author_id, u.display_name AS author,
82           (SELECT title FROM song_revisions r WHERE r.song = c.listener_song AND r.screen = 'fine'
83              ORDER BY r.rev DESC LIMIT 1) AS listener_title
84         FROM comments c JOIN users u ON u.id = c.user_id
85         WHERE c.created_at < ? ORDER BY c.created_at DESC LIMIT ?`,
86      )
87      .bind(before, limit),
88    db
89      .prepare(
90        `SELECT p.id, p.body, p.created_at AS at, p.screen, u.id AS author_id, u.display_name AS author
91         FROM pitches p JOIN users u ON u.id = p.user_id
92         WHERE p.created_at < ? ORDER BY p.created_at DESC LIMIT ?`,
93      )
94      .bind(before, limit),
95    db
96      .prepare(
97        `SELECT p.id, p.song, p.recorded_at AS at, p.ended, u.id AS author_id, u.display_name AS author,
98           (SELECT count(*) FROM performance_segments s WHERE s.performance = p.id AND s.jev = coalesce(p.form_jev, 0)) AS sections,
99           (SELECT r.title FROM song_revisions r WHERE p.song = 'listener:' || r.song AND r.screen = 'fine' ORDER BY r.rev DESC LIMIT 1) AS title
100         FROM performances p LEFT JOIN users u ON u.id = p.user_id
101         WHERE p.recorded_at < ? ORDER BY p.recorded_at DESC LIMIT ?`,
102      )
103      .bind(before, limit),
104  ])) as D1Result<Row>[];
105
106  const sources: Activity[][] = [
107    revisions.results.map((r) => ({
108      kind: 'revision' as const,
109      at: r.at as number,
110      song: r.song as string,
111      rev: r.rev as number,
112      title: r.title as string,
113      author: author(r),
114      screen: r.screen as string,
115      art: (r.art as number | null) ?? null,
116    })),
117    scored.results.map((r) => ({
118      kind: 'scored' as const,
119      at: r.at as number,
120      song: r.song as string,
121      rev: r.rev as number,
122      title: r.title as string,
123      author: author(r),
124      art: r.art as number,
125    })),
126    comments.results.map((r) => ({
127      kind: 'comment' as const,
128      at: r.at as number,
129      id: r.id as string,
130      author: author(r),
131      on: r.site_song
132        ? { site: r.site_song as string }
133        : { listener: r.listener_song as string, title: (r.listener_title as string | null) ?? null },
134      screen: r.screen as string,
135      body: r.screen === 'fine' ? (r.body as string) : null,
136    })),
137    pitches.results.map((r) => ({
138      kind: 'pitch' as const,
139      at: r.at as number,
140      id: r.id as string,
141      author: author(r),
142      screen: r.screen as string,
143      body: r.screen === 'fine' ? (r.body as string) : null,
144    })),
145    played.results.map((r) => ({
146      kind: 'take' as const,
147      at: r.at as number,
148      id: r.id as string,
149      song: r.song as string,
150      title: (r.title as string | null) ?? null,
151      player: typeof r.author_id === 'string' ? author(r) : null,
152      sections: r.sections as number,
153      ended: r.ended as string,
154    })),
155  ];
156  return merge(sources, limit);
157}