@wtfalch/threads 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/bin/copy.d.ts +31 -0
- package/dist/bin/copy.js +58 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +21 -0
- package/dist/gates.d.ts +48 -0
- package/dist/gates.js +38 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/migrations/0001_threads.sql +185 -0
- package/dist/react/CategoryList.d.ts +9 -0
- package/dist/react/CategoryList.js +7 -0
- package/dist/react/CommentForm.d.ts +19 -0
- package/dist/react/CommentForm.js +35 -0
- package/dist/react/CommentSection.d.ts +42 -0
- package/dist/react/CommentSection.js +26 -0
- package/dist/react/NewThreadForm.d.ts +18 -0
- package/dist/react/NewThreadForm.js +29 -0
- package/dist/react/Roadmap.d.ts +10 -0
- package/dist/react/Roadmap.js +12 -0
- package/dist/react/ThreadList.d.ts +21 -0
- package/dist/react/ThreadList.js +15 -0
- package/dist/react/ThreadView.d.ts +26 -0
- package/dist/react/ThreadView.js +20 -0
- package/dist/react/format.d.ts +16 -0
- package/dist/react/format.js +42 -0
- package/dist/react/index.d.ts +25 -0
- package/dist/react/index.js +17 -0
- package/dist/schema.d.ts +2085 -0
- package/dist/schema.js +129 -0
- package/dist/threads.css +182 -0
- package/dist/threads.d.ts +109 -0
- package/dist/threads.js +626 -0
- package/dist/tree.d.ts +47 -0
- package/dist/tree.js +75 -0
- package/package.json +72 -0
package/dist/threads.js
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
2
|
+
import { CATEGORY, ThreadsError, categorySubject, } from './gates.js';
|
|
3
|
+
import { schema, threadCategories, threadComments, threadOutbox, threadPeople, threadSubscriptions, threadVotes, threads, } from './schema.js';
|
|
4
|
+
export const DEFAULT_LIMITS = { threadsPerHour: 5, commentsPerTenMinutes: 30 };
|
|
5
|
+
export function slugify(title) {
|
|
6
|
+
return (title
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.normalize('NFKD')
|
|
9
|
+
.replace(/\p{M}/gu, '')
|
|
10
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
11
|
+
.replace(/^-+|-+$/g, '')
|
|
12
|
+
.slice(0, 80) || 'thread');
|
|
13
|
+
}
|
|
14
|
+
function subjectOf(thread) {
|
|
15
|
+
return { kind: thread.subjectKind, id: thread.subjectId };
|
|
16
|
+
}
|
|
17
|
+
export function createThreads(opts) {
|
|
18
|
+
const { db, gates } = opts;
|
|
19
|
+
const limits = { ...DEFAULT_LIMITS, ...opts.limits };
|
|
20
|
+
const now = opts.now ?? (() => new Date());
|
|
21
|
+
async function mayRead(subject, who) {
|
|
22
|
+
if (!(await gates.read(subject, who))) {
|
|
23
|
+
throw new ThreadsError('forbidden', 'not allowed to read here');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function mayWrite(subject, who) {
|
|
27
|
+
if (!who || !(await gates.write(subject, who))) {
|
|
28
|
+
throw new ThreadsError('forbidden', 'not allowed to write here');
|
|
29
|
+
}
|
|
30
|
+
return who;
|
|
31
|
+
}
|
|
32
|
+
async function mayModerate(subject, who) {
|
|
33
|
+
if (!who || !(await gates.moderate(subject, who))) {
|
|
34
|
+
throw new ThreadsError('forbidden', 'not allowed to moderate here');
|
|
35
|
+
}
|
|
36
|
+
return who;
|
|
37
|
+
}
|
|
38
|
+
async function moderates(subject, who) {
|
|
39
|
+
return who !== null && (await gates.moderate(subject, who));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Records the person and returns the row. Called on every write, so the
|
|
43
|
+
* name follows the issuer; the email moves only once the issuer verified it,
|
|
44
|
+
* because it is where notifications go.
|
|
45
|
+
*/
|
|
46
|
+
async function seen(who) {
|
|
47
|
+
const set = who.emailVerified
|
|
48
|
+
? { name: who.name, email: who.email, lastSeenAt: sql `now()` }
|
|
49
|
+
: { name: who.name, lastSeenAt: sql `now()` };
|
|
50
|
+
const [row] = await db
|
|
51
|
+
.insert(threadPeople)
|
|
52
|
+
.values({
|
|
53
|
+
id: who.id,
|
|
54
|
+
name: who.name,
|
|
55
|
+
email: who.emailVerified ? who.email : null,
|
|
56
|
+
lastSeenAt: sql `now()`,
|
|
57
|
+
})
|
|
58
|
+
.onConflictDoUpdate({ target: threadPeople.id, set })
|
|
59
|
+
.returning();
|
|
60
|
+
if (!row)
|
|
61
|
+
throw new Error('seen: no row');
|
|
62
|
+
return row;
|
|
63
|
+
}
|
|
64
|
+
async function inGoodStanding(who) {
|
|
65
|
+
const person = await seen(who);
|
|
66
|
+
if (person.standing !== 'ok') {
|
|
67
|
+
throw new ThreadsError('standing', `this account is ${person.standing}`);
|
|
68
|
+
}
|
|
69
|
+
return person;
|
|
70
|
+
}
|
|
71
|
+
async function underLimit(table, authorId, windowMs, max, what) {
|
|
72
|
+
const since = new Date(now().getTime() - windowMs);
|
|
73
|
+
const [row] = await db
|
|
74
|
+
.select({ n: sql `count(*)::int` })
|
|
75
|
+
.from(table)
|
|
76
|
+
.where(and(eq(table.authorId, authorId), sql `${table.createdAt} > ${since}`));
|
|
77
|
+
if ((row?.n ?? 0) >= max)
|
|
78
|
+
throw new ThreadsError('rate_limited', `too many ${what}, try later`);
|
|
79
|
+
}
|
|
80
|
+
// ---- categories --------------------------------------------------------
|
|
81
|
+
async function categories() {
|
|
82
|
+
return db
|
|
83
|
+
.select()
|
|
84
|
+
.from(threadCategories)
|
|
85
|
+
.orderBy(asc(threadCategories.position), asc(threadCategories.name));
|
|
86
|
+
}
|
|
87
|
+
async function category(slug) {
|
|
88
|
+
const [row] = await db.select().from(threadCategories).where(eq(threadCategories.slug, slug));
|
|
89
|
+
return row ?? null;
|
|
90
|
+
}
|
|
91
|
+
async function categoryById(id) {
|
|
92
|
+
if (!Number.isFinite(id))
|
|
93
|
+
return null;
|
|
94
|
+
const [row] = await db.select().from(threadCategories).where(eq(threadCategories.id, id));
|
|
95
|
+
return row ?? null;
|
|
96
|
+
}
|
|
97
|
+
/** Categories are the owner's: the moderate gate on the subject `category:*`. */
|
|
98
|
+
async function createCategory(who, input) {
|
|
99
|
+
await mayModerate(categorySubject('*'), who);
|
|
100
|
+
const [row] = await db
|
|
101
|
+
.insert(threadCategories)
|
|
102
|
+
.values({
|
|
103
|
+
slug: input.slug,
|
|
104
|
+
name: input.name,
|
|
105
|
+
description: input.description ?? null,
|
|
106
|
+
kind: input.kind ?? 'discussion',
|
|
107
|
+
position: input.position ?? 0,
|
|
108
|
+
})
|
|
109
|
+
.returning();
|
|
110
|
+
if (!row)
|
|
111
|
+
throw new Error('createCategory: no row');
|
|
112
|
+
return row;
|
|
113
|
+
}
|
|
114
|
+
// ---- reading -----------------------------------------------------------
|
|
115
|
+
const authorName = sql `(select ${threadPeople.name} from ${threadPeople} where ${threadPeople.id} = ${threads.authorId})`;
|
|
116
|
+
async function listThreads(subject, who, options = {}) {
|
|
117
|
+
await mayRead(subject, who);
|
|
118
|
+
const mod = await moderates(subject, who);
|
|
119
|
+
const page = Math.max(1, options.page ?? 1);
|
|
120
|
+
const perPage = Math.min(100, Math.max(1, options.perPage ?? 25));
|
|
121
|
+
const where = and(eq(threads.subjectKind, subject.kind), eq(threads.subjectId, subject.id), mod ? undefined : eq(threads.hidden, false), options.status ? eq(threads.status, options.status) : undefined);
|
|
122
|
+
const order = options.sort === 'newest'
|
|
123
|
+
? [desc(threads.pinned), desc(threads.createdAt)]
|
|
124
|
+
: options.sort === 'votes'
|
|
125
|
+
? [desc(threads.pinned), desc(threads.voteCount), desc(threads.lastActivityAt)]
|
|
126
|
+
: [desc(threads.pinned), desc(threads.lastActivityAt)];
|
|
127
|
+
const items = await db
|
|
128
|
+
.select({ ...threadColumns(), authorName })
|
|
129
|
+
.from(threads)
|
|
130
|
+
.where(where)
|
|
131
|
+
.orderBy(...order)
|
|
132
|
+
.limit(perPage)
|
|
133
|
+
.offset((page - 1) * perPage);
|
|
134
|
+
const [count] = await db.select({ n: sql `count(*)::int` }).from(threads).where(where);
|
|
135
|
+
return { items, page, perPage, total: count?.n ?? 0 };
|
|
136
|
+
}
|
|
137
|
+
async function thread(id, who) {
|
|
138
|
+
const [row] = await db
|
|
139
|
+
.select({ ...threadColumns(), authorName })
|
|
140
|
+
.from(threads)
|
|
141
|
+
.where(eq(threads.id, id));
|
|
142
|
+
if (!row)
|
|
143
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
144
|
+
await mayRead(subjectOf(row), who);
|
|
145
|
+
if (row.hidden && !(await moderates(subjectOf(row), who))) {
|
|
146
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
147
|
+
}
|
|
148
|
+
return row;
|
|
149
|
+
}
|
|
150
|
+
/** The thread a host subject's comment section belongs to, or null before the first comment. */
|
|
151
|
+
async function threadFor(subject, who) {
|
|
152
|
+
await mayRead(subject, who);
|
|
153
|
+
const [row] = await db
|
|
154
|
+
.select({ ...threadColumns(), authorName })
|
|
155
|
+
.from(threads)
|
|
156
|
+
.where(and(eq(threads.subjectKind, subject.kind), eq(threads.subjectId, subject.id)));
|
|
157
|
+
return row ?? null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Every comment of a thread, ordered by path, which is depth-first in
|
|
161
|
+
* creation order; `buildTree` turns it into the tree. With `root`, only
|
|
162
|
+
* that comment and what is under it, for a "continue this thread" page.
|
|
163
|
+
*/
|
|
164
|
+
async function comments(threadId, who, options = {}) {
|
|
165
|
+
const t = await thread(threadId, who);
|
|
166
|
+
const mod = await moderates(subjectOf(t), who);
|
|
167
|
+
let where = eq(threadComments.threadId, threadId);
|
|
168
|
+
if (options.root !== undefined) {
|
|
169
|
+
const [root] = await db
|
|
170
|
+
.select({ path: threadComments.path })
|
|
171
|
+
.from(threadComments)
|
|
172
|
+
.where(and(eq(threadComments.id, options.root), eq(threadComments.threadId, threadId)));
|
|
173
|
+
if (!root)
|
|
174
|
+
throw new ThreadsError('not_found', 'no such comment');
|
|
175
|
+
where = and(where, sql `${threadComments.path} <@ ${root.path}::ltree`) ?? where;
|
|
176
|
+
}
|
|
177
|
+
const rows = await db
|
|
178
|
+
.select({
|
|
179
|
+
id: threadComments.id,
|
|
180
|
+
threadId: threadComments.threadId,
|
|
181
|
+
replyTo: threadComments.replyTo,
|
|
182
|
+
path: threadComments.path,
|
|
183
|
+
authorId: threadComments.authorId,
|
|
184
|
+
body: threadComments.body,
|
|
185
|
+
hidden: threadComments.hidden,
|
|
186
|
+
createdAt: threadComments.createdAt,
|
|
187
|
+
updatedAt: threadComments.updatedAt,
|
|
188
|
+
authorName: sql `(select ${threadPeople.name} from ${threadPeople} where ${threadPeople.id} = ${threadComments.authorId})`,
|
|
189
|
+
})
|
|
190
|
+
.from(threadComments)
|
|
191
|
+
.where(where)
|
|
192
|
+
.orderBy(asc(threadComments.path));
|
|
193
|
+
return rows.map((r) => (r.hidden && !mod ? { ...r, body: null } : r));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Full-text over titles and bodies. Scoped to one subject kind, and every
|
|
197
|
+
* distinct subject in the result is asked of the read gate before a row
|
|
198
|
+
* of it is returned: a hit under a private subject is dropped, not shown.
|
|
199
|
+
*/
|
|
200
|
+
async function search(q, who, options = {}) {
|
|
201
|
+
const term = q.trim();
|
|
202
|
+
if (!term)
|
|
203
|
+
return [];
|
|
204
|
+
const kind = options.subjectKind ?? CATEGORY;
|
|
205
|
+
const rows = await db
|
|
206
|
+
.select({ ...threadColumns(), authorName })
|
|
207
|
+
.from(threads)
|
|
208
|
+
.where(and(eq(threads.subjectKind, kind), eq(threads.hidden, false), sql `to_tsvector('simple', coalesce(${threads.title}, '') || ' ' || coalesce(${threads.body}, '')) @@ plainto_tsquery('simple', ${term})`))
|
|
209
|
+
.orderBy(desc(threads.lastActivityAt))
|
|
210
|
+
.limit(Math.min(100, options.limit ?? 25));
|
|
211
|
+
return gateRows(rows, who);
|
|
212
|
+
}
|
|
213
|
+
async function gateRows(rows, who) {
|
|
214
|
+
const allowed = new Map();
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const row of rows) {
|
|
217
|
+
const key = `${row.subjectKind} ${row.subjectId}`;
|
|
218
|
+
let ok = allowed.get(key);
|
|
219
|
+
if (ok === undefined) {
|
|
220
|
+
ok = await gates.read(subjectOf(row), who);
|
|
221
|
+
allowed.set(key, ok);
|
|
222
|
+
}
|
|
223
|
+
if (ok)
|
|
224
|
+
out.push(row);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
/** Every feedback thread the person may see, grouped by status. */
|
|
229
|
+
async function roadmap(who) {
|
|
230
|
+
const feedback = await db
|
|
231
|
+
.select({ id: threadCategories.id })
|
|
232
|
+
.from(threadCategories)
|
|
233
|
+
.where(eq(threadCategories.kind, 'feedback'));
|
|
234
|
+
const groups = {
|
|
235
|
+
open: [],
|
|
236
|
+
planned: [],
|
|
237
|
+
in_progress: [],
|
|
238
|
+
done: [],
|
|
239
|
+
declined: [],
|
|
240
|
+
};
|
|
241
|
+
if (feedback.length === 0)
|
|
242
|
+
return groups;
|
|
243
|
+
const rows = await db
|
|
244
|
+
.select({ ...threadColumns(), authorName })
|
|
245
|
+
.from(threads)
|
|
246
|
+
.where(and(eq(threads.subjectKind, CATEGORY), inArray(threads.subjectId, feedback.map((c) => String(c.id))), eq(threads.hidden, false), isNull(threads.duplicateOf)))
|
|
247
|
+
.orderBy(desc(threads.voteCount), desc(threads.lastActivityAt));
|
|
248
|
+
for (const row of await gateRows(rows, who)) {
|
|
249
|
+
groups[row.status ?? 'open'].push(row);
|
|
250
|
+
}
|
|
251
|
+
return groups;
|
|
252
|
+
}
|
|
253
|
+
// ---- writing -----------------------------------------------------------
|
|
254
|
+
async function post(who, subject, input) {
|
|
255
|
+
if (subject.kind !== CATEGORY) {
|
|
256
|
+
throw new ThreadsError('invalid', 'a thread with a title belongs in a category; comment on the subject instead');
|
|
257
|
+
}
|
|
258
|
+
const person = await inGoodStanding(await mayWrite(subject, who));
|
|
259
|
+
const cat = await categoryById(Number(subject.id));
|
|
260
|
+
if (!cat)
|
|
261
|
+
throw new ThreadsError('not_found', 'no such category');
|
|
262
|
+
const title = input.title.trim();
|
|
263
|
+
const body = input.body.trim();
|
|
264
|
+
if (title.length < 3 || title.length > 200) {
|
|
265
|
+
throw new ThreadsError('invalid', 'a title is 3 to 200 characters');
|
|
266
|
+
}
|
|
267
|
+
if (body.length < 1 || body.length > 20_000) {
|
|
268
|
+
throw new ThreadsError('invalid', 'a body is 1 to 20000 characters');
|
|
269
|
+
}
|
|
270
|
+
await underLimit(threads, person.id, 60 * 60 * 1000, limits.threadsPerHour, 'new threads');
|
|
271
|
+
return db.transaction(async (tx) => {
|
|
272
|
+
const [row] = await tx
|
|
273
|
+
.insert(threads)
|
|
274
|
+
.values({
|
|
275
|
+
subjectKind: subject.kind,
|
|
276
|
+
subjectId: subject.id,
|
|
277
|
+
authorId: person.id,
|
|
278
|
+
title,
|
|
279
|
+
slug: slugify(title),
|
|
280
|
+
body,
|
|
281
|
+
status: cat.kind === 'feedback' ? 'open' : null,
|
|
282
|
+
})
|
|
283
|
+
.returning();
|
|
284
|
+
if (!row)
|
|
285
|
+
throw new Error('post: no row');
|
|
286
|
+
await tx
|
|
287
|
+
.insert(threadSubscriptions)
|
|
288
|
+
.values({ threadId: row.id, personId: person.id, reason: 'author' })
|
|
289
|
+
.onConflictDoNothing();
|
|
290
|
+
return row;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
async function comment(who, threadId, input) {
|
|
294
|
+
const [t] = await db.select().from(threads).where(eq(threads.id, threadId));
|
|
295
|
+
if (!t)
|
|
296
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
297
|
+
return addComment(who, t, input);
|
|
298
|
+
}
|
|
299
|
+
/** Comment under a host subject; the thread is created on the first comment. */
|
|
300
|
+
async function commentOn(who, subject, input) {
|
|
301
|
+
if (subject.kind === CATEGORY) {
|
|
302
|
+
throw new ThreadsError('invalid', 'a category takes threads, not comments');
|
|
303
|
+
}
|
|
304
|
+
await mayWrite(subject, who);
|
|
305
|
+
const bySubject = and(eq(threads.subjectKind, subject.kind), eq(threads.subjectId, subject.id));
|
|
306
|
+
const [existing] = await db.select().from(threads).where(bySubject);
|
|
307
|
+
let t = existing;
|
|
308
|
+
if (!t) {
|
|
309
|
+
const [created] = await db
|
|
310
|
+
.insert(threads)
|
|
311
|
+
.values({ subjectKind: subject.kind, subjectId: subject.id })
|
|
312
|
+
.onConflictDoNothing()
|
|
313
|
+
.returning();
|
|
314
|
+
t = created ?? (await db.select().from(threads).where(bySubject))[0];
|
|
315
|
+
}
|
|
316
|
+
if (!t)
|
|
317
|
+
throw new Error('commentOn: no thread');
|
|
318
|
+
return addComment(who, t, input);
|
|
319
|
+
}
|
|
320
|
+
async function addComment(who, t, input) {
|
|
321
|
+
const subject = subjectOf(t);
|
|
322
|
+
const person = await inGoodStanding(await mayWrite(subject, who));
|
|
323
|
+
const mod = await moderates(subject, who);
|
|
324
|
+
if (t.hidden && !mod)
|
|
325
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
326
|
+
if (t.locked && !mod)
|
|
327
|
+
throw new ThreadsError('locked', 'this thread is locked');
|
|
328
|
+
const body = input.body.trim();
|
|
329
|
+
if (body.length < 1 || body.length > 20_000) {
|
|
330
|
+
throw new ThreadsError('invalid', 'a comment is 1 to 20000 characters');
|
|
331
|
+
}
|
|
332
|
+
if (input.replyTo != null) {
|
|
333
|
+
const [parent] = await db
|
|
334
|
+
.select({ id: threadComments.id })
|
|
335
|
+
.from(threadComments)
|
|
336
|
+
.where(and(eq(threadComments.id, input.replyTo), eq(threadComments.threadId, t.id)));
|
|
337
|
+
if (!parent)
|
|
338
|
+
throw new ThreadsError('not_found', 'no such comment to reply to');
|
|
339
|
+
}
|
|
340
|
+
await underLimit(threadComments, person.id, 10 * 60 * 1000, limits.commentsPerTenMinutes, 'comments');
|
|
341
|
+
return db.transaction(async (tx) => {
|
|
342
|
+
const [row] = await tx
|
|
343
|
+
.insert(threadComments)
|
|
344
|
+
.values({
|
|
345
|
+
threadId: t.id,
|
|
346
|
+
replyTo: input.replyTo ?? null,
|
|
347
|
+
authorId: person.id,
|
|
348
|
+
body,
|
|
349
|
+
path: '',
|
|
350
|
+
})
|
|
351
|
+
.returning();
|
|
352
|
+
if (!row)
|
|
353
|
+
throw new Error('comment: no row');
|
|
354
|
+
await tx
|
|
355
|
+
.insert(threadSubscriptions)
|
|
356
|
+
.values({ threadId: t.id, personId: person.id, reason: 'commented' })
|
|
357
|
+
.onConflictDoNothing();
|
|
358
|
+
const listeners = await tx
|
|
359
|
+
.select({ personId: threadSubscriptions.personId })
|
|
360
|
+
.from(threadSubscriptions)
|
|
361
|
+
.innerJoin(threadPeople, eq(threadPeople.id, threadSubscriptions.personId))
|
|
362
|
+
.where(and(eq(threadSubscriptions.threadId, t.id), eq(threadSubscriptions.muted, false), ne(threadSubscriptions.personId, person.id), sql `${threadPeople.email} is not null`));
|
|
363
|
+
if (listeners.length > 0) {
|
|
364
|
+
await tx.insert(threadOutbox).values(listeners.map((l) => ({
|
|
365
|
+
personId: l.personId,
|
|
366
|
+
threadId: t.id,
|
|
367
|
+
commentId: row.id,
|
|
368
|
+
kind: 'comment',
|
|
369
|
+
})));
|
|
370
|
+
}
|
|
371
|
+
return row;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async function vote(who, threadId) {
|
|
375
|
+
const [t] = await db.select().from(threads).where(eq(threads.id, threadId));
|
|
376
|
+
if (!t)
|
|
377
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
378
|
+
const person = await inGoodStanding(await mayWrite(subjectOf(t), who));
|
|
379
|
+
if (t.subjectKind !== CATEGORY ||
|
|
380
|
+
(await categoryById(Number(t.subjectId)))?.kind !== 'feedback') {
|
|
381
|
+
throw new ThreadsError('invalid', 'votes belong to feedback');
|
|
382
|
+
}
|
|
383
|
+
await db.transaction(async (tx) => {
|
|
384
|
+
await tx.insert(threadVotes).values({ threadId, personId: person.id }).onConflictDoNothing();
|
|
385
|
+
await tx
|
|
386
|
+
.insert(threadSubscriptions)
|
|
387
|
+
.values({ threadId, personId: person.id, reason: 'voted' })
|
|
388
|
+
.onConflictDoNothing();
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async function unvote(who, threadId) {
|
|
392
|
+
const [t] = await db.select().from(threads).where(eq(threads.id, threadId));
|
|
393
|
+
if (!t)
|
|
394
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
395
|
+
const person = await mayWrite(subjectOf(t), who);
|
|
396
|
+
await db
|
|
397
|
+
.delete(threadVotes)
|
|
398
|
+
.where(and(eq(threadVotes.threadId, threadId), eq(threadVotes.personId, person.id)));
|
|
399
|
+
}
|
|
400
|
+
async function voted(who, threadIds) {
|
|
401
|
+
if (!who || threadIds.length === 0)
|
|
402
|
+
return new Set();
|
|
403
|
+
const rows = await db
|
|
404
|
+
.select({ threadId: threadVotes.threadId })
|
|
405
|
+
.from(threadVotes)
|
|
406
|
+
.where(and(eq(threadVotes.personId, who.id), inArray(threadVotes.threadId, threadIds)));
|
|
407
|
+
return new Set(rows.map((r) => r.threadId));
|
|
408
|
+
}
|
|
409
|
+
async function subscribe(who, threadId, muted = false) {
|
|
410
|
+
const [t] = await db.select().from(threads).where(eq(threads.id, threadId));
|
|
411
|
+
if (!t)
|
|
412
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
413
|
+
const person = await seen(await mayWrite(subjectOf(t), who));
|
|
414
|
+
await db
|
|
415
|
+
.insert(threadSubscriptions)
|
|
416
|
+
.values({ threadId, personId: person.id, reason: 'manual', muted })
|
|
417
|
+
.onConflictDoUpdate({
|
|
418
|
+
target: [threadSubscriptions.threadId, threadSubscriptions.personId],
|
|
419
|
+
set: { muted },
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// ---- moderating --------------------------------------------------------
|
|
423
|
+
async function moderated(who, threadId) {
|
|
424
|
+
const [t] = await db.select().from(threads).where(eq(threads.id, threadId));
|
|
425
|
+
if (!t)
|
|
426
|
+
throw new ThreadsError('not_found', 'no such thread');
|
|
427
|
+
return { t, who: await mayModerate(subjectOf(t), who) };
|
|
428
|
+
}
|
|
429
|
+
async function setFlag(who, threadId, flag, value) {
|
|
430
|
+
await moderated(who, threadId);
|
|
431
|
+
await db
|
|
432
|
+
.update(threads)
|
|
433
|
+
.set({ [flag]: value, updatedAt: sql `now()` })
|
|
434
|
+
.where(eq(threads.id, threadId));
|
|
435
|
+
}
|
|
436
|
+
async function hideComment(who, commentId, hidden = true) {
|
|
437
|
+
const [c] = await db.select().from(threadComments).where(eq(threadComments.id, commentId));
|
|
438
|
+
if (!c)
|
|
439
|
+
throw new ThreadsError('not_found', 'no such comment');
|
|
440
|
+
await moderated(who, c.threadId);
|
|
441
|
+
await db
|
|
442
|
+
.update(threadComments)
|
|
443
|
+
.set({ hidden, updatedAt: sql `now()` })
|
|
444
|
+
.where(eq(threadComments.id, commentId));
|
|
445
|
+
}
|
|
446
|
+
async function move(who, threadId, categoryId) {
|
|
447
|
+
const { t } = await moderated(who, threadId);
|
|
448
|
+
if (t.subjectKind !== CATEGORY)
|
|
449
|
+
throw new ThreadsError('invalid', 'only a forum thread moves');
|
|
450
|
+
const cat = await categoryById(categoryId);
|
|
451
|
+
if (!cat)
|
|
452
|
+
throw new ThreadsError('not_found', 'no such category');
|
|
453
|
+
await mayModerate(categorySubject(categoryId), who);
|
|
454
|
+
await db
|
|
455
|
+
.update(threads)
|
|
456
|
+
.set({
|
|
457
|
+
subjectId: String(categoryId),
|
|
458
|
+
status: cat.kind === 'feedback' ? (t.status ?? 'open') : null,
|
|
459
|
+
updatedAt: sql `now()`,
|
|
460
|
+
})
|
|
461
|
+
.where(eq(threads.id, threadId));
|
|
462
|
+
}
|
|
463
|
+
async function setStatus(who, threadId, status) {
|
|
464
|
+
const { t } = await moderated(who, threadId);
|
|
465
|
+
if (t.status === null)
|
|
466
|
+
throw new ThreadsError('invalid', 'only feedback has a status');
|
|
467
|
+
if (t.status === status)
|
|
468
|
+
return;
|
|
469
|
+
await db.transaction(async (tx) => {
|
|
470
|
+
await tx
|
|
471
|
+
.update(threads)
|
|
472
|
+
.set({ status, updatedAt: sql `now()` })
|
|
473
|
+
.where(eq(threads.id, threadId));
|
|
474
|
+
const listeners = await tx
|
|
475
|
+
.select({ personId: threadSubscriptions.personId })
|
|
476
|
+
.from(threadSubscriptions)
|
|
477
|
+
.innerJoin(threadPeople, eq(threadPeople.id, threadSubscriptions.personId))
|
|
478
|
+
.where(and(eq(threadSubscriptions.threadId, threadId), eq(threadSubscriptions.muted, false), sql `${threadPeople.email} is not null`));
|
|
479
|
+
if (listeners.length > 0) {
|
|
480
|
+
await tx.insert(threadOutbox).values(listeners.map((l) => ({
|
|
481
|
+
personId: l.personId,
|
|
482
|
+
threadId,
|
|
483
|
+
kind: 'status',
|
|
484
|
+
})));
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/** The duplicate points at the original and its votes move there; a page redirects. */
|
|
489
|
+
async function markDuplicate(who, threadId, ofId) {
|
|
490
|
+
const { t } = await moderated(who, threadId);
|
|
491
|
+
const [original] = await db.select().from(threads).where(eq(threads.id, ofId));
|
|
492
|
+
if (!original || original.id === t.id)
|
|
493
|
+
throw new ThreadsError('not_found', 'no such original');
|
|
494
|
+
await db.transaction(async (tx) => {
|
|
495
|
+
const votes = await tx.select().from(threadVotes).where(eq(threadVotes.threadId, threadId));
|
|
496
|
+
if (votes.length > 0) {
|
|
497
|
+
await tx
|
|
498
|
+
.insert(threadVotes)
|
|
499
|
+
.values(votes.map((v) => ({ threadId: ofId, personId: v.personId })))
|
|
500
|
+
.onConflictDoNothing();
|
|
501
|
+
await tx.delete(threadVotes).where(eq(threadVotes.threadId, threadId));
|
|
502
|
+
}
|
|
503
|
+
await tx
|
|
504
|
+
.update(threads)
|
|
505
|
+
.set({
|
|
506
|
+
duplicateOf: ofId,
|
|
507
|
+
status: t.status === null ? null : 'declined',
|
|
508
|
+
locked: true,
|
|
509
|
+
updatedAt: sql `now()`,
|
|
510
|
+
})
|
|
511
|
+
.where(eq(threads.id, threadId));
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/** Standing is per person, not per subject; the gate asked is `category:*`, the owner's. */
|
|
515
|
+
async function setStanding(who, personId, standing) {
|
|
516
|
+
await mayModerate(categorySubject('*'), who);
|
|
517
|
+
await db.update(threadPeople).set({ standing }).where(eq(threadPeople.id, personId));
|
|
518
|
+
}
|
|
519
|
+
// ---- outbox ------------------------------------------------------------
|
|
520
|
+
/**
|
|
521
|
+
* Claims up to `batch` unsent rows with FOR UPDATE SKIP LOCKED and hands
|
|
522
|
+
* each to `send`. A send that throws is recorded and retried by a later
|
|
523
|
+
* drain, up to five times. Run this from a small loop in the app's own
|
|
524
|
+
* process; it is safe to run from two.
|
|
525
|
+
*/
|
|
526
|
+
async function drainOutbox(send, batch = 20) {
|
|
527
|
+
const claimed = await db.transaction(async (tx) => {
|
|
528
|
+
const rows = await tx
|
|
529
|
+
.select()
|
|
530
|
+
.from(threadOutbox)
|
|
531
|
+
.where(and(isNull(threadOutbox.sentAt), sql `${threadOutbox.attempts} < 5`))
|
|
532
|
+
.orderBy(asc(threadOutbox.createdAt))
|
|
533
|
+
.limit(batch)
|
|
534
|
+
.for('update', { skipLocked: true });
|
|
535
|
+
if (rows.length === 0)
|
|
536
|
+
return rows;
|
|
537
|
+
await tx
|
|
538
|
+
.update(threadOutbox)
|
|
539
|
+
.set({ claimedAt: sql `now()`, attempts: sql `${threadOutbox.attempts} + 1` })
|
|
540
|
+
.where(inArray(threadOutbox.id, rows.map((r) => r.id)));
|
|
541
|
+
return rows;
|
|
542
|
+
});
|
|
543
|
+
let sent = 0;
|
|
544
|
+
for (const row of claimed) {
|
|
545
|
+
try {
|
|
546
|
+
const [to] = await db.select().from(threadPeople).where(eq(threadPeople.id, row.personId));
|
|
547
|
+
const [t] = await db.select().from(threads).where(eq(threads.id, row.threadId));
|
|
548
|
+
const [c] = row.commentId === null
|
|
549
|
+
? [null]
|
|
550
|
+
: await db.select().from(threadComments).where(eq(threadComments.id, row.commentId));
|
|
551
|
+
if (!to?.email || !t)
|
|
552
|
+
throw new Error('outbox row points at nothing');
|
|
553
|
+
await send({
|
|
554
|
+
id: row.id,
|
|
555
|
+
kind: row.kind,
|
|
556
|
+
to: { id: to.id, name: to.name, email: to.email },
|
|
557
|
+
thread: t,
|
|
558
|
+
comment: c ?? null,
|
|
559
|
+
});
|
|
560
|
+
await db
|
|
561
|
+
.update(threadOutbox)
|
|
562
|
+
.set({ sentAt: sql `now()`, lastError: null })
|
|
563
|
+
.where(eq(threadOutbox.id, row.id));
|
|
564
|
+
sent += 1;
|
|
565
|
+
}
|
|
566
|
+
catch (e) {
|
|
567
|
+
await db
|
|
568
|
+
.update(threadOutbox)
|
|
569
|
+
.set({ lastError: e instanceof Error ? e.message : String(e) })
|
|
570
|
+
.where(eq(threadOutbox.id, row.id));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return sent;
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
seen,
|
|
577
|
+
categories,
|
|
578
|
+
category,
|
|
579
|
+
categoryById,
|
|
580
|
+
createCategory,
|
|
581
|
+
listThreads,
|
|
582
|
+
thread,
|
|
583
|
+
threadFor,
|
|
584
|
+
comments,
|
|
585
|
+
search,
|
|
586
|
+
roadmap,
|
|
587
|
+
post,
|
|
588
|
+
comment,
|
|
589
|
+
commentOn,
|
|
590
|
+
vote,
|
|
591
|
+
unvote,
|
|
592
|
+
voted,
|
|
593
|
+
subscribe,
|
|
594
|
+
pin: (who, id, on = true) => setFlag(who, id, 'pinned', on),
|
|
595
|
+
lock: (who, id, on = true) => setFlag(who, id, 'locked', on),
|
|
596
|
+
hide: (who, id, on = true) => setFlag(who, id, 'hidden', on),
|
|
597
|
+
hideComment,
|
|
598
|
+
move,
|
|
599
|
+
setStatus,
|
|
600
|
+
markDuplicate,
|
|
601
|
+
setStanding,
|
|
602
|
+
drainOutbox,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function threadColumns() {
|
|
606
|
+
return {
|
|
607
|
+
id: threads.id,
|
|
608
|
+
subjectKind: threads.subjectKind,
|
|
609
|
+
subjectId: threads.subjectId,
|
|
610
|
+
authorId: threads.authorId,
|
|
611
|
+
title: threads.title,
|
|
612
|
+
slug: threads.slug,
|
|
613
|
+
body: threads.body,
|
|
614
|
+
pinned: threads.pinned,
|
|
615
|
+
locked: threads.locked,
|
|
616
|
+
hidden: threads.hidden,
|
|
617
|
+
status: threads.status,
|
|
618
|
+
duplicateOf: threads.duplicateOf,
|
|
619
|
+
commentCount: threads.commentCount,
|
|
620
|
+
voteCount: threads.voteCount,
|
|
621
|
+
lastActivityAt: threads.lastActivityAt,
|
|
622
|
+
createdAt: threads.createdAt,
|
|
623
|
+
updatedAt: threads.updatedAt,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
export { schema };
|
package/dist/tree.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comment tree, from rows to nodes and back to the pieces a page shows.
|
|
3
|
+
*
|
|
4
|
+
* `path` is the chain of zero-padded comment ids from the top of the thread
|
|
5
|
+
* to the comment, written by a trigger, so rows ordered by `path` are already
|
|
6
|
+
* depth-first in creation order. Building the tree is one pass over that
|
|
7
|
+
* order; nothing here touches the database.
|
|
8
|
+
*/
|
|
9
|
+
export declare const LABEL_WIDTH = 12;
|
|
10
|
+
export declare function label(id: number): string;
|
|
11
|
+
export declare function depthOf(path: string): number;
|
|
12
|
+
export interface TreeRow {
|
|
13
|
+
id: number;
|
|
14
|
+
replyTo: number | null;
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
export type Node<T extends TreeRow> = T & {
|
|
18
|
+
/** Distance from the top of the tree being built, not of the thread. */
|
|
19
|
+
depth: number;
|
|
20
|
+
children: Node<T>[];
|
|
21
|
+
/** Every comment under this one, at any depth. */
|
|
22
|
+
descendants: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Rows to a forest. Pass `root` to build only the subtree under one comment,
|
|
26
|
+
* with that comment at depth 0; the rows must already be that subtree, or the
|
|
27
|
+
* thread's whole set, either way ordered by `path`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildTree<T extends TreeRow>(rows: T[], opts?: {
|
|
30
|
+
root?: number;
|
|
31
|
+
}): Node<T>[];
|
|
32
|
+
/** Every node in depth-first order, which is the order the page draws them. */
|
|
33
|
+
export declare function walk<T extends TreeRow>(nodes: Node<T>[]): Node<T>[];
|
|
34
|
+
export type Folded<T extends TreeRow> = T & {
|
|
35
|
+
depth: number;
|
|
36
|
+
descendants: number;
|
|
37
|
+
children: Folded<T>[];
|
|
38
|
+
/** How many comments sit under this one and are not drawn. */
|
|
39
|
+
folded: number;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* What a page draws past the depth it is willing to indent: the node stays,
|
|
43
|
+
* its children are replaced by a count and a link. Eight is the reference
|
|
44
|
+
* rendering's choice; a place that renders flat passes 0, one that never
|
|
45
|
+
* folds passes Infinity.
|
|
46
|
+
*/
|
|
47
|
+
export declare function collapse<T extends TreeRow>(nodes: Node<T>[], at: number): Folded<T>[];
|