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