@wtfalch/threads 0.1.1 → 0.2.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/dist/audit.d.ts +74 -0
- package/dist/audit.js +22 -0
- package/dist/bin/migrations.js +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/threads.d.ts +3 -0
- package/dist/threads.js +81 -23
- package/package.json +9 -10
- package/LICENSE +0 -21
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Db } from './threads.js';
|
|
2
|
+
/**
|
|
3
|
+
* The moderation events this package can write, when the host hands
|
|
4
|
+
* `createThreads` a writer. Declared here so the host can merge them into
|
|
5
|
+
* its ledger's vocabulary before it binds the writer; the names sit in the
|
|
6
|
+
* `thread` namespace and nowhere else, which is what lets a host bind the
|
|
7
|
+
* writer to that namespace alone. `tenantVisible` is false throughout: a
|
|
8
|
+
* forum is one per app, not one per organisation.
|
|
9
|
+
*/
|
|
10
|
+
export declare const THREADS_AUDIT_EVENTS: {
|
|
11
|
+
readonly 'thread.pinned': {
|
|
12
|
+
readonly tenantVisible: false;
|
|
13
|
+
};
|
|
14
|
+
readonly 'thread.unpinned': {
|
|
15
|
+
readonly tenantVisible: false;
|
|
16
|
+
};
|
|
17
|
+
readonly 'thread.locked': {
|
|
18
|
+
readonly tenantVisible: false;
|
|
19
|
+
};
|
|
20
|
+
readonly 'thread.unlocked': {
|
|
21
|
+
readonly tenantVisible: false;
|
|
22
|
+
};
|
|
23
|
+
readonly 'thread.hidden': {
|
|
24
|
+
readonly tenantVisible: false;
|
|
25
|
+
};
|
|
26
|
+
readonly 'thread.unhidden': {
|
|
27
|
+
readonly tenantVisible: false;
|
|
28
|
+
};
|
|
29
|
+
readonly 'thread.comment_hidden': {
|
|
30
|
+
readonly tenantVisible: false;
|
|
31
|
+
};
|
|
32
|
+
readonly 'thread.comment_unhidden': {
|
|
33
|
+
readonly tenantVisible: false;
|
|
34
|
+
};
|
|
35
|
+
readonly 'thread.moved': {
|
|
36
|
+
readonly tenantVisible: false;
|
|
37
|
+
};
|
|
38
|
+
readonly 'thread.status_changed': {
|
|
39
|
+
readonly tenantVisible: false;
|
|
40
|
+
};
|
|
41
|
+
readonly 'thread.marked_duplicate': {
|
|
42
|
+
readonly tenantVisible: false;
|
|
43
|
+
};
|
|
44
|
+
readonly 'thread.standing_changed': {
|
|
45
|
+
readonly tenantVisible: false;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export type ThreadsAuditAction = keyof typeof THREADS_AUDIT_EVENTS;
|
|
49
|
+
/** One moderation act, as the host's writer receives it. The actor is the `who` the host's own gate admitted. */
|
|
50
|
+
export interface ThreadsAuditEvent {
|
|
51
|
+
readonly action: ThreadsAuditAction;
|
|
52
|
+
readonly actor: {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly display: string;
|
|
55
|
+
};
|
|
56
|
+
readonly target: {
|
|
57
|
+
readonly type: 'thread' | 'thread_comment' | 'person';
|
|
58
|
+
readonly id: string;
|
|
59
|
+
};
|
|
60
|
+
readonly before?: unknown;
|
|
61
|
+
readonly after?: unknown;
|
|
62
|
+
/** The person the act was about, when that is not the actor: a standing change. */
|
|
63
|
+
readonly subject?: {
|
|
64
|
+
readonly class: 'human';
|
|
65
|
+
readonly id: string;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* What a host passes as `createThreads({ audit })`: a writer already bound
|
|
70
|
+
* to its ledger and to the `thread` namespace (`@wtfalch/audit`'s
|
|
71
|
+
* `ledger.writer`, structurally). The package calls it inside the
|
|
72
|
+
* transaction that makes the change, so a refused row rolls the change back.
|
|
73
|
+
*/
|
|
74
|
+
export type ThreadsAudit = (event: ThreadsAuditEvent, tx: Db) => Promise<void>;
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The moderation events this package can write, when the host hands
|
|
3
|
+
* `createThreads` a writer. Declared here so the host can merge them into
|
|
4
|
+
* its ledger's vocabulary before it binds the writer; the names sit in the
|
|
5
|
+
* `thread` namespace and nowhere else, which is what lets a host bind the
|
|
6
|
+
* writer to that namespace alone. `tenantVisible` is false throughout: a
|
|
7
|
+
* forum is one per app, not one per organisation.
|
|
8
|
+
*/
|
|
9
|
+
export const THREADS_AUDIT_EVENTS = {
|
|
10
|
+
'thread.pinned': { tenantVisible: false },
|
|
11
|
+
'thread.unpinned': { tenantVisible: false },
|
|
12
|
+
'thread.locked': { tenantVisible: false },
|
|
13
|
+
'thread.unlocked': { tenantVisible: false },
|
|
14
|
+
'thread.hidden': { tenantVisible: false },
|
|
15
|
+
'thread.unhidden': { tenantVisible: false },
|
|
16
|
+
'thread.comment_hidden': { tenantVisible: false },
|
|
17
|
+
'thread.comment_unhidden': { tenantVisible: false },
|
|
18
|
+
'thread.moved': { tenantVisible: false },
|
|
19
|
+
'thread.status_changed': { tenantVisible: false },
|
|
20
|
+
'thread.marked_duplicate': { tenantVisible: false },
|
|
21
|
+
'thread.standing_changed': { tenantVisible: false },
|
|
22
|
+
};
|
package/dist/bin/migrations.js
CHANGED
|
File without changes
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/threads.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
import type { ThreadsAudit } from './audit.js';
|
|
2
3
|
import { type Gates, type Subject, type Who } from './gates.js';
|
|
3
4
|
import { type Category, type Comment, type Person, type Thread, type ThreadStatus, schema } from './schema.js';
|
|
4
5
|
/**
|
|
@@ -22,6 +23,8 @@ export interface ThreadsOptions {
|
|
|
22
23
|
gates: Gates;
|
|
23
24
|
limits?: Partial<Limits>;
|
|
24
25
|
now?: () => Date;
|
|
26
|
+
/** The host's audit writer for moderation, bound to the `thread` namespace. Optional: without it, moderation leaves no trail here. */
|
|
27
|
+
audit?: ThreadsAudit;
|
|
25
28
|
}
|
|
26
29
|
export type Sort = 'activity' | 'newest' | 'votes';
|
|
27
30
|
export interface ListOptions {
|
package/dist/threads.js
CHANGED
|
@@ -18,6 +18,13 @@ export function createThreads(opts) {
|
|
|
18
18
|
const { db, gates } = opts;
|
|
19
19
|
const limits = { ...DEFAULT_LIMITS, ...opts.limits };
|
|
20
20
|
const now = opts.now ?? (() => new Date());
|
|
21
|
+
const display = (who) => who.name ?? who.id;
|
|
22
|
+
/** Records a moderation act on the caller's transaction, when the host wants a trail. */
|
|
23
|
+
async function audited(tx, who, event) {
|
|
24
|
+
if (!opts.audit)
|
|
25
|
+
return;
|
|
26
|
+
await opts.audit({ ...event, actor: { id: who.id, display: display(who) } }, tx);
|
|
27
|
+
}
|
|
21
28
|
async function mayRead(subject, who) {
|
|
22
29
|
if (!(await gates.read(subject, who))) {
|
|
23
30
|
throw new ThreadsError('forbidden', 'not allowed to read here');
|
|
@@ -430,21 +437,40 @@ export function createThreads(opts) {
|
|
|
430
437
|
return { t, who: await mayModerate(subjectOf(t), who) };
|
|
431
438
|
}
|
|
432
439
|
async function setFlag(who, threadId, flag, value) {
|
|
433
|
-
await moderated(who, threadId);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
.
|
|
440
|
+
const { t, who: mod } = await moderated(who, threadId);
|
|
441
|
+
if (t[flag] === value)
|
|
442
|
+
return;
|
|
443
|
+
const action = {
|
|
444
|
+
pinned: value ? 'thread.pinned' : 'thread.unpinned',
|
|
445
|
+
locked: value ? 'thread.locked' : 'thread.unlocked',
|
|
446
|
+
hidden: value ? 'thread.hidden' : 'thread.unhidden',
|
|
447
|
+
}[flag];
|
|
448
|
+
await db.transaction(async (tx) => {
|
|
449
|
+
await tx
|
|
450
|
+
.update(threads)
|
|
451
|
+
.set({ [flag]: value, updatedAt: sql `now()` })
|
|
452
|
+
.where(eq(threads.id, threadId));
|
|
453
|
+
await audited(tx, mod, { action, target: { type: 'thread', id: String(threadId) } });
|
|
454
|
+
});
|
|
438
455
|
}
|
|
439
456
|
async function hideComment(who, commentId, hidden = true) {
|
|
440
457
|
const [c] = await db.select().from(threadComments).where(eq(threadComments.id, commentId));
|
|
441
458
|
if (!c)
|
|
442
459
|
throw new ThreadsError('not_found', 'no such comment');
|
|
443
|
-
await moderated(who, c.threadId);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
460
|
+
const { who: mod } = await moderated(who, c.threadId);
|
|
461
|
+
if (c.hidden === hidden)
|
|
462
|
+
return;
|
|
463
|
+
await db.transaction(async (tx) => {
|
|
464
|
+
await tx
|
|
465
|
+
.update(threadComments)
|
|
466
|
+
.set({ hidden, updatedAt: sql `now()` })
|
|
467
|
+
.where(eq(threadComments.id, commentId));
|
|
468
|
+
await audited(tx, mod, {
|
|
469
|
+
action: hidden ? 'thread.comment_hidden' : 'thread.comment_unhidden',
|
|
470
|
+
target: { type: 'thread_comment', id: String(commentId) },
|
|
471
|
+
after: { threadId: c.threadId },
|
|
472
|
+
});
|
|
473
|
+
});
|
|
448
474
|
}
|
|
449
475
|
async function move(who, threadId, categoryId) {
|
|
450
476
|
const { t } = await moderated(who, threadId);
|
|
@@ -453,18 +479,26 @@ export function createThreads(opts) {
|
|
|
453
479
|
const cat = await categoryById(categoryId);
|
|
454
480
|
if (!cat)
|
|
455
481
|
throw new ThreadsError('not_found', 'no such category');
|
|
456
|
-
await mayModerate(categorySubject(categoryId), who);
|
|
457
|
-
await db
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
482
|
+
const mod = await mayModerate(categorySubject(categoryId), who);
|
|
483
|
+
await db.transaction(async (tx) => {
|
|
484
|
+
await tx
|
|
485
|
+
.update(threads)
|
|
486
|
+
.set({
|
|
487
|
+
subjectId: String(categoryId),
|
|
488
|
+
status: cat.kind === 'feedback' ? (t.status ?? 'open') : null,
|
|
489
|
+
updatedAt: sql `now()`,
|
|
490
|
+
})
|
|
491
|
+
.where(eq(threads.id, threadId));
|
|
492
|
+
await audited(tx, mod, {
|
|
493
|
+
action: 'thread.moved',
|
|
494
|
+
target: { type: 'thread', id: String(threadId) },
|
|
495
|
+
before: { categoryId: t.subjectId },
|
|
496
|
+
after: { categoryId: String(categoryId) },
|
|
497
|
+
});
|
|
498
|
+
});
|
|
465
499
|
}
|
|
466
500
|
async function setStatus(who, threadId, status) {
|
|
467
|
-
const { t } = await moderated(who, threadId);
|
|
501
|
+
const { t, who: mod } = await moderated(who, threadId);
|
|
468
502
|
if (t.status === null)
|
|
469
503
|
throw new ThreadsError('invalid', 'only feedback has a status');
|
|
470
504
|
if (t.status === status)
|
|
@@ -474,6 +508,12 @@ export function createThreads(opts) {
|
|
|
474
508
|
.update(threads)
|
|
475
509
|
.set({ status, updatedAt: sql `now()` })
|
|
476
510
|
.where(eq(threads.id, threadId));
|
|
511
|
+
await audited(tx, mod, {
|
|
512
|
+
action: 'thread.status_changed',
|
|
513
|
+
target: { type: 'thread', id: String(threadId) },
|
|
514
|
+
before: { status: t.status },
|
|
515
|
+
after: { status },
|
|
516
|
+
});
|
|
477
517
|
const listeners = await tx
|
|
478
518
|
.select({ personId: threadSubscriptions.personId })
|
|
479
519
|
.from(threadSubscriptions)
|
|
@@ -490,11 +530,16 @@ export function createThreads(opts) {
|
|
|
490
530
|
}
|
|
491
531
|
/** The duplicate points at the original and its votes move there; a page redirects. */
|
|
492
532
|
async function markDuplicate(who, threadId, ofId) {
|
|
493
|
-
const { t } = await moderated(who, threadId);
|
|
533
|
+
const { t, who: mod } = await moderated(who, threadId);
|
|
494
534
|
const [original] = await db.select().from(threads).where(eq(threads.id, ofId));
|
|
495
535
|
if (!original || original.id === t.id)
|
|
496
536
|
throw new ThreadsError('not_found', 'no such original');
|
|
497
537
|
await db.transaction(async (tx) => {
|
|
538
|
+
await audited(tx, mod, {
|
|
539
|
+
action: 'thread.marked_duplicate',
|
|
540
|
+
target: { type: 'thread', id: String(threadId) },
|
|
541
|
+
after: { duplicateOf: ofId },
|
|
542
|
+
});
|
|
498
543
|
const votes = await tx.select().from(threadVotes).where(eq(threadVotes.threadId, threadId));
|
|
499
544
|
if (votes.length > 0) {
|
|
500
545
|
await tx
|
|
@@ -516,8 +561,21 @@ export function createThreads(opts) {
|
|
|
516
561
|
}
|
|
517
562
|
/** Standing is per person, not per subject; the gate asked is `category:*`, the owner's. */
|
|
518
563
|
async function setStanding(who, personId, standing) {
|
|
519
|
-
await mayModerate(categorySubject('*'), who);
|
|
520
|
-
|
|
564
|
+
const mod = await mayModerate(categorySubject('*'), who);
|
|
565
|
+
const [before] = await db
|
|
566
|
+
.select({ standing: threadPeople.standing })
|
|
567
|
+
.from(threadPeople)
|
|
568
|
+
.where(eq(threadPeople.id, personId));
|
|
569
|
+
await db.transaction(async (tx) => {
|
|
570
|
+
await tx.update(threadPeople).set({ standing }).where(eq(threadPeople.id, personId));
|
|
571
|
+
await audited(tx, mod, {
|
|
572
|
+
action: 'thread.standing_changed',
|
|
573
|
+
target: { type: 'person', id: personId },
|
|
574
|
+
subject: { class: 'human', id: personId },
|
|
575
|
+
before: { standing: before?.standing ?? null },
|
|
576
|
+
after: { standing },
|
|
577
|
+
});
|
|
578
|
+
});
|
|
521
579
|
}
|
|
522
580
|
// ---- outbox ------------------------------------------------------------
|
|
523
581
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/threads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Threads, comments, votes and subscriptions attached to a subject the host names: a forum, a feedback board, a comment section. Per-app Postgres, host-supplied gates, React on @wtfalch/design.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"files": [
|
|
13
|
-
"dist"
|
|
14
|
-
],
|
|
12
|
+
"files": ["dist"],
|
|
15
13
|
"bin": {
|
|
16
14
|
"threads-migrations": "./dist/bin/migrations.js"
|
|
17
15
|
},
|
|
@@ -34,6 +32,12 @@
|
|
|
34
32
|
"engines": {
|
|
35
33
|
"node": ">=22.0.0"
|
|
36
34
|
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/ && cp src/react/threads.css dist/threads.css",
|
|
37
|
+
"prepack": "pnpm build",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
37
41
|
"peerDependencies": {
|
|
38
42
|
"@wtfalch/design": ">=0.3.0",
|
|
39
43
|
"drizzle-orm": ">=0.39.0",
|
|
@@ -64,10 +68,5 @@
|
|
|
64
68
|
"react-dom": "^19",
|
|
65
69
|
"typescript": "^5.9.0",
|
|
66
70
|
"vitest": "^4.1.6"
|
|
67
|
-
},
|
|
68
|
-
"scripts": {
|
|
69
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/ && cp src/react/threads.css dist/threads.css",
|
|
70
|
-
"typecheck": "tsc --noEmit",
|
|
71
|
-
"test": "vitest run"
|
|
72
71
|
}
|
|
73
|
-
}
|
|
72
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 William Tallis Falch
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|