rules
tcmts.examples.userinput.rules · #4ufm22e3 · ts file
// tcm: namespace userinput
// What the records add up to. Anyone can write any record to their own repo, so what a record means depends on who
// wrote it: a status, pin, hide, ban or lock counts only from the space's owner or one of its moderators; an edit
// only from the author of what it edits; a membership only from the owner. These are those rules, and what the board
// shows: scores, threads, sorting and filtering.
import assert from "node:assert/strict";
import { parseAtUri, STATES } from "#codebase/userinput";
import type {
BanRecord, DiscussionRecord, EditRecord, LockRecord, MemberRecord, ModerationRecord, ReplyRecord, Role, State, StatusRecord, Stored,
} from "#codebase/userinput";
/** Who wrote a record: the DID of the repo it's in. */
export const authorOf = (uri: string): string => parseAtUri(uri)?.did ?? "";
/** A space's people with a say: its owner, and the moderators and admins the owner appointed. */
export interface Authority {
owner: string;
roles: Map<string, Role>;
}
/** Who has a say in a space: the owner, and whoever the owner's membership records name. */
export function authorityOf(spaceUri: string, members: Stored<MemberRecord>[]): Authority {
const owner = authorOf(spaceUri);
const roles = new Map<string, Role>();
for (const m of [...members].sort((a, b) => a.value.createdAt.localeCompare(b.value.createdAt)))
if (authorOf(m.uri) === owner && m.value.space.uri === spaceUri) roles.set(m.value.subject, m.value.role);
return { owner, roles };
}
/** Whether someone can moderate a space: its owner, or one of its moderators or admins. */
export const canModerate = (a: Authority, did: string | undefined): boolean => !!did && (did === a.owner || a.roles.has(did));
/** The records that count: the ones written by someone with a say. */
export const honored = <T>(records: Stored<T>[], a: Authority): Stored<T>[] => records.filter((r) => canModerate(a, authorOf(r.uri)));
/** The latest of some records, by `createdAt`. */
export function latest<T extends { createdAt: string }>(records: Stored<T>[]): Stored<T> | undefined {
let best: Stored<T> | undefined;
for (const r of records) if (!best || r.value.createdAt > best.value.createdAt) best = r;
return best;
}
/** A discussion's official status: the latest set by someone with a say ("open" if none). */
export function statusOf(statuses: Stored<StatusRecord>[], a: Authority): { state: State; note?: string; duplicateOf?: string; at?: string } {
const s = latest(honored(statuses, a).filter((x) => (STATES as readonly string[]).includes(x.value.state)));
return s ? { state: s.value.state, note: s.value.note, duplicateOf: s.value.duplicateOf?.uri, at: s.value.createdAt } : { state: "open" };
}
/** Space-wide moderation, as it counts: what's pinned, what's hidden, who's banned. */
export interface Moderation {
pinned: Set<string>;
hidden: Set<string>;
banned: Set<string>;
}
/** The moderation that counts, from records by anyone: only the ones by the owner and moderators. */
export function moderationOf(a: Authority, pins: Stored<ModerationRecord>[], hides: Stored<ModerationRecord>[], bans: Stored<BanRecord>[]): Moderation {
return {
pinned: new Set(honored(pins, a).map((p) => p.value.subject.uri)),
hidden: new Set(honored(hides, a).map((h) => h.value.subject.uri)),
banned: new Set(honored(bans, a).map((b) => b.value.subject).filter((d) => d !== a.owner)),
};
}
/** When a discussion was locked, if it is: the earliest lock that counts (later ones don't move it). */
export function lockedAt(locks: Stored<LockRecord>[], a: Authority): string | undefined {
return honored(locks, a).map((l) => l.value.lockedAt).sort()[0];
}
/** A discussion or reply as it reads now: its author's latest edit applied (anyone else's are ignored). */
export function edited<T extends { title?: string; body?: string; tags?: string[] }>(record: Stored<T>, edits: Stored<EditRecord>[]): Stored<T> & { editedAt?: string } {
const own = edits.filter((e) => authorOf(e.uri) === authorOf(record.uri) && e.value.subject.uri === record.uri);
const last = latest(own);
if (!last) return record;
const e = last.value;
return {
...record,
value: {
...record.value,
...(e.title !== undefined && "title" in record.value ? { title: e.title } : {}),
...(e.body !== undefined ? { body: e.body } : {}),
...(e.tags !== undefined && "title" in record.value ? { tags: e.tags } : {}),
},
editedAt: e.createdAt,
};
}
/** A subject's votes: its score, and how the person looking voted. */
export interface Tally {
up: number;
down: number;
score: number;
mine?: "up" | "down";
}
/** A score from who voted which way (each person once each way), and how `me` voted. */
export function tally(upvoters: string[], downvoters: string[], me?: string): Tally {
const up = new Set(upvoters);
const down = new Set(downvoters);
return { up: up.size, down: down.size, score: up.size - down.size, mine: me ? (up.has(me) ? "up" : down.has(me) ? "down" : undefined) : undefined };
}
/** A reply with the replies to it. */
export interface Thread {
reply: Stored<ReplyRecord> & { editedAt?: string };
children: Thread[];
}
/**
* A discussion's replies, as threads: each under the reply it answers (top level if that's gone or hidden), oldest
* first. Hidden ones, ones by banned people, and ones after the discussion was locked aren't shown.
*/
export function threads(replies: (Stored<ReplyRecord> & { editedAt?: string })[], m: Moderation, locked?: string): Thread[] {
const shown = replies.filter((r) => !m.hidden.has(r.uri) && !m.banned.has(authorOf(r.uri)) && (!locked || r.value.createdAt <= locked));
const byUri = new Map(shown.map((r) => [r.uri, { reply: r, children: [] as Thread[] }]));
const top: Thread[] = [];
for (const r of [...shown].sort((a, b) => a.value.createdAt.localeCompare(b.value.createdAt))) {
const t = byUri.get(r.uri)!;
const parent = r.value.parent && byUri.get(r.value.parent.uri);
if (parent && parent !== t) parent.children.push(t);
else top.push(t);
}
return top;
}
/** How many replies a set of threads holds, all the way down. */
export const replyCount = (ts: Thread[]): number => ts.reduce((n, t) => n + 1 + replyCount(t.children), 0);
/** A discussion as a board lists it. */
export interface Item {
discussion: Stored<DiscussionRecord> & { editedAt?: string };
status: State;
tally: Tally;
replies: number;
pinned: boolean;
}
/** How a board is ordered. */
export type Sort = "top" | "new" | "trending";
/** A trending score: votes, worth less as they age (hours). */
export const trendingScore = (score: number, ageHours: number): number => (score + 1) / Math.pow(ageHours + 2, 1.5);
/** A board's discussions in order: pinned first, then by `sort`. */
export function sortItems(items: Item[], sort: Sort, now = Date.now()): Item[] {
const age = (i: Item) => (now - Date.parse(i.discussion.value.createdAt)) / 3_600_000;
const key = (i: Item) => (sort === "top" ? i.tally.score : sort === "trending" ? trendingScore(i.tally.score, age(i)) : -age(i));
return [...items].sort((a, b) => Number(b.pinned) - Number(a.pinned) || key(b) - key(a) || b.discussion.value.createdAt.localeCompare(a.discussion.value.createdAt));
}
/** What a board shows: a tag, some states, and words to look for in titles and bodies. */
export interface Filter {
tag?: string;
states?: State[];
query?: string;
}
/** The items a filter keeps. */
export function filterItems(items: Item[], f: Filter): Item[] {
const words = (f.query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
return items.filter((i) => {
const d = i.discussion.value;
if (f.tag && !(d.tags ?? []).includes(f.tag)) return false;
if (f.states?.length && !f.states.includes(i.status)) return false;
const text = `${d.title}\n${d.body ?? ""}`.toLowerCase();
return words.every((w) => text.includes(w));
});
}
/** How states are grouped on a board: what's being looked at, what's coming, what's done. */
export const STATE_GROUPS: { label: string; states: State[] }[] = [
{ label: "Open", states: ["open", "under-review"] },
{ label: "Planned", states: ["backlog", "planned", "in-progress"] },
{ label: "Done", states: ["implemented"] },
{ label: "Closed", states: ["declined", "duplicate", "closed"] },
];
/** A state as people read it. */
export const stateLabel = (s: State): string => ({ "under-review": "Under review", "in-progress": "In progress" } as Record<string, string>)[s] ?? s[0]!.toUpperCase() + s.slice(1);
/** How long ago something was, briefly ("just now", "5m", "3h", "2d", or the date). */
export function ago(iso: string, now = Date.now()): string {
const s = Math.max(0, (now - Date.parse(iso)) / 1000);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
if (s < 7 * 86400) return `${Math.floor(s / 86400)}d`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: s > 300 * 86400 ? "numeric" : undefined });
}
const fixtureRef = (did: string, coll: string, rkey: string) => ({ uri: `at://${did}/${coll}/${rkey}`, cid: `cid-${rkey}` });
const stored = <T>(did: string, coll: string, rkey: string, value: T): Stored<T> => ({ ...fixtureRef(did, coll, rkey), value });
const SPACE_URI = "at://did:plc:owner/app.userinput.space/s1";
export const testOnlyTheOwnerAppointsModerators = () => {
const members = [
stored<MemberRecord>("did:plc:owner", "app.userinput.member", "m1", { space: { uri: SPACE_URI, cid: "c" }, subject: "did:plc:mod", role: "moderator", createdAt: "2026-01-01T00:00:00Z" }),
// someone else claiming a role counts for nothing
stored<MemberRecord>("did:plc:mallory", "app.userinput.member", "m2", { space: { uri: SPACE_URI, cid: "c" }, subject: "did:plc:mallory", role: "admin", createdAt: "2026-01-02T00:00:00Z" }),
];
const a = authorityOf(SPACE_URI, members);
assert.equal(a.owner, "did:plc:owner");
assert.ok(canModerate(a, "did:plc:owner") && canModerate(a, "did:plc:mod"));
assert.ok(!canModerate(a, "did:plc:mallory") && !canModerate(a, undefined));
};
export const testTheLatestStatusByAModeratorWins = () => {
const a = authorityOf(SPACE_URI, [stored<MemberRecord>("did:plc:owner", "app.userinput.member", "m1", { space: { uri: SPACE_URI, cid: "c" }, subject: "did:plc:mod", role: "moderator", createdAt: "2026-01-01T00:00:00Z" })]);
const subject = fixtureRef("did:plc:alice", "app.userinput.discussion", "d1");
const statuses = [
stored<StatusRecord>("did:plc:owner", "app.userinput.status", "s1", { subject, state: "planned", createdAt: "2026-02-01T00:00:00Z" }),
stored<StatusRecord>("did:plc:mod", "app.userinput.status", "s2", { subject, state: "in-progress", note: "on it", createdAt: "2026-02-03T00:00:00Z" }),
// the author can't mark their own feedback shipped
stored<StatusRecord>("did:plc:alice", "app.userinput.status", "s3", { subject, state: "implemented", createdAt: "2026-02-05T00:00:00Z" }),
];
assert.deepEqual(statusOf(statuses, a), { state: "in-progress", note: "on it", duplicateOf: undefined, at: "2026-02-03T00:00:00Z" });
assert.equal(statusOf([], a).state, "open");
};
export const testEditsCountOnlyFromTheAuthor = () => {
const d = stored<DiscussionRecord>("did:plc:alice", "app.userinput.discussion", "d1", { space: { uri: SPACE_URI, cid: "c" }, title: "Dark mode", body: "please", createdAt: "2026-01-01T00:00:00Z" });
const edits = [
stored<EditRecord>("did:plc:alice", "app.userinput.edit", "e1", { subject: { uri: d.uri, cid: d.cid }, title: "Dark mode, please", createdAt: "2026-01-02T00:00:00Z" }),
stored<EditRecord>("did:plc:alice", "app.userinput.edit", "e2", { subject: { uri: d.uri, cid: d.cid }, body: "for the board", createdAt: "2026-01-03T00:00:00Z" }),
stored<EditRecord>("did:plc:mallory", "app.userinput.edit", "e3", { subject: { uri: d.uri, cid: d.cid }, title: "spam", createdAt: "2026-01-04T00:00:00Z" }),
];
const e = edited(d, edits);
// the latest of the author's: its body, and the title as it was
assert.equal(e.value.body, "for the board");
assert.equal(e.value.title, "Dark mode");
assert.equal(e.editedAt, "2026-01-03T00:00:00Z");
};
export const testRepliesThreadAndHideAsModerated = () => {
const subject = fixtureRef("did:plc:alice", "app.userinput.discussion", "d1");
const r = (did: string, rkey: string, at: string, parent?: string) =>
stored<ReplyRecord>(did, "app.userinput.reply", rkey, { subject, body: rkey, createdAt: at, ...(parent ? { parent: { uri: `at://did:plc:bob/app.userinput.reply/${parent}`, cid: "c" } } : {}) });
const replies = [r("did:plc:bob", "r1", "2026-01-01T01:00:00Z"), r("did:plc:carol", "r2", "2026-01-01T02:00:00Z", "r1"), r("did:plc:spam", "r3", "2026-01-01T03:00:00Z"), r("did:plc:dan", "r4", "2026-01-09T00:00:00Z")];
const m: Moderation = { pinned: new Set(), hidden: new Set(), banned: new Set(["did:plc:spam"]) };
const t = threads(replies, m, "2026-01-05T00:00:00Z");
assert.deepEqual(t.map((x) => [x.reply.value.body, x.children.map((c) => c.reply.value.body)]), [["r1", ["r2"]]]);
assert.equal(replyCount(t), 2);
};
export const testBoardsSortAndFilter = () => {
const item = (rkey: string, score: number, at: string, extra: Partial<Item> = {}, tags: string[] = []): Item => ({
discussion: stored<DiscussionRecord>("did:plc:a", "app.userinput.discussion", rkey, { space: { uri: SPACE_URI, cid: "c" }, title: `idea ${rkey}`, tags, createdAt: at }),
status: "open",
tally: { up: score, down: 0, score },
replies: 0,
pinned: false,
...extra,
});
const now = Date.parse("2026-03-10T00:00:00Z");
const items = [item("old", 50, "2026-01-01T00:00:00Z"), item("new", 2, "2026-03-09T23:00:00Z", {}, ["bug"]), item("pin", 0, "2026-02-01T00:00:00Z", { pinned: true })];
assert.deepEqual(sortItems(items, "top", now).map((i) => i.discussion.uri.split("/").pop()), ["pin", "old", "new"]);
assert.deepEqual(sortItems(items, "new", now).map((i) => i.discussion.uri.split("/").pop()), ["pin", "new", "old"]);
assert.deepEqual(sortItems(items, "trending", now).map((i) => i.discussion.uri.split("/").pop()), ["pin", "new", "old"]);
assert.deepEqual(filterItems(items, { tag: "bug" }).length, 1);
assert.deepEqual(filterItems(items, { query: "IDEA old" }).length, 1);
assert.deepEqual(tally(["did:a", "did:a", "did:b"], ["did:c"], "did:c"), { up: 2, down: 1, score: 1, mine: "down" });
assert.equal(stateLabel("in-progress"), "In progress");
assert.equal(ago("2026-03-09T21:00:00Z", now), "3h");
};
used by