From 3f3dfcc941f3db41ba48ed526d7215c0b3e24359 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 18 Sep 2026 14:30:46 -0400 Subject: [PATCH 1/9] docs(specs): admin members roster and human spam votes Staff get one place to see who joined and what they have done on the site, and to record a human verdict the offline pipeline treats as final. Human votes live on published as person-evaluations records authored by the voter; a spam vote deactivates immediately. Machine evaluations and Slack-derived inputs move to a private repo, so the public data repo never carries message text or LLM prose about people. Also records that heuristic records have no confidence and must be LLM-confirmed before they can prune. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LFyA5poHwrhAktrnsKrUiQ --- specs/api/moderation.md | 131 ++++++++++++++++++++++++++++ specs/behaviors/person-lifecycle.md | 6 +- specs/behaviors/spam-exclusion.md | 49 ++++++++--- specs/screens/admin-members.md | 85 ++++++++++++++++++ 4 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 specs/api/moderation.md create mode 100644 specs/screens/admin-members.md diff --git a/specs/api/moderation.md b/specs/api/moderation.md new file mode 100644 index 0000000..72a393d --- /dev/null +++ b/specs/api/moderation.md @@ -0,0 +1,131 @@ +# Moderation API + +Staff-facing endpoints behind the [admin members screen](../screens/admin-members.md): +a newest-first roster of members with their footprint on the site, and a way to +record a **human spam verdict** that the offline spam pipeline treats as final +(see [behaviors/spam-exclusion.md](../behaviors/spam-exclusion.md)). + +All endpoints require `accountLevel ∈ {staff, administrator}`. Anyone else gets +`404 not_found` — like the other staff surfaces, the endpoints' existence is not +a signal. + +| Method | Path | Purpose | +| ------ | ---- | ------- | +| `GET` | `/api/admin/members` | Paginated roster, newest signup first, with vote state and footprint counts. | +| `GET` | `/api/admin/members/:slug` | One member's full footprint plus every human vote on record. | +| `POST` | `/api/admin/members/:slug/vote` | Record the caller's verdict; applies the side effect described below. | + +## GET /api/admin/members + +### Query parameters + +| Param | Type | Notes | +| ----- | ---- | ----- | +| `q` | string | Full-text on `fullName`, `slug`, `bio`, and (staff-visible) `email`. | +| `vote` | enum | `none` (no human vote yet) \| `spam` \| `legit`. Filters on the **latest** human vote. | +| `joinedAfter`, `joinedBefore` | ISO date | Inclusive bounds on `createdAt`. | +| `includeDeactivated` | bool | Default `true` — moderation needs to see what it hid. | +| `sort` | sort | Default `-createdAt`. Allowed: `createdAt`, `fullName`, `lastLoginAt`. | +| `page`, `perPage` | int | Default `perPage = 50`, max 200. | + +### Response — 200 + +```json +{ + "success": true, + "data": [ + { + "id": "…", "slug": "jane", "fullName": "Jane Doe", "avatarUrl": "…", + "createdAt": "2026-09-17T22:41:00Z", + "deletedAt": null, + "email": "jane@example.org", + "hasGitHubLink": true, + "lastLoginAt": "2026-09-18T01:12:00Z", + "lastLoginMethod": "github", + "bioExcerpt": "first ~160 chars of bio, markdown stripped", + "footprint": { "memberships": 2, "updates": 1, "buzz": 0, "blogPosts": 0, "helpWantedInterest": 1, "tags": 3 }, + "latestVote": { "verdict": "legit", "voter": { "slug": "chris", "fullName": "Chris Alfano" }, "evaluatedAt": "…" } + } + ], + "metadata": { "timestamp": "…", "page": 1, "perPage": 50, "totalItems": 12661, "totalPages": 254 } +} +``` + +`email`, `lastLoginAt`, `lastLoginMethod`, and `hasGitHubLink` come from the private +store and are the same staff-only fields `GET /api/people/:slug` exposes; they are +never present for a non-staff caller (who gets 404 anyway). `latestVote` is `null` +when no human has voted. + +## GET /api/admin/members/:slug + +The row above, plus the full footprint and vote history: + +```json +{ + "success": true, + "data": { + "person": { …Person as GET /api/people/:slug for staff, including email and deletedAt… }, + "footprint": { + "memberships": [ { "project": { "slug", "title" }, "role", "joinedAt" } ], + "updates": [ { "project": { "slug", "title" }, "number", "title", "postedAt" } ], + "buzz": [ { "project": { "slug", "title" }, "slug", "title", "postedAt" } ], + "blogPosts": [ { "slug", "title", "postedAt" } ], + "helpWantedInterest": [ { "project": { "slug", "title" }, "role": { "title" }, "createdAt" } ], + "tags": [ { "handle", "type" } ] + }, + "votes": [ + { "verdict": "spam", "reasoning": "…", "voter": { "slug", "fullName" }, "evaluatedAt": "…" } + ] + } +} +``` + +`votes` is newest first. Everything in `footprint` is drawn from the in-memory +public state — nothing here is fetched from elsewhere. + +### Errors + +- `404 not_found` — slug doesn't exist, or the caller is not staff + +## POST /api/admin/members/:slug/vote + +Record the caller's verdict on this person as a `person-evaluations` record with +`evaluator = "human:"` (one record per voter per person — voting +again replaces the caller's previous record). Committed through the write mutex +as the caller, so the vote carries the voter's pseudonymous git identity. + +### Request + +```json +{ "verdict": "spam" | "legit", "reasoning": "optional, ≤ 1000 chars" } +``` + +### Side effects (per [behaviors/person-lifecycle.md](../behaviors/person-lifecycle.md)) + +- `verdict = "spam"` → the person is **deactivated** in the same transaction + (`deletedAt = now()` if not already set). +- `verdict = "legit"` → if the person's previous **latest** human vote was `spam` + (i.e. this vote reverses a moderation hide), `deletedAt` is cleared. A + self-deactivation is left alone. +- Voting on yourself is rejected. + +### Response — 200 + +```json +{ "success": true, "data": { "person": Person, "vote": { …the recorded vote… }, "latestVote": { … } } } +``` + +### Errors + +- `422 validation_failed` — bad verdict, reasoning too long, or `slug` is the caller +- `404 not_found` — slug doesn't exist, or the caller is not staff + +## Relationship to other specs + +- [behaviors/spam-exclusion.md](../behaviors/spam-exclusion.md) — the + `person-evaluations` record shape, why human votes live on `published`, and + how the offline pipeline consumes them. +- [behaviors/person-lifecycle.md](../behaviors/person-lifecycle.md) — the + deactivate side effect and its reversal rule. +- [api/people.md](people.md) — `deactivate`, `reactivate`, `purge`, and the + staff-only fields reused here. diff --git a/specs/behaviors/person-lifecycle.md b/specs/behaviors/person-lifecycle.md index 60b3734..5b71c83 100644 --- a/specs/behaviors/person-lifecycle.md +++ b/specs/behaviors/person-lifecycle.md @@ -5,7 +5,7 @@ A person record has two removal paths with very different intent and reversibili | State | Set by | Effect | Reversible | | ----- | ------ | ------ | ---------- | | **Active** | default | Normal — visible in lists, detail, and as a reference on content. | — | -| **Deactivated** | self **or** staff/admin | Soft hide. `deletedAt` set. Hidden from public lists + detail; references render a placeholder. The person **can still sign in** and reactivate. | Reactivate (clears `deletedAt`). | +| **Deactivated** | self, staff/admin, **or a staff spam vote** | Soft hide. `deletedAt` set. Hidden from public lists + detail; references render a placeholder. The person **can still sign in** and reactivate. | Reactivate (clears `deletedAt`). | | **Purged** | admin only | Cascading hard delete of the person + their content, in a single commit. | Via git history only (revert the commit). | ## Deactivate (soft, self-service) @@ -16,7 +16,8 @@ The privacy / self-removal path — members should be able to remove themselves; - **Mechanism:** sets `person.deletedAt = now()` (reactivate clears it). The record and relationships stay intact. - **Visibility while deactivated:** excluded from public list endpoints; `GET /api/people/:slug` returns 404 for non-staff (staff may still fetch it, with `deletedAt` populated). Anywhere a deactivated person is referenced (project member grids, project-update/project-buzz authors, help-wanted "posted by", blog author) the serialized reference is a **"Deactivated user" placeholder** (no slug link, generic avatar) rather than the person — substitute, do not omit, so counts/history stay coherent. - **Login is NOT blocked** — a deactivated user can still authenticate and reactivate themselves. No session revocation. -- **Surfaces:** self at `/account` ("Deactivate my account" / "Reactivate"); staff/admin via a person "Danger Zone". +- **Surfaces:** self at `/account` ("Deactivate my account" / "Reactivate"); staff/admin via a person "Danger Zone"; and the **admin members screen**, where a `spam` vote deactivates the person in the same transaction that records the vote ([api/moderation.md](../api/moderation.md)). +- **Vote reversal rule:** a `legit` vote clears `deletedAt` only when the person's previous latest human vote was `spam` — that is, when moderation set it. A self-deactivation is never undone by a vote. ## Purge (cascading hard delete, admin only) @@ -32,6 +33,7 @@ The garbage-collection path for spam — the runtime sibling of the offline spam | Action | Self | Staff | Admin | | ------ | ---- | ----- | ----- | | Deactivate / Reactivate | ✓ (own) | ✓ (any) | ✓ (any) | +| Spam / not-spam vote (deactivates / may reactivate) | – | ✓ | ✓ | | Purge | – | – | ✓ | ## Relationship to other specs diff --git a/specs/behaviors/spam-exclusion.md b/specs/behaviors/spam-exclusion.md index 657e359..531b6d8 100644 --- a/specs/behaviors/spam-exclusion.md +++ b/specs/behaviors/spam-exclusion.md @@ -16,33 +16,50 @@ whatever `published` contains. ## Where verdicts come from -Spam evaluation runs offline and lands on the **`spam-detection`** branch of the -data repo, in the **`person-evaluations`** sheet (path template -`${personSlug}/${evaluator}` — one record per (person, evaluator)). Each record: +Two sources write the same record shape into the **`person-evaluations`** sheet +(path template `${personSlug}/${evaluator}` — one record per (person, evaluator)): + +1. **Machine evaluators** (heuristic, LLM passes) run offline in the **private + spam-detection repo** (`codeforphilly-spam-detection`), which also holds the + Slack-derived inputs. That material — public-channel message text, Slack + identities, LLM prose about named people — never enters the public data repo. +2. **Human votes** are cast by staff on the site ([api/moderation.md](../api/moderation.md)) + and committed to **`published`** in this repo as `evaluator = "human:"`, + authored by the voter. They are small, summary-only, and attributable, which is + what a public civic dataset can carry. + +Each record: | Field | Meaning | | ----- | ------- | | `personSlug` | the evaluated person | -| `evaluator` | model/run id (e.g. `haiku-2026-05`) | +| `evaluator` | model/run id (e.g. `haiku-2026-05`) or `human:` | | `verdict` | `"spam"` \| `"legit"` \| `"uncertain"` | -| `confidence` | 0–1 | +| `confidence` | 0–1 (LLM); absent on heuristic records, which carry `score` instead; `1` on human votes | | `flags` | array of short reason tags | -| `reasoning` | free-text justification | +| `reasoning` | free-text justification (optional on human votes) | | `evaluatedAt` | ISO 8601 UTC | -The evaluations stay on `spam-detection`; they are **not** merged into -`published` (they are bulky and not runtime data). The pipeline reads them from -`spam-detection` and applies the result to `published`. +Machine evaluations stay in the private repo; the runtime never loads them. The +pipeline reads machine records from its own repo and human votes from +`published`, aggregates, and applies the result to `published`. ## Per-person verdict aggregation A person may have multiple evaluator records. The aggregate decision is deliberately **conservative — only confident spam is pruned**: -> A person is **pruned as spam** iff they have at least one `spam` verdict with -> `confidence ≥ SPAM_CONFIDENCE_THRESHOLD` (default **0.8**), no `legit` -> verdict at any confidence, **and no `project-membership`** (real project -> involvement overrides any spam verdict). Otherwise they are **kept** — this +> **A human vote is final.** If any `human:*` records exist for the person, the +> latest one decides: `spam` → pruned (membership protection does not apply — +> a person looked at the profile); `legit` → kept. +> +> Otherwise a person is **pruned as spam** iff they have at least one `spam` +> verdict with `confidence ≥ SPAM_CONFIDENCE_THRESHOLD` (default **0.8**), no +> `legit` verdict at any confidence, **and no `project-membership`** (real +> project involvement overrides any machine verdict). Heuristic records carry +> a `score`, not a `confidence`, and therefore never prune on their own: the +> pipeline must LLM-confirm the heuristic-spam bucket (`evaluate-llm --filter spam`) +> before pruning, or those accounts stay. Otherwise they are **kept** — this > includes `uncertain`, `legit`, low-confidence spam, anyone who is a project > member, and people with no evaluation. @@ -74,7 +91,11 @@ records, not a full-tree replacement like the importer). ## What the runtime sees -Nothing changes in the loader or read services. After a prune, `published` holds +The runtime loads exactly one evaluation sheet: `person-evaluations` from +`published`, which by construction holds only human votes. It uses them for the +admin members screen and for the vote side effect (a `spam` vote deactivates the +person immediately — [person-lifecycle.md](./person-lifecycle.md)); the public +read services stay spam-unaware. After a prune, `published` holds only kept people (legit + uncertain + unevaluated minus confident spam), so the in-memory state, indices, and FTS are built over that smaller set. Dangling references are avoided by the cascade, so member lists, help-wanted interest, and diff --git a/specs/screens/admin-members.md b/specs/screens/admin-members.md new file mode 100644 index 0000000..11ac603 --- /dev/null +++ b/specs/screens/admin-members.md @@ -0,0 +1,85 @@ +# Screen: Admin members + +## Route + +`/admin/members` — staff and administrators. Anyone else (including signed-out) +gets the site's 404, same as `/staff/account-claim`. + +`/admin/members/:slug` — the expanded footprint view for one member (also +reachable in-page by expanding a row). + +## Purpose + +New members now arrive only through GitHub sign-in, so the volume is low and the +question is simple: *is this a real person?* Everyone listed here has already +passed the offline spam pipeline (or arrived after its last run), so the page +does **not** show machine evaluations. It shows the member's footprint on the +site in one place, and lets staff record a **human judgment** the pipeline treats +as final. + +## Data Requirements + +- `GET /api/admin/members` on entry and on every filter/sort/page change +- `GET /api/admin/members/:slug` when a row is expanded or the detail route opens +- `POST /api/admin/members/:slug/vote` on either vote button + +See [api/moderation.md](../api/moderation.md). + +## Display Rules + +### Roster + +- Newest signup first by default; sort toggles for name and last sign-in. +- Filter bar: text search; **Vote**: any / no vote yet (default) / voted spam / + voted legit; joined date range; a "show deactivated" toggle (on by default — + moderation needs to see what it hid). +- Each row: avatar, `fullName` (link to the public profile, opens in a new tab), + `@slug`, "joined {createdAt relative}", GitHub-linked badge, last sign-in + relative, email (staff-visible), bio excerpt, compact footprint counts + (`2 projects · 1 update · 3 tags`), and the vote state: + - no vote → two buttons **Spam** / **Not spam** + - voted → a badge `Spam · by {voter} · {when}` or `Not spam · …` with a + "Change" affordance that re-shows the buttons +- Deactivated members render dimmed with a "Deactivated" chip; a member hidden by + a spam vote shows "Hidden by {voter}". + +### Expanded row / detail + +Full bio (rendered markdown, server-side), then the footprint as lists with +links: project memberships (role, joined), authored updates, buzz, blog posts, +help-wanted interest, tags. Then the vote history, newest first, each with +voter, verdict, reasoning, and time. Then the same two buttons, plus a link to +the person's Danger Zone for admins (purge lives there, not here). + +### Voting + +- **Spam** opens a small confirm with an optional reasoning field ("Why? — + optional, saved with your vote"), then calls the vote endpoint. On success the + row dims immediately ("Hidden by you"), because the API deactivates the person + in the same transaction. +- **Not spam** calls the endpoint directly (reasoning optional via the same + disclosure). If the member had been hidden by a spam vote, the row un-dims. +- The buttons are disabled on the caller's own row. +- Errors surface as a toast; the row state is refetched, never guessed. + +## Actions + +| Action | API call | On success | +| ------ | -------- | ---------- | +| Filter / sort / page | `GET /api/admin/members?…` | Re-render roster | +| Expand row | `GET /api/admin/members/:slug` | Show footprint + votes | +| Spam | `POST …/vote { verdict: "spam", reasoning? }` | Row → deactivated + vote badge | +| Not spam | `POST …/vote { verdict: "legit", reasoning? }` | Row → vote badge; un-dim if vote-hidden | + +## Authorization + +Staff and administrators. Purge is not offered here (admin-only, on the person +page). Votes are attributed to the individual caller and can be changed by that +caller at any time. + +## Out of scope (v1) + +- Machine verdicts, confidence, or reasoning on this page — the pipeline's + concern, and its data lives in a private repo. +- Bulk actions. +- Running the heuristic in-process at signup (natural follow-up). From dbd2b1a80fe94833199350f97a657e235d7575f5 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 18 Sep 2026 14:30:46 -0400 Subject: [PATCH 2/9] chore(plans): propose admin-members-moderation Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LFyA5poHwrhAktrnsKrUiQ --- plans/admin-members-moderation.md | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 plans/admin-members-moderation.md diff --git a/plans/admin-members-moderation.md b/plans/admin-members-moderation.md new file mode 100644 index 0000000..f5331bb --- /dev/null +++ b/plans/admin-members-moderation.md @@ -0,0 +1,86 @@ +--- +status: proposed +depends: [person-deactivate-purge, spam-prune] +specs: + - specs/screens/admin-members.md + - specs/api/moderation.md + - specs/behaviors/spam-exclusion.md + - specs/behaviors/person-lifecycle.md +issues: [] +pr: null +--- + +# Plan: admin members roster + human spam votes + +## Scope + +Staff need one place to see who joined recently, what they've done on the site, +and to record a human spam/not-spam judgment that the offline pipeline treats +as final. Decisions taken 2026-09-18 with Chris: + +- The page shows the member's **footprint**, not machine evaluations — anyone + listed has already passed (or post-dates) the pipeline. +- A **spam vote deactivates immediately**; the pipeline hard-deletes later. + A legit vote reverses a vote-set deactivation only. +- Staff see the member's **email** (already a staff-visible field). +- Route is **`/admin/members`**, open to staff and administrators. +- Machine evaluations and Slack-derived inputs move to a **private repo** + (codeforphilly-data issue); the public repo carries only human votes. + +In: the two read endpoints, the vote endpoint with its lifecycle side effect, +loading `person-evaluations` (human votes) from `published`, the screen. +Out: machine verdicts on the page; bulk actions; in-process heuristic at +signup; the private-repo migration itself (tracked on the data repo). + +## Implements + +- [screens/admin-members.md](../specs/screens/admin-members.md) +- [api/moderation.md](../specs/api/moderation.md) +- [behaviors/spam-exclusion.md](../specs/behaviors/spam-exclusion.md) — + human votes on `published`; the "human vote is final" aggregation clause. +- [behaviors/person-lifecycle.md](../specs/behaviors/person-lifecycle.md) — + vote-driven deactivate and its reversal rule. + +## Approach + +1. **Data repo first**: add `.gitsheets/person-evaluations.toml` (same schema + as the pipeline's, so records are interchangeable) to `empty` and + `published`. The importer/prune never touch this sheet. +2. **Schema**: `PersonEvaluation` in `packages/shared` (verdict, evaluator, + confidence?, score?, flags, reasoning?, evaluatedAt, personSlug). +3. **Store**: register the sheet in `store/public.ts`; add + `personEvaluations` + `evaluationsByPerson` to `InMemoryState`, state-apply, + and the hot-reload swap (the swap enumerates `Object.keys(fresh)`, so the new + maps are covered automatically — add the integration assertion anyway). +4. **Services**: `moderation` service — roster query (sort/filter/paginate over + people + private profiles + vote index), footprint assembly from existing + indices (`membershipsByPerson`, updates/buzz/blog by author, interest, + tag-assignments), and `castVote()` inside the write mutex: upsert the + `human:` record, apply the deactivate/reactivate rule, one commit + authored by the voter. +5. **Routes**: `apps/api/src/routes/moderation.ts` — three endpoints, staff + guard that 404s. Reuse the people serializer's staff-visible fields. +6. **Web**: `pages/AdminMembers.tsx` (+ `AdminMemberDetail`), routes + `/admin/members` and `/admin/members/:slug`, guarded like the staff queue; + nav entry for staff. +7. **Pipeline**: `prune-spam` reads human votes from `published` in addition to + machine records (`--evaluations-ref` stays for the private clone); update + `docs/operations/spam-detection.md` for the two-repo layout. + +## Validation + +- API: staff sees roster/footprint/email, non-staff and anonymous get 404 on + all three; vote writes a `human:` record and deactivates; legit vote + after spam reactivates; legit vote after self-deactivate does not; self-vote + 422; re-voting replaces the caller's record; hot reload keeps the vote index. +- Web: roster renders, filters work, vote buttons update row state, own row + disabled; a11y pass on the grid (landmarks, button names). +- Prune: a person with a human `spam` vote is pruned even with a membership; + a human `legit` vote protects against a confident machine spam verdict. +- `npm run type-check && npm run lint && npm test` clean; browser check on + sandbox. + +## Follow-ups + +- Tracked as: codeforphilly-data issue (private repo migration). +- Deferred to plan: in-process heuristic scoring at signup. From 864cf379894898b2289b8cf77efe6f60f3006af1 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 18 Sep 2026 15:30:07 -0400 Subject: [PATCH 3/9] docs(specs): human votes use a human- evaluator; drop lastLoginMethod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitsheets 2.x rejects ':' in a path component, so the May-era `human:` naming cannot be written. Also drop `lastLoginMethod` from the roster row — session metadata records when a session was issued, not how. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LFyA5poHwrhAktrnsKrUiQ --- specs/api/moderation.md | 10 +++++----- specs/behaviors/spam-exclusion.md | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/specs/api/moderation.md b/specs/api/moderation.md index 72a393d..653023d 100644 --- a/specs/api/moderation.md +++ b/specs/api/moderation.md @@ -41,7 +41,6 @@ a signal. "email": "jane@example.org", "hasGitHubLink": true, "lastLoginAt": "2026-09-18T01:12:00Z", - "lastLoginMethod": "github", "bioExcerpt": "first ~160 chars of bio, markdown stripped", "footprint": { "memberships": 2, "updates": 1, "buzz": 0, "blogPosts": 0, "helpWantedInterest": 1, "tags": 3 }, "latestVote": { "verdict": "legit", "voter": { "slug": "chris", "fullName": "Chris Alfano" }, "evaluatedAt": "…" } @@ -51,9 +50,10 @@ a signal. } ``` -`email`, `lastLoginAt`, `lastLoginMethod`, and `hasGitHubLink` come from the private -store and are the same staff-only fields `GET /api/people/:slug` exposes; they are -never present for a non-staff caller (who gets 404 anyway). `latestVote` is `null` +`email` comes from the private store (the same staff-only field `GET /api/people/:slug` +exposes); `lastLoginAt` is the newest session issued to the person; `hasGitHubLink` +mirrors `Person.githubUserId`. None of it is reachable by a non-staff caller (who gets +404 anyway). `latestVote` is `null` when no human has voted. ## GET /api/admin/members/:slug @@ -90,7 +90,7 @@ public state — nothing here is fetched from elsewhere. ## POST /api/admin/members/:slug/vote Record the caller's verdict on this person as a `person-evaluations` record with -`evaluator = "human:"` (one record per voter per person — voting +`evaluator = "human-"` (one record per voter per person — voting again replaces the caller's previous record). Committed through the write mutex as the caller, so the vote carries the voter's pseudonymous git identity. diff --git a/specs/behaviors/spam-exclusion.md b/specs/behaviors/spam-exclusion.md index 531b6d8..41873ef 100644 --- a/specs/behaviors/spam-exclusion.md +++ b/specs/behaviors/spam-exclusion.md @@ -24,7 +24,7 @@ Two sources write the same record shape into the **`person-evaluations`** sheet Slack-derived inputs. That material — public-channel message text, Slack identities, LLM prose about named people — never enters the public data repo. 2. **Human votes** are cast by staff on the site ([api/moderation.md](../api/moderation.md)) - and committed to **`published`** in this repo as `evaluator = "human:"`, + and committed to **`published`** in this repo as `evaluator = "human-"`, authored by the voter. They are small, summary-only, and attributable, which is what a public civic dataset can carry. @@ -33,7 +33,7 @@ Each record: | Field | Meaning | | ----- | ------- | | `personSlug` | the evaluated person | -| `evaluator` | model/run id (e.g. `haiku-2026-05`) or `human:` | +| `evaluator` | model/run id (e.g. `haiku-2026-05`) or `human-` | | `verdict` | `"spam"` \| `"legit"` \| `"uncertain"` | | `confidence` | 0–1 (LLM); absent on heuristic records, which carry `score` instead; `1` on human votes | | `flags` | array of short reason tags | @@ -49,7 +49,7 @@ pipeline reads machine records from its own repo and human votes from A person may have multiple evaluator records. The aggregate decision is deliberately **conservative — only confident spam is pruned**: -> **A human vote is final.** If any `human:*` records exist for the person, the +> **A human vote is final.** If any `human-*` records exist for the person, the > latest one decides: `spam` → pruned (membership protection does not apply — > a person looked at the profile); `legit` → kept. > From c652271c2e7e314488c789b0cf3a9590fedf81f5 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 18 Sep 2026 15:30:07 -0400 Subject: [PATCH 4/9] feat(store): load person-evaluations (human spam votes) from published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the sheet, keep the records in InMemoryState keyed personSlug/evaluator with a per-person index, apply them through StateApply, and declare the config in every test fixture — gitsheets' openStore is strict about validators whose config is missing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LFyA5poHwrhAktrnsKrUiQ --- apps/api/src/store/memory/loader.ts | 4 ++ apps/api/src/store/memory/state.ts | 24 ++++++++++++ apps/api/src/store/public.ts | 4 ++ apps/api/src/store/state-apply.ts | 7 ++++ apps/api/tests/helpers/test-full-repo.ts | 1 + apps/api/tests/import-laddr.test.ts | 1 + apps/api/tests/internal-reload.test.ts | 1 + apps/api/tests/reload-swap.test.ts | 9 +++++ packages/shared/src/schemas/index.ts | 8 ++++ .../shared/src/schemas/person-evaluation.ts | 38 +++++++++++++++++++ 10 files changed, 97 insertions(+) create mode 100644 packages/shared/src/schemas/person-evaluation.ts diff --git a/apps/api/src/store/memory/loader.ts b/apps/api/src/store/memory/loader.ts index 156c87e..d7e0ef0 100644 --- a/apps/api/src/store/memory/loader.ts +++ b/apps/api/src/store/memory/loader.ts @@ -12,6 +12,7 @@ import { indexHelpWantedRole, indexMembership, indexPerson, + indexPersonEvaluation, indexProject, indexProjectBuzz, indexProjectUpdate, @@ -44,6 +45,8 @@ export async function loadInMemoryState(publicStore: PublicStore): Promise; + + /** + * Human spam votes from `person-evaluations` on the served branch, keyed + * `${personSlug}/${evaluator}`. Machine verdicts never reach the runtime + * (specs/behaviors/spam-exclusion.md → "What the runtime sees"). + */ + personEvaluations: Map; + /** personSlug → Set */ + evaluationsByPerson: Map>; } /** Compose the slug-history map key. Kept here so call sites stay consistent. */ @@ -155,6 +165,8 @@ export function createEmptyState(): InMemoryState { interestByRoleAndPerson: new Map(), interestByRole: new Map(), slugHistory: new Map(), + personEvaluations: new Map(), + evaluationsByPerson: new Map(), }; } @@ -300,3 +312,15 @@ export function indexHelpWantedInterest(state: InMemoryState, expr: HelpWantedIn const key = `${expr.roleId}:${expr.personId}`; state.interestByRoleAndPerson.set(key, expr.id); } + +/** Add or replace one person-evaluation record (one per person + evaluator). */ +export function indexPersonEvaluation(state: InMemoryState, record: PersonEvaluation): void { + const key = `${record.personSlug}/${record.evaluator}`; + state.personEvaluations.set(key, record); + let set = state.evaluationsByPerson.get(record.personSlug); + if (!set) { + set = new Set(); + state.evaluationsByPerson.set(record.personSlug, set); + } + set.add(key); +} diff --git a/apps/api/src/store/public.ts b/apps/api/src/store/public.ts index 3d752a7..fd3471e 100644 --- a/apps/api/src/store/public.ts +++ b/apps/api/src/store/public.ts @@ -8,6 +8,7 @@ import { HelpWantedInterestExpressionSchema, HelpWantedRoleSchema, PersonSchema, + PersonEvaluationSchema, ProjectBuzzSchema, ProjectMembershipSchema, ProjectSchema, @@ -22,6 +23,7 @@ import type { HelpWantedInterestExpression, HelpWantedRole, Person, + PersonEvaluation, ProjectBuzz, ProjectMembership, ProjectUpdate, @@ -117,6 +119,7 @@ type PublicValidators = { readonly 'tag-assignments': StandardSchemaV1; readonly 'slug-history': StandardSchemaV1; readonly revocations: StandardSchemaV1; + readonly 'person-evaluations': StandardSchemaV1; } & ValidatorMap; export type PublicStore = Store; @@ -167,6 +170,7 @@ export async function openPublicStore( 'tag-assignments': asValidator(TagAssignmentSchema), 'slug-history': asValidator(SlugHistorySchema), revocations: asValidator(RevocationSchema), + 'person-evaluations': asValidator(PersonEvaluationSchema), }; const store = (await openStore(repo, { validators })) as PublicStore; diff --git a/apps/api/src/store/state-apply.ts b/apps/api/src/store/state-apply.ts index 3bfcf12..03f0cdc 100644 --- a/apps/api/src/store/state-apply.ts +++ b/apps/api/src/store/state-apply.ts @@ -12,6 +12,7 @@ import type { HelpWantedInterestExpression, HelpWantedRole, Person, + PersonEvaluation, Project, ProjectBuzz, ProjectMembership, @@ -27,6 +28,7 @@ import { indexHelpWantedRole, indexMembership, indexPerson, + indexPersonEvaluation, indexProject, indexProjectBuzz, indexProjectUpdate, @@ -267,6 +269,11 @@ export class StateApply { return this; } + upsertPersonEvaluation(record: PersonEvaluation): this { + this.#ops.push((state) => indexPersonEvaluation(state, record)); + return this; + } + apply(state: InMemoryState, fts: FtsEngine): void { for (const op of this.#ops) { op(state, fts); diff --git a/apps/api/tests/helpers/test-full-repo.ts b/apps/api/tests/helpers/test-full-repo.ts index 7b7de4f..d834f0b 100644 --- a/apps/api/tests/helpers/test-full-repo.ts +++ b/apps/api/tests/helpers/test-full-repo.ts @@ -29,6 +29,7 @@ const SHEET_CONFIGS: Record = { 'tag-assignments': `[gitsheet]\nroot = 'tag-assignments'\npath = '\${{ tagId }}/\${{ taggableType }}/\${{ taggableId }}'\n`, 'slug-history': `[gitsheet]\nroot = 'slug-history'\npath = '\${{ entityType }}/\${{ oldSlug }}'\n`, 'revocations': `[gitsheet]\nroot = 'revocations'\npath = '\${{ jti }}'\n`, + 'person-evaluations': `[gitsheet]\nroot = 'person-evaluations'\npath = '\${{ personSlug }}/\${{ evaluator }}'\n`, }; export interface FullTestRepo { diff --git a/apps/api/tests/import-laddr.test.ts b/apps/api/tests/import-laddr.test.ts index 3b461e2..965b456 100644 --- a/apps/api/tests/import-laddr.test.ts +++ b/apps/api/tests/import-laddr.test.ts @@ -750,6 +750,7 @@ async function makeRepo(): Promise<{ path: string; cleanup: () => Promise ], ['slug-history', "root = 'slug-history'\npath = '${{ entityType }}/${{ slug }}'\n"], ['revocations', "root = 'revocations'\npath = '${{ jti }}'\n"], + ['person-evaluations', "root = 'person-evaluations'\npath = '${{ personSlug }}/${{ evaluator }}'\n"], ]; for (const [name, body] of sheets) { await writeFile(join(seedDir, '.gitsheets', `${name}.toml`), `[gitsheet]\n${body}`); diff --git a/apps/api/tests/internal-reload.test.ts b/apps/api/tests/internal-reload.test.ts index 85e754d..b14328c 100644 --- a/apps/api/tests/internal-reload.test.ts +++ b/apps/api/tests/internal-reload.test.ts @@ -54,6 +54,7 @@ const SHEET_CONFIGS: Record = { 'tag-assignments': `[gitsheet]\nroot = 'tag-assignments'\npath = '\${{ tagId }}/\${{ taggableType }}/\${{ taggableId }}'\n`, 'slug-history': `[gitsheet]\nroot = 'slug-history'\npath = '\${{ entityType }}/\${{ oldSlug }}'\n`, 'revocations': `[gitsheet]\nroot = 'revocations'\npath = '\${{ jti }}'\n`, + 'person-evaluations': `[gitsheet]\nroot = 'person-evaluations'\npath = '\${{ personSlug }}/\${{ evaluator }}'\n`, }; interface Rig { diff --git a/apps/api/tests/reload-swap.test.ts b/apps/api/tests/reload-swap.test.ts index b018837..761d2d4 100644 --- a/apps/api/tests/reload-swap.test.ts +++ b/apps/api/tests/reload-swap.test.ts @@ -30,6 +30,7 @@ import { createEmptyState, indexBlogPost, indexHelpWantedInterest, + indexPersonEvaluation, indexHelpWantedRole, indexMembership, indexPerson, @@ -159,6 +160,14 @@ function buildState(base: number, slugs: { project: string; buzz: string; oldSlu indexHelpWantedRole(state, role); indexHelpWantedInterest(state, makeInterest(base + 10, role.id, person.id)); indexSlugHistory(state, makeSlugHistory(base + 11, project.id, slugs.oldSlug, slugs.project)); + indexPersonEvaluation(state, { + personSlug: person.slug, + evaluator: 'human-voter' + base, + verdict: 'legit', + confidence: 1, + flags: [], + evaluatedAt: '2026-05-01T00:00:00Z', + }); return state; } diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 1d206fc..bdeba63 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -45,3 +45,11 @@ export type { PasswordToken } from './password-token.js'; export { AccountClaimRequestSchema } from './account-claim-request.js'; export type { AccountClaimRequest } from './account-claim-request.js'; +export { + PersonEvaluationSchema, + HUMAN_EVALUATOR_PREFIX, + isHumanEvaluator, + humanEvaluatorFor, + personEvaluationKey, +} from './person-evaluation.js'; +export type { PersonEvaluation } from './person-evaluation.js'; diff --git a/packages/shared/src/schemas/person-evaluation.ts b/packages/shared/src/schemas/person-evaluation.ts new file mode 100644 index 0000000..d768903 --- /dev/null +++ b/packages/shared/src/schemas/person-evaluation.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +/** + * One spam verdict per (person, evaluator) — the `person-evaluations` sheet. + * + * On the public data repo the only writer is the site: staff votes with + * `evaluator = "human-"`. Machine evaluators (heuristic, LLM) live + * in the private spam-detection repo and share this shape so the prune can + * aggregate both. See specs/behaviors/spam-exclusion.md. + */ +export const PersonEvaluationSchema = z.object({ + personSlug: z.string().min(1), + evaluator: z.string().min(1), + verdict: z.enum(['spam', 'legit', 'uncertain']), + /** LLM and human evaluators: 0–1 (human votes are 1). Absent on heuristic records. */ + confidence: z.number().min(0).max(1).optional(), + /** Heuristic evaluators only: rule points, unbounded. */ + score: z.number().int().optional(), + flags: z.array(z.string()), + reasoning: z.string().optional(), + evaluatedAt: z.string().datetime({ offset: true }), +}); + +export type PersonEvaluation = z.infer; + +export const HUMAN_EVALUATOR_PREFIX = 'human-'; + +export function isHumanEvaluator(evaluator: string): boolean { + return evaluator.startsWith(HUMAN_EVALUATOR_PREFIX); +} + +export function humanEvaluatorFor(voterSlug: string): string { + return `${HUMAN_EVALUATOR_PREFIX}${voterSlug}`; +} + +export function personEvaluationKey(personSlug: string, evaluator: string): string { + return `${personSlug}/${evaluator}`; +} From 8a08509c87bdbb7c647d13183a77bb053786b276 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 18 Sep 2026 15:30:07 -0400 Subject: [PATCH 5/9] feat(api): staff members roster, footprint, and human spam votes GET /api/admin/members (newest first, filters, staff-visible email and last sign-in, footprint counts, latest vote), GET /api/admin/members/:slug (full footprint + vote history), POST /api/admin/members/:slug/vote. A spam vote deactivates in the same transaction; a legit vote reactivates only when it reverses a prior spam vote. Every endpoint 404s to non-staff. Votes commit as the voter. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LFyA5poHwrhAktrnsKrUiQ --- apps/api/src/app.ts | 2 + apps/api/src/plugins/services.ts | 13 + apps/api/src/routes/moderation.ts | 140 ++++++++++ apps/api/src/services/moderation.ts | 412 ++++++++++++++++++++++++++++ apps/api/tests/moderation.test.ts | 224 +++++++++++++++ 5 files changed, 791 insertions(+) create mode 100644 apps/api/src/routes/moderation.ts create mode 100644 apps/api/src/services/moderation.ts create mode 100644 apps/api/tests/moderation.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 99ea97a..cfd6e26 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -60,6 +60,7 @@ import { projectMembershipRoutes } from './routes/projects-members.js'; import { previewRoutes } from './routes/preview.js'; import { attachmentRoutes } from './routes/attachments.js'; import { chatRoutes } from './routes/chat.js'; +import { moderationRoutes } from './routes/moderation.js'; import { samlRoutes } from './routes/saml.js'; import { internalRoutes } from './routes/internal.js'; @@ -201,6 +202,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise { tagsWrite: new TagWriteService(state), githubAccount, accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount), + moderation: new ModerationService(state, fastify.store.private, (personId) => { + // Newest sign-in from session metadata; the auth plugin decorates it + // after this one registers, so resolve lazily per call. + let latest: string | null = null; + for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) { + if (!latest || m.issuedAt > latest) latest = m.issuedAt; + } + return latest; + }), + moderationWrite: new ModerationWriteService(state), }); } diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts new file mode 100644 index 0000000..ddf8d62 --- /dev/null +++ b/apps/api/src/routes/moderation.ts @@ -0,0 +1,140 @@ +/** + * Moderation API — specs/api/moderation.md. + * + * GET /api/admin/members — staff roster, newest signup first + * GET /api/admin/members/:slug — one member's footprint + human votes + * POST /api/admin/members/:slug/vote — record the caller's spam/legit verdict + * + * Every route is staff/admin only and answers 404 to anyone else: like the + * other staff surfaces, the endpoints' existence is not a signal. + */ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { ok, paginated } from '../lib/response.js'; +import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; +import { getCallerSession } from '../services/permissions.js'; +import { buildTransactionOptions } from '../store/commit-meta.js'; +import type { VoteFilter } from '../services/moderation.js'; + +function requireStaffOr404(request: FastifyRequest): void { + const level = request.session.accountLevel; + if (level !== 'staff' && level !== 'administrator') { + throw new ApiNotFoundError('Not found'); + } +} + +export async function moderationRoutes(fastify: FastifyInstance): Promise { + fastify.get( + '/api/admin/members', + { + schema: { + tags: ['moderation'], + summary: 'Staff roster of members, newest signup first', + querystring: { + type: 'object', + properties: { + q: { type: 'string' }, + vote: { type: 'string', enum: ['none', 'spam', 'legit'] }, + joinedAfter: { type: 'string' }, + joinedBefore: { type: 'string' }, + includeDeactivated: { type: 'boolean' }, + sort: { type: 'string' }, + page: { type: 'integer', minimum: 1 }, + perPage: { type: 'integer', minimum: 1, maximum: 200 }, + }, + additionalProperties: false, + }, + }, + }, + async (request) => { + requireStaffOr404(request); + const q = request.query as Record; + const result = await fastify.services.moderation.listMembers({ + q: q['q'] as string | undefined, + vote: q['vote'] as VoteFilter | undefined, + joinedAfter: q['joinedAfter'] as string | undefined, + joinedBefore: q['joinedBefore'] as string | undefined, + includeDeactivated: q['includeDeactivated'] as boolean | undefined, + sort: q['sort'] as string | undefined, + page: q['page'] as number | undefined, + perPage: q['perPage'] as number | undefined, + }); + if ('error' in result) { + throw new ApiValidationError('Unknown sort key', { sort: 'unknown sort key' }); + } + return paginated(result.items, { + page: result.page, + perPage: result.perPage, + totalItems: result.totalItems, + totalPages: Math.max(1, Math.ceil(result.totalItems / result.perPage)), + }); + }, + ); + + fastify.get( + '/api/admin/members/:slug', + { + schema: { + tags: ['moderation'], + summary: "One member's footprint on the site and every human vote", + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + }, + }, + async (request) => { + requireStaffOr404(request); + const { slug } = request.params as { slug: string }; + const caller = getCallerSession(request); + const person = await fastify.services.people.get(slug, caller); + const footprint = fastify.services.moderation.footprint(slug); + if (!person || !footprint) throw new ApiNotFoundError(`Person '${slug}' not found`); + return ok({ + person, + footprint, + votes: fastify.services.moderation.humanVotes(slug), + }); + }, + ); + + fastify.post( + '/api/admin/members/:slug/vote', + { + schema: { + tags: ['moderation'], + summary: "Record the caller's spam / not-spam verdict on a member", + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: 'object', + properties: { + verdict: { type: 'string', enum: ['spam', 'legit'] }, + reasoning: { type: 'string', maxLength: 1000 }, + }, + required: ['verdict'], + additionalProperties: false, + }, + }, + }, + async (request) => { + requireStaffOr404(request); + const { slug } = request.params as { slug: string }; + const body = request.body as { verdict: 'spam' | 'legit'; reasoning?: string }; + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'moderation.vote', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + summary: `${body.verdict}${body.reasoning ? `: ${body.reasoning}` : ''}`, + }), + async (tx) => fastify.services.moderationWrite.castVote(tx, slug, request.session, body), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + const caller = getCallerSession(request); + const person = await fastify.services.people.get(result.value.person.slug, caller); + return ok({ + person, + vote: result.value.vote, + latestVote: fastify.services.moderation.latestHumanVote(slug), + }); + }, + ); +} diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts new file mode 100644 index 0000000..fc8de4c --- /dev/null +++ b/apps/api/src/services/moderation.ts @@ -0,0 +1,412 @@ +/** + * Moderation: the staff members roster, per-member footprint, and human spam + * votes. Per specs/api/moderation.md and specs/screens/admin-members.md. + * + * Reads come entirely from in-memory state plus the private store (email) + * and session metadata (last sign-in). The only write is `castVote`, which + * records a `human-` person-evaluation and applies the lifecycle + * side effect from specs/behaviors/person-lifecycle.md. + */ +import { + PersonSchema, + PersonEvaluationSchema, + HUMAN_EVALUATOR_PREFIX, + humanEvaluatorFor, + isHumanEvaluator, + type Person, + type PersonEvaluation, +} from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { PrivateStore } from '../store/private/interface.js'; +import type { DualStoreTx } from '../store/store.js'; +import { StateApply } from '../store/state-apply.js'; +import type { SessionContext } from '../auth/middleware.js'; +import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; + +export type VoteVerdict = 'spam' | 'legit'; +export type VoteFilter = 'none' | VoteVerdict; + +export interface MemberListOptions { + readonly q?: string; + readonly vote?: VoteFilter; + readonly joinedAfter?: string; + readonly joinedBefore?: string; + readonly includeDeactivated?: boolean; + readonly sort?: string; + readonly page?: number; + readonly perPage?: number; +} + +export interface VoterRef { + readonly slug: string; + readonly fullName: string; +} + +export interface VoteView { + readonly verdict: 'spam' | 'legit' | 'uncertain'; + readonly reasoning: string | null; + readonly voter: VoterRef; + readonly evaluatedAt: string; +} + +export interface FootprintCounts { + readonly memberships: number; + readonly updates: number; + readonly buzz: number; + readonly blogPosts: number; + readonly helpWantedInterest: number; + readonly tags: number; +} + +export interface MemberRow { + readonly id: string; + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; + readonly createdAt: string; + readonly deletedAt: string | null; + readonly email: string | null; + readonly hasGitHubLink: boolean; + readonly lastLoginAt: string | null; + readonly bioExcerpt: string; + readonly footprint: FootprintCounts; + readonly latestVote: VoteView | null; +} + +export interface ProjectRef { + readonly slug: string; + readonly title: string; +} + +export interface MemberFootprint { + readonly memberships: Array<{ project: ProjectRef; role: string; joinedAt: string }>; + readonly updates: Array<{ project: ProjectRef; number: number; title: string; postedAt: string }>; + readonly buzz: Array<{ project: ProjectRef; slug: string; title: string; postedAt: string }>; + readonly blogPosts: Array<{ slug: string; title: string; postedAt: string }>; + readonly helpWantedInterest: Array<{ project: ProjectRef; role: { title: string }; createdAt: string }>; + readonly tags: Array<{ handle: string; type: string }>; +} + +export interface MemberListResult { + readonly items: MemberRow[]; + readonly totalItems: number; + readonly page: number; + readonly perPage: number; +} + +/** Newest sign-in for a person, from session metadata; injected to avoid a plugin dependency. */ +export type LastLoginLookup = (personId: string) => string | null; + +const SORT_KEYS = new Set(['createdAt', 'fullName', 'lastLoginAt']); + +function parseSort(sort: string | undefined): { key: string; desc: boolean } | null { + const raw = sort && sort.trim() !== '' ? sort.trim() : '-createdAt'; + const desc = raw.startsWith('-'); + const key = desc ? raw.slice(1) : raw; + if (!SORT_KEYS.has(key)) return null; + return { key, desc }; +} + +/** Strip the markdown a bio typically carries and cut to ~160 chars. */ +export function bioExcerpt(bio: string | null | undefined, max = 160): string { + if (!bio) return ''; + const text = bio + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[`*_>#~]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +export class ModerationService { + readonly #state: InMemoryState; + readonly #privateStore: PrivateStore; + readonly #lastLogin: LastLoginLookup; + + constructor(state: InMemoryState, privateStore: PrivateStore, lastLogin: LastLoginLookup) { + this.#state = state; + this.#privateStore = privateStore; + this.#lastLogin = lastLogin; + } + + /** Every human vote on a person, newest first. */ + humanVotes(personSlug: string): VoteView[] { + const keys = this.#state.evaluationsByPerson.get(personSlug); + if (!keys) return []; + const votes: VoteView[] = []; + for (const key of keys) { + const rec = this.#state.personEvaluations.get(key); + if (!rec || !isHumanEvaluator(rec.evaluator)) continue; + votes.push(this.#voteView(rec)); + } + return votes.sort((a, b) => b.evaluatedAt.localeCompare(a.evaluatedAt)); + } + + latestHumanVote(personSlug: string): VoteView | null { + return this.humanVotes(personSlug)[0] ?? null; + } + + async listMembers(opts: MemberListOptions): Promise { + const sort = parseSort(opts.sort); + if (!sort) return { error: 'invalid_sort' }; + + const includeDeactivated = opts.includeDeactivated ?? true; + const q = opts.q?.trim().toLowerCase() ?? ''; + const emailSearch = q.includes('@'); + + let people = [...this.#state.people.values()]; + if (!includeDeactivated) people = people.filter((p) => !p.deletedAt); + if (opts.joinedAfter) people = people.filter((p) => p.createdAt >= opts.joinedAfter!); + if (opts.joinedBefore) people = people.filter((p) => p.createdAt <= opts.joinedBefore!); + if (opts.vote) { + people = people.filter((p) => { + const latest = this.latestHumanVote(p.slug); + if (opts.vote === 'none') return latest === null; + return latest?.verdict === opts.vote; + }); + } + + const authored = this.#authoredCounts(); + const lastLogins = new Map(); + const emails = new Map(); + const emailOf = async (p: Person): Promise => { + if (!emails.has(p.id)) emails.set(p.id, (await this.#privateStore.getProfile(p.id))?.email ?? null); + return emails.get(p.id) ?? null; + }; + + if (q) { + const matched: Person[] = []; + for (const p of people) { + const hay = `${p.fullName} ${p.slug} ${p.bio ?? ''}`.toLowerCase(); + if (hay.includes(q)) { + matched.push(p); + continue; + } + if (emailSearch) { + const email = await emailOf(p); + if (email && email.toLowerCase().includes(q)) matched.push(p); + } + } + people = matched; + } + + const lastLoginOf = (p: Person): string | null => { + if (!lastLogins.has(p.id)) lastLogins.set(p.id, this.#lastLogin(p.id)); + return lastLogins.get(p.id) ?? null; + }; + + people.sort((a, b) => { + let cmp: number; + if (sort.key === 'fullName') cmp = a.fullName.localeCompare(b.fullName); + else if (sort.key === 'lastLoginAt') cmp = (lastLoginOf(a) ?? '').localeCompare(lastLoginOf(b) ?? ''); + else cmp = a.createdAt.localeCompare(b.createdAt); + if (cmp === 0) cmp = a.slug.localeCompare(b.slug); + return sort.desc ? -cmp : cmp; + }); + + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(200, Math.max(1, opts.perPage ?? 50)); + const slice = people.slice((page - 1) * perPage, page * perPage); + + const items: MemberRow[] = []; + for (const p of slice) { + items.push({ + id: p.id, + slug: p.slug, + fullName: p.fullName, + avatarUrl: p.avatarKey ? `/api/attachments/${p.avatarKey}` : null, + createdAt: p.createdAt, + deletedAt: p.deletedAt ?? null, + email: await emailOf(p), + hasGitHubLink: typeof p.githubUserId === 'number', + lastLoginAt: lastLoginOf(p), + bioExcerpt: bioExcerpt(p.bio), + footprint: { + memberships: this.#state.membershipsByPerson.get(p.id)?.size ?? 0, + updates: authored.updates.get(p.id) ?? 0, + buzz: authored.buzz.get(p.id) ?? 0, + blogPosts: authored.blogPosts.get(p.id) ?? 0, + helpWantedInterest: authored.interest.get(p.id) ?? 0, + tags: this.#state.tagAssignmentsByTaggable.get(p.id)?.size ?? 0, + }, + latestVote: this.latestHumanVote(p.slug), + }); + } + + return { items, totalItems: people.length, page, perPage }; + } + + /** Full footprint for one member (deactivated included — this is moderation). */ + footprint(slug: string): MemberFootprint | null { + const id = this.#state.personIdBySlug.get(slug); + if (!id) return null; + const s = this.#state; + const projectRef = (projectId: string): ProjectRef | null => { + const project = s.projects.get(projectId); + return project ? { slug: project.slug, title: project.title } : null; + }; + + const memberships = [...(s.membershipsByPerson.get(id) ?? [])] + .map((mid) => s.projectMemberships.get(mid)) + .flatMap((m) => { + const project = m ? projectRef(m.projectId) : null; + return m && project ? [{ project, role: m.role ?? (m.isMaintainer ? 'maintainer' : 'member'), joinedAt: m.joinedAt }] : []; + }); + + const updates = [...s.projectUpdates.values()] + .filter((u) => u.authorId === id) + .flatMap((u) => { + const project = projectRef(u.projectId); + // Updates have no title; the excerpt of the body stands in for one. + return project ? [{ project, number: u.number, title: bioExcerpt(u.body, 80), postedAt: u.createdAt }] : []; + }) + .sort((a, b) => b.postedAt.localeCompare(a.postedAt)); + + const buzz = [...s.projectBuzz.values()] + .filter((b) => b.postedById === id) + .flatMap((b) => { + const project = projectRef(b.projectId); + return project ? [{ project, slug: b.slug, title: b.headline, postedAt: b.publishedAt }] : []; + }) + .sort((a, b) => b.postedAt.localeCompare(a.postedAt)); + + const blogPosts = [...s.blogPosts.values()] + .filter((bp) => bp.authorId === id) + .map((bp) => ({ slug: bp.slug, title: bp.title, postedAt: bp.postedAt })) + .sort((a, b) => b.postedAt.localeCompare(a.postedAt)); + + const helpWantedInterest = [...s.helpWantedInterest.values()] + .filter((i) => i.personId === id) + .flatMap((i) => { + const role = s.helpWantedRoles.get(i.roleId); + const project = role ? projectRef(role.projectId) : null; + return role && project ? [{ project, role: { title: role.title }, createdAt: i.createdAt }] : []; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + const tags = [...(s.tagAssignmentsByTaggable.get(id) ?? [])] + .map((taId) => s.tagAssignments.get(taId)) + .flatMap((ta) => { + const tag = ta ? s.tags.get(ta.tagId) : undefined; + return tag ? [{ handle: `${tag.namespace}.${tag.slug}`, type: tag.namespace }] : []; + }); + + return { memberships, updates, buzz, blogPosts, helpWantedInterest, tags }; + } + + #voteView(rec: PersonEvaluation): VoteView { + const voterSlug = rec.evaluator.slice(HUMAN_EVALUATOR_PREFIX.length); + const voterId = this.#state.personIdBySlug.get(voterSlug); + const voter = voterId ? this.#state.people.get(voterId) : undefined; + return { + verdict: rec.verdict, + reasoning: rec.reasoning ?? null, + voter: { slug: voterSlug, fullName: voter?.fullName ?? voterSlug }, + evaluatedAt: rec.evaluatedAt, + }; + } + + #authoredCounts(): { + updates: Map; + buzz: Map; + blogPosts: Map; + interest: Map; + } { + const bump = (m: Map, k: string | null | undefined): void => { + if (k) m.set(k, (m.get(k) ?? 0) + 1); + }; + const updates = new Map(); + const buzz = new Map(); + const blogPosts = new Map(); + const interest = new Map(); + for (const u of this.#state.projectUpdates.values()) bump(updates, u.authorId); + for (const b of this.#state.projectBuzz.values()) bump(buzz, b.postedById); + for (const bp of this.#state.blogPosts.values()) bump(blogPosts, bp.authorId); + for (const i of this.#state.helpWantedInterest.values()) bump(interest, i.personId); + return { updates, buzz, blogPosts, interest }; + } +} + +export interface CastVoteInput { + readonly verdict: VoteVerdict; + readonly reasoning?: string; +} + +export class ModerationWriteService { + readonly #state: InMemoryState; + + constructor(state: InMemoryState) { + this.#state = state; + } + + /** + * Record the caller's verdict as `person-evaluations//human-` + * and apply the lifecycle side effect: spam → deactivate; legit → reactivate + * only when the previous latest human vote was spam (a self-deactivation is + * never undone by a vote). One record per voter; re-voting replaces it. + */ + async castVote( + tx: DualStoreTx, + slug: string, + session: SessionContext, + input: CastVoteInput, + ): Promise<{ person: Person; vote: PersonEvaluation; stateApply: StateApply }> { + const voter = session.person; + if (!voter) throw new ApiNotFoundError(`Person '${slug}' not found`); + + const id = this.#state.personIdBySlug.get(slug); + const existing = id ? this.#state.people.get(id) : undefined; + if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`); + if (existing.id === voter.id) { + throw new ApiValidationError('You cannot vote on your own account', { slug: 'self' }); + } + if (input.reasoning !== undefined && input.reasoning.length > 1000) { + throw new ApiValidationError('Reasoning is too long', { reasoning: 'max 1000 characters' }); + } + + const previousLatest = latestHumanVerdict(this.#state, slug); + const now = nowIso(); + const vote: PersonEvaluation = PersonEvaluationSchema.parse({ + personSlug: slug, + evaluator: humanEvaluatorFor(voter.slug), + verdict: input.verdict, + confidence: 1, + flags: ['manual-override'], + ...(input.reasoning && input.reasoning.trim() !== '' ? { reasoning: input.reasoning.trim() } : {}), + evaluatedAt: now, + }); + + await tx.public['person-evaluations'].upsert(vote); + const stateApply = new StateApply().upsertPersonEvaluation(vote); + + let person = existing; + if (input.verdict === 'spam' && !existing.deletedAt) { + person = PersonSchema.parse({ ...existing, deletedAt: now, updatedAt: now }); + } else if (input.verdict === 'legit' && existing.deletedAt && previousLatest === 'spam') { + person = PersonSchema.parse({ ...existing, deletedAt: null, updatedAt: now }); + } + if (person !== existing) { + await tx.public.people.upsert(person); + stateApply.upsertPerson(person); + } + + return { person, vote, stateApply }; + } +} + +function latestHumanVerdict(state: InMemoryState, personSlug: string): PersonEvaluation['verdict'] | null { + const keys = state.evaluationsByPerson.get(personSlug); + if (!keys) return null; + let latest: PersonEvaluation | null = null; + for (const key of keys) { + const rec = state.personEvaluations.get(key); + if (!rec || !isHumanEvaluator(rec.evaluator)) continue; + if (!latest || rec.evaluatedAt > latest.evaluatedAt) latest = rec; + } + return latest?.verdict ?? null; +} diff --git a/apps/api/tests/moderation.test.ts b/apps/api/tests/moderation.test.ts new file mode 100644 index 0000000..111151f --- /dev/null +++ b/apps/api/tests/moderation.test.ts @@ -0,0 +1,224 @@ +/** + * Moderation API — specs/api/moderation.md, specs/behaviors/person-lifecycle.md. + * + * Covers: + * - every endpoint 404s for anonymous and ordinary users (existence is not a signal) + * - the roster lists members newest first with staff-visible email and vote state + * - a spam vote records `human-` and deactivates in the same transaction + * - a legit vote after a spam vote reactivates; after a self-deactivation it does not + * - re-voting replaces the caller's record; self-votes are rejected + * - the `vote` filter and the detail endpoint's footprint + vote history + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { execFile } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +import { buildApp } from '../src/app.js'; +import { mintSessionFor } from '../src/auth/issue.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawToml } from './helpers/seed-fixtures.js'; + +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; + +const STAFF_ID = '01951a3c-0000-7000-8000-0000000000a1'; +const ADMIN_ID = '01951a3c-0000-7000-8000-0000000000a2'; +const USER_ID = '01951a3c-0000-7000-8000-0000000000b1'; +const NEW_ID = '01951a3c-0000-7000-8000-0000000000b2'; +const SELFOFF_ID = '01951a3c-0000-7000-8000-0000000000b3'; + +async function seedPerson( + repoDir: string, + opts: { slug: string; id: string; accountLevel?: string; createdAt: string; deletedAt?: string; bio?: string; githubUserId?: number }, +): Promise { + const lines = [ + `id = "${opts.id}"`, + `slug = "${opts.slug}"`, + `fullName = "Test ${opts.slug}"`, + `accountLevel = "${opts.accountLevel ?? 'user'}"`, + opts.bio ? `bio = "${opts.bio}"` : '', + typeof opts.githubUserId === 'number' ? `githubUserId = ${opts.githubUserId}` : '', + opts.deletedAt ? `deletedAt = "${opts.deletedAt}"` : '', + `createdAt = "${opts.createdAt}"`, + `updatedAt = "${opts.createdAt}"`, + ].filter(Boolean); + await seedRawToml(repoDir, `people/${opts.slug}.toml`, lines.join('\n'), `seed person ${opts.slug}`); +} + +describe('moderation API', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + let staffCookie: string; + let adminCookie: string; + let userCookie: string; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, { slug: 'staffer', id: STAFF_ID, accountLevel: 'staff', createdAt: '2026-01-01T00:00:00Z' }); + await seedPerson(dataRepo.path, { slug: 'boss', id: ADMIN_ID, accountLevel: 'administrator', createdAt: '2026-01-02T00:00:00Z' }); + await seedPerson(dataRepo.path, { slug: 'regular', id: USER_ID, createdAt: '2026-02-01T00:00:00Z', githubUserId: 42 }); + await seedPerson(dataRepo.path, { slug: 'newest', id: NEW_ID, createdAt: '2026-09-01T00:00:00Z', bio: 'Buy **cheap** [pills](https://x.example) now' }); + await seedPerson(dataRepo.path, { slug: 'selfoff', id: SELFOFF_ID, createdAt: '2026-03-01T00:00:00Z', deletedAt: '2026-04-01T00:00:00Z' }); + + const profiles = [ + { personId: NEW_ID, email: 'newest@example.org' }, + { personId: USER_ID, email: 'regular@example.org' }, + ].map((p) => + JSON.stringify({ + ...p, + emailRefreshedAt: '2026-05-01T00:00:00.000Z', + newsletter: { optedIn: false, optedInAt: null, optedOutAt: null, unsubscribeToken: null }, + updatedAt: '2026-05-01T00:00:00.000Z', + }), + ); + await writeFile(join(privateStore.path, 'profiles.jsonl'), profiles.join('\n') + '\n'); + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + + staffCookie = (await mintSessionFor(STAFF_ID, 'staff', JWT_KEY)).accessToken; + adminCookie = (await mintSessionFor(ADMIN_ID, 'administrator', JWT_KEY)).accessToken; + userCookie = (await mintSessionFor(USER_ID, 'user', JWT_KEY)).accessToken; + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + const asStaff = (cookie: string) => ({ cookies: { cfp_session: cookie } }); + + it('404s for anonymous and ordinary users on all three endpoints', async () => { + for (const cookies of [{}, asStaff(userCookie)]) { + const list = await app.inject({ method: 'GET', url: '/api/admin/members', ...cookies }); + expect(list.statusCode).toBe(404); + const detail = await app.inject({ method: 'GET', url: '/api/admin/members/newest', ...cookies }); + expect(detail.statusCode).toBe(404); + const vote = await app.inject({ + method: 'POST', + url: '/api/admin/members/newest/vote', + payload: { verdict: 'spam' }, + ...cookies, + }); + expect(vote.statusCode).toBe(404); + } + }); + + it('lists members newest first with staff-visible fields and no vote yet', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/members', ...asStaff(staffCookie) }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: Array>; metadata: { totalItems: number } }>(); + expect(body.metadata.totalItems).toBe(5); + expect(body.data[0]?.['slug']).toBe('newest'); + const newest = body.data[0]!; + expect(newest['email']).toBe('newest@example.org'); + expect(newest['latestVote']).toBeNull(); + expect(newest['bioExcerpt']).toBe('Buy cheap pills now'); + const regular = body.data.find((r) => r['slug'] === 'regular')!; + expect(regular['hasGitHubLink']).toBe(true); + // Deactivated members are included by default — moderation needs to see what it hid. + expect(body.data.some((r) => r['slug'] === 'selfoff')).toBe(true); + }); + + it('spam vote records human- and deactivates in the same transaction', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/newest/vote', + payload: { verdict: 'spam', reasoning: 'pharma bio' }, + ...asStaff(staffCookie), + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: { person: { deletedAt: string | null }; vote: { evaluator: string; confidence: number }; latestVote: { verdict: string; voter: { slug: string } } } }>(); + expect(body.data.vote.evaluator).toBe('human-staffer'); + expect(body.data.vote.confidence).toBe(1); + expect(body.data.person.deletedAt).not.toBeNull(); + expect(body.data.latestVote.verdict).toBe('spam'); + expect(body.data.latestVote.voter.slug).toBe('staffer'); + + // Hidden from the public detail endpoint now. + const pub = await app.inject({ method: 'GET', url: '/api/people/newest' }); + expect(pub.statusCode).toBe(404); + + // The record landed on the data repo as a committed file, authored by the voter. + const tree = await execFileAsync('git', ['ls-tree', '-r', '--name-only', 'HEAD', 'person-evaluations/'], { + cwd: dataRepo.path, + }); + expect(tree.stdout.trim().split('\n')).toEqual(['person-evaluations/newest/human-staffer.toml']); + const author = await execFileAsync('git', ['log', '-1', '--format=%an <%ae>'], { cwd: dataRepo.path }); + expect(author.stdout.trim()).toBe('Test staffer '); + }); + + it('vote filter and detail endpoint expose the vote history and footprint', async () => { + const spamOnly = await app.inject({ method: 'GET', url: '/api/admin/members?vote=spam', ...asStaff(adminCookie) }); + expect(spamOnly.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug)).toEqual(['newest']); + + const none = await app.inject({ method: 'GET', url: '/api/admin/members?vote=none', ...asStaff(adminCookie) }); + expect(none.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug)).not.toContain('newest'); + + const detail = await app.inject({ method: 'GET', url: '/api/admin/members/newest', ...asStaff(adminCookie) }); + expect(detail.statusCode).toBe(200); + const d = detail.json<{ data: { person: { slug: string; email: string | null }; footprint: Record; votes: Array<{ verdict: string; reasoning: string | null }> } }>().data; + expect(d.person.slug).toBe('newest'); + expect(d.person.email).toBe('newest@example.org'); + expect(d.footprint.memberships).toEqual([]); + expect(d.votes).toHaveLength(1); + expect(d.votes[0]).toMatchObject({ verdict: 'spam', reasoning: 'pharma bio' }); + }); + + it('a legit vote after a spam vote reactivates; re-voting replaces the record', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/newest/vote', + payload: { verdict: 'legit' }, + ...asStaff(staffCookie), + }); + expect(res.statusCode).toBe(200); + expect(res.json<{ data: { person: { deletedAt: string | null } } }>().data.person.deletedAt).toBeNull(); + + const detail = await app.inject({ method: 'GET', url: '/api/admin/members/newest', ...asStaff(staffCookie) }); + const votes = detail.json<{ data: { votes: Array<{ verdict: string; voter: { slug: string } }> } }>().data.votes; + expect(votes).toHaveLength(1); + expect(votes[0]).toMatchObject({ verdict: 'legit', voter: { slug: 'staffer' } }); + }); + + it('a legit vote never undoes a self-deactivation', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/selfoff/vote', + payload: { verdict: 'legit' }, + ...asStaff(adminCookie), + }); + expect(res.statusCode).toBe(200); + expect(res.json<{ data: { person: { deletedAt: string | null } } }>().data.person.deletedAt).not.toBeNull(); + }); + + it('rejects voting on your own account', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/staffer/vote', + payload: { verdict: 'legit' }, + ...asStaff(staffCookie), + }); + expect(res.statusCode).toBe(422); + }); + + it('rejects an unknown sort key', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/members?sort=bogus', ...asStaff(staffCookie) }); + expect(res.statusCode).toBe(422); + }); +}); From 2a50f0856348ece4283d3f9e7b808be6f7639fe1 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 18 Sep 2026 15:30:08 -0400 Subject: [PATCH 6/9] feat(scripts): prune-spam reads human votes and the private evaluations repo --evaluations-repo points at the private codeforphilly-spam-detection clone; --human-votes-ref (default: the pruned branch) supplies staff votes from the data repo. The latest human vote is final and a human spam vote bypasses the project-membership protection. The operations doc now describes the two-repo layout and the mandatory `evaluate-llm --filter spam` confirmation step. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LFyA5poHwrhAktrnsKrUiQ --- apps/api/scripts/prune-spam.ts | 141 +++++++++++++++++++++--------- docs/operations/spam-detection.md | 39 ++++++--- 2 files changed, 126 insertions(+), 54 deletions(-) diff --git a/apps/api/scripts/prune-spam.ts b/apps/api/scripts/prune-spam.ts index dbacc9e..e7cfc31 100644 --- a/apps/api/scripts/prune-spam.ts +++ b/apps/api/scripts/prune-spam.ts @@ -1,23 +1,30 @@ /** * prune-spam.ts — Re-runnable spam-prune operator script. * - * Reads spam verdicts from the `spam-detection` branch of the data repo, - * aggregates them per the spec rule, and removes confident-spam people from - * the `published` branch with cascaded deletes of their associated records. + * Reads machine spam verdicts from the private spam-detection repo and staff + * `human-*` votes from the data repo's served branch, aggregates them per the + * spec rule (a human vote is final), and removes spam people from the + * `published` branch with cascaded deletes of their associated records. * * Spec: specs/behaviors/spam-exclusion.md * * Usage: * npm run -w apps/api script:prune-spam -- \ - * --data-repo=/path/to/codeforphilly-data \ - * [--evaluations-ref=spam-detection] \ + * --data-repo=/path/to/codeforphilly-data.git \ + * --evaluations-repo=/path/to/codeforphilly-spam-detection \ + * [--evaluations-ref=HEAD] \ + * [--human-votes-ref=published] \ * [--branch=published] \ * [--threshold=0.8] \ * [--dry-run] [--verbose] * * --data-repo Path to a local bare clone of the data repo. * Falls back to $CFP_DATA_REPO_PATH. - * --evaluations-ref Ref to read person-evaluations from (default: spam-detection). + * --evaluations-repo Repo (bare or not) holding machine person-evaluations — + * the private codeforphilly-spam-detection clone. Defaults + * to --data-repo for single-repo setups. + * --evaluations-ref Ref in --evaluations-repo to read (default: HEAD). + * --human-votes-ref Ref in --data-repo carrying staff votes (default: --branch). * --branch Branch to prune (default: published). * --threshold Spam confidence threshold (default: 0.8). * --dry-run Report without writing. @@ -43,7 +50,11 @@ import type { interface CliArgs { readonly dataRepo: string; + /** Repo holding the machine evaluations (the private spam-detection clone). Defaults to dataRepo. */ + readonly evaluationsRepo: string; readonly evaluationsRef: string; + /** Ref in dataRepo carrying staff `human-*` votes. Defaults to --branch. */ + readonly humanVotesRef: string; readonly branch: string; readonly threshold: number; readonly dryRun: boolean; @@ -73,16 +84,23 @@ function parseArgs(argv: readonly string[]): CliArgs { const threshold = typeof thresholdRaw === 'string' ? Number.parseFloat(thresholdRaw) : 0.8; + const branch = + typeof opts['branch'] === 'string' && opts['branch'] !== '' ? opts['branch'] : 'published'; return { dataRepo: resolve(dataRepoRaw), + evaluationsRepo: + typeof opts['evaluations-repo'] === 'string' && opts['evaluations-repo'] !== '' + ? resolve(opts['evaluations-repo']) + : resolve(dataRepoRaw), evaluationsRef: typeof opts['evaluations-ref'] === 'string' && opts['evaluations-ref'] !== '' ? opts['evaluations-ref'] - : 'spam-detection', - branch: - typeof opts['branch'] === 'string' && opts['branch'] !== '' - ? opts['branch'] - : 'published', + : 'HEAD', + humanVotesRef: + typeof opts['human-votes-ref'] === 'string' && opts['human-votes-ref'] !== '' + ? opts['human-votes-ref'] + : branch, + branch, threshold: Number.isFinite(threshold) ? threshold : 0.8, dryRun: opts['dry-run'] === true, verbose: opts['verbose'] === true, @@ -94,22 +112,31 @@ function parseArgs(argv: readonly string[]): CliArgs { // --------------------------------------------------------------------------- interface PersonVerdict { - /** Whether any evaluator gave spam confidence >= threshold. */ + /** Whether any machine evaluator gave spam confidence >= threshold. */ hasConfidentSpam: boolean; - /** Whether any evaluator gave a legit verdict at any confidence. */ + /** Whether any machine evaluator gave a legit verdict at any confidence. */ hasAnyLegit: boolean; + /** + * The latest `human-*` verdict, if any. A human vote is final: `spam` + * prunes regardless of machine verdicts or project membership; `legit` + * keeps. Per specs/behaviors/spam-exclusion.md. + */ + humanVerdict: string | null; + humanEvaluatedAt: string | null; } /** - * Parse verdict and confidence from TOML content using line-regex - * (tolerant, avoids pulling in a full TOML parser just for two fields). + * Parse verdict, confidence, and evaluatedAt from TOML content using + * line-regex (tolerant, avoids pulling in a full TOML parser for three fields). */ function parseEvaluationRecord(tomlContent: string): { verdict: string | null; confidence: number | null; + evaluatedAt: string | null; } { let verdict: string | null = null; let confidence: number | null = null; + let evaluatedAt: string | null = null; for (const line of tomlContent.split('\n')) { const trimmed = line.trim(); @@ -122,12 +149,17 @@ function parseEvaluationRecord(tomlContent: string): { if (confidenceMatch) { const parsed = Number.parseFloat(confidenceMatch[1] ?? ''); if (Number.isFinite(parsed)) confidence = parsed; + continue; } + const atMatch = trimmed.match(/^evaluatedAt\s*=\s*"([^"]+)"/); + if (atMatch) evaluatedAt = atMatch[1] ?? null; } - return { verdict, confidence }; + return { verdict, confidence, evaluatedAt }; } +const HUMAN_EVALUATOR_PREFIX = 'human-'; + /** * Read all person-evaluations from the given ref via `git cat-file` bulk read. * Does NOT go through gitsheets — there are ~54k records and we want a @@ -138,8 +170,9 @@ async function aggregateVerdicts( evaluationsRef: string, threshold: number, log: (msg: string) => void, + verdictMap: Map = new Map(), ): Promise> { - log(`[prune-spam] listing person-evaluations under ref=${evaluationsRef}`); + log(`[prune-spam] listing person-evaluations in ${repo} under ref=${evaluationsRef}`); // List all blobs under person-evaluations/ in the evaluations ref. const lsOutput = await exec( @@ -152,7 +185,7 @@ async function aggregateVerdicts( log(`[prune-spam] found ${lines.length} evaluation records`); if (lines.length === 0) { - return new Map(); + return verdictMap; } // Build a batch-check-mailbox input: one object hash per line. @@ -174,8 +207,6 @@ async function aggregateVerdicts( // Use child_process.spawn for streaming instead of execFile (fits in memory for this size). const { spawn } = await import('node:child_process'); - const verdictMap = new Map(); - await new Promise((resolvePromise, reject) => { const catFile = spawn('git', ['cat-file', '--batch'], { cwd: repo }); @@ -248,20 +279,25 @@ async function aggregateVerdicts( const pathParts = currentExpected.path.split('/'); // path is like: person-evaluations//.toml const personSlug = pathParts[1]; + const evaluator = (pathParts[2] ?? '').replace(/\.toml$/, ''); if (personSlug) { - const { verdict, confidence } = parseEvaluationRecord(tomlContent); - if (verdict !== null && confidence !== null) { - let entry = verdictMap.get(personSlug); - if (!entry) { - entry = { hasConfidentSpam: false, hasAnyLegit: false }; - verdictMap.set(personSlug, entry); - } - if (verdict === 'spam' && confidence >= threshold) { - (entry as { hasConfidentSpam: boolean }).hasConfidentSpam = true; - } - if (verdict === 'legit') { - (entry as { hasAnyLegit: boolean }).hasAnyLegit = true; + const { verdict, confidence, evaluatedAt } = parseEvaluationRecord(tomlContent); + let entry = verdictMap.get(personSlug); + if (!entry) { + entry = { hasConfidentSpam: false, hasAnyLegit: false, humanVerdict: null, humanEvaluatedAt: null }; + verdictMap.set(personSlug, entry); + } + if (evaluator.startsWith(HUMAN_EVALUATOR_PREFIX)) { + // Latest human vote wins; ties resolve to whichever is read last. + if (verdict !== null && (entry.humanEvaluatedAt === null || (evaluatedAt ?? '') >= entry.humanEvaluatedAt)) { + entry.humanVerdict = verdict; + entry.humanEvaluatedAt = evaluatedAt ?? ''; } + } else if (verdict !== null && confidence !== null) { + // Heuristic records carry `score`, not `confidence`, and never + // reach here — they must be LLM-confirmed before they can prune. + if (verdict === 'spam' && confidence >= threshold) entry.hasConfidentSpam = true; + if (verdict === 'legit') entry.hasAnyLegit = true; } } @@ -292,17 +328,30 @@ async function aggregateVerdicts( } /** - * Compute the set of person slugs to prune: - * prune iff hasConfidentSpam AND NOT hasAnyLegit. + * Compute the set of person slugs to prune. A human vote is final: `spam` + * prunes, `legit` keeps. Otherwise prune iff hasConfidentSpam AND NOT + * hasAnyLegit. Returns the human-spam subset too, since those bypass the + * project-membership protection. */ -function computePruneSet(verdictMap: Map): Set { +function computePruneSet(verdictMap: Map): { + pruneSet: Set; + humanSpam: Set; +} { const pruneSet = new Set(); + const humanSpam = new Set(); for (const [slug, v] of verdictMap) { + if (v.humanVerdict !== null) { + if (v.humanVerdict === 'spam') { + pruneSet.add(slug); + humanSpam.add(slug); + } + continue; + } if (v.hasConfidentSpam && !v.hasAnyLegit) { pruneSet.add(slug); } } - return pruneSet; + return { pruneSet, humanSpam }; } /** Minimal read surface shared by the live store and an open transaction. */ @@ -326,6 +375,7 @@ interface CandidatePerson { async function partitionCandidates( q: Queryable, candidateSlugs: Set, + humanSpam: Set = new Set(), ): Promise<{ prune: CandidatePerson[]; protectedByMembership: number }> { const candidates: CandidatePerson[] = []; for await (const person of q.people.query()) { @@ -343,7 +393,9 @@ async function partitionCandidates( if (typeof pid === 'string') memberPersonIds.add(pid); } - const prune = candidates.filter((c) => !memberPersonIds.has(c.id)); + // Membership protects against machine verdicts only — a human looked at the + // profile, so a human `spam` vote is not overridden by real-looking content. + const prune = candidates.filter((c) => humanSpam.has(c.slug) || !memberPersonIds.has(c.id)); return { prune, protectedByMembership: candidates.length - prune.length }; } @@ -406,17 +458,22 @@ async function pruneSpam(args: CliArgs): Promise { // ------------------------------------------------------------------------- // 1. Read verdicts from evaluations ref (efficient git read) // ------------------------------------------------------------------------- - log(`[prune-spam] reading verdicts from ref=${args.evaluationsRef}, threshold=${args.threshold}`); + log( + `[prune-spam] reading machine verdicts from ${args.evaluationsRepo}@${args.evaluationsRef}, ` + + `human votes from ${args.dataRepo}@${args.humanVotesRef}, threshold=${args.threshold}`, + ); const verdictMap = await aggregateVerdicts( - args.dataRepo, + args.evaluationsRepo, args.evaluationsRef, args.threshold, log, ); + await aggregateVerdicts(args.dataRepo, args.humanVotesRef, args.threshold, log, verdictMap); - const pruneSet = computePruneSet(verdictMap); + const { pruneSet, humanSpam } = computePruneSet(verdictMap); log( - `[prune-spam] evaluated=${verdictMap.size} persons, pruneSet=${pruneSet.size} (confident spam with no legit)`, + `[prune-spam] evaluated=${verdictMap.size} persons, pruneSet=${pruneSet.size} ` + + `(${humanSpam.size} by human vote; the rest confident machine spam with no legit)`, ); // ------------------------------------------------------------------------- @@ -443,6 +500,7 @@ async function pruneSpam(args: CliArgs): Promise { const { prune, protectedByMembership } = await partitionCandidates( store as unknown as Queryable, pruneSet, + humanSpam, ); console.log( `[prune-spam] dry-run: would prune ${prune.length} (of ${pruneSet.size} verdict-flagged slugs); ${protectedByMembership} protected by project membership`, @@ -492,6 +550,7 @@ async function pruneSpam(args: CliArgs): Promise { const { prune, protectedByMembership: protectedCount } = await partitionCandidates( tx as unknown as Queryable, pruneSet, + humanSpam, ); protectedByMembership = protectedCount; diff --git a/docs/operations/spam-detection.md b/docs/operations/spam-detection.md index 4e00cc9..2265e0d 100644 --- a/docs/operations/spam-detection.md +++ b/docs/operations/spam-detection.md @@ -2,11 +2,18 @@ The `codeforphilly-data` site has accumulated tens of thousands of legacy signups, the majority of which are spam — SEO link drops, gambling / adult / cleaning-service promotional bios, foreign-language commercial content, and so on. This document describes the multi-pass evaluation system that scores every person record and the workflow for refreshing the evaluations as new data arrives. -> See also: the [codeforphilly-data repo](https://github.com/CodeForPhilly/codeforphilly-data), currently on the `spam-detection` branch where the scripts, sheet configs, and evaluation data all live. +> See also: the private [codeforphilly-spam-detection repo](https://github.com/CodeForPhilly/codeforphilly-spam-detection), where the scripts, the evaluation sheet configs, and the machine-produced evaluation data live. Staff **human votes** live in the public data repo (`person-evaluations` on `published`) — see [specs/api/moderation.md](../../specs/api/moderation.md). ## Where everything lives -All work — scripts, sheet configs, and evaluation records — currently sits on the `spam-detection` branch of `codeforphilly-data`. Eventually this will migrate (scripts + configs up to `empty`, eval data down to `published`), but for now treat `spam-detection` as the source of truth for the spam moderation surface. +Since 2026-09-18 the pipeline is split across two repositories: + +| Repo | Visibility | Holds | +| --- | --- | --- | +| `CodeForPhilly/codeforphilly-spam-detection` | **private** | scripts, the four evaluation sheet configs, machine `person-evaluations` (heuristic + LLM), and the Slack-derived sheets. Slack message text, Slack identities, and LLM prose about named people never leave it. | +| `CodeForPhilly/codeforphilly-data` | public-by-design | the served data on `published`, plus `person-evaluations` records written by the site: staff votes with `evaluator = "human-"`. | + +The scripts read people/projects/etc. from a clone of the data repo on `published` (`CFP_DATA_GIT_DIR`) and write only to the private repo. The prune reads machine verdicts from the private repo and human votes from `published`; **a human vote is final** ([spam-exclusion.md](../../specs/behaviors/spam-exclusion.md)). The old `spam-detection` branch of the data repo was deleted when the split landed. ## Data model @@ -108,7 +115,7 @@ Per-person cache at `.llm-eval-cache/.json`, flushed every 50 evaluat `person-evaluations` is keyed `personSlug/evaluator`, so multiple evaluator opinions coexist per person. To compute the authoritative verdict for a given slug, apply priority: ``` -1. Any `human:*` evaluator → use that (manual override is final) +1. Any `human-*` evaluator → use that (manual override is final) 2. Latest `haiku-*` evaluator → use that (most recent LLM is current) 3. Latest `heuristic-*` evaluator → use that 4. No record → treat as legit (default-allow per @@ -119,12 +126,12 @@ When new evaluator versions ship (e.g. `haiku-2026-06` with rubric improvements) ## Manual overrides -To override an LLM verdict for one person — for example, to mark a mis-flagged spammer as legit, or to confirm a high-confidence-spam call as definitely-spam before a deletion pass — upsert a `human:` record: +To override an LLM verdict for one person — for example, to mark a mis-flagged spammer as legit, or to confirm a high-confidence-spam call as definitely-spam before a deletion pass — upsert a `human-` record: ```bash gitsheets-axi upsert person-evaluations --data '{ "personSlug": "ackrolix123", - "evaluator": "human:chris", + "evaluator": "human-chris", "verdict": "legit", "confidence": 1.0, "flags": ["manual-override"], @@ -133,18 +140,17 @@ gitsheets-axi upsert person-evaluations --data '{ }' ``` -Because the path template is `${{ personSlug }}/${{ evaluator }}` and `evaluator` is `human:chris`, the file lands at `person-evaluations/ackrolix123/human:chris.toml`. Verdict aggregation will pick it up automatically. +Because the path template is `${{ personSlug }}/${{ evaluator }}` and `evaluator` is `human-chris`, the file lands at `person-evaluations/ackrolix123/human-chris.toml`. Verdict aggregation will pick it up automatically. -To unset a human override, `gitsheets-axi delete person-evaluations ackrolix123/human:chris`. +To unset a human override, `gitsheets-axi delete person-evaluations ackrolix123/human-chris`. ## Refreshing evaluations after new data arrives `legacy-import` snapshots and live API writes land new + updated person records on `published`. To refresh: ```bash -# 1. Pull latest data -git fetch origin published -git merge origin/published # or rebase onto spam-detection's data work +# 1. Refresh the data clone the scripts read from (CFP_DATA_GIT_DIR, HEAD on published) +git -C $CFP_DATA_GIT_DIR fetch origin published:published # 2. Refresh Slack snapshot (cheap — cache hits if no new channels) npm run fetch-slack @@ -158,6 +164,13 @@ npm run evaluate-heuristic # 5. LLM-eval the new uncertain bucket npm run evaluate-llm +# 5b. LLM-confirm the heuristic-spam bucket. REQUIRED: heuristic records carry a +# `score` but no `confidence`, so the prune ignores them until Haiku confirms. +npm run evaluate-llm -- --filter spam + +# 5c. Push the evaluations (they commit to this repo's current branch) +git push + # 6. Apply the verdicts — prune confident-spam from `published` (see below). # Run from the codeforphilly-ng repo against a bare clone, then push. # THIS STEP IS MANDATORY after any import/merge — see "Applying spam decisions". @@ -173,13 +186,13 @@ When source records get updated (e.g., a previously-empty profile gets a new bio Verdicts are advisory until the **prune** step applies them. Prune is not a read-path filter (the runtime loader stays spam-unaware); it **removes confident-spam people from `published`** so the deployed app never loads them into memory or shows them. This is what keeps the in-memory footprint within the node budget — see [specs/behaviors/spam-exclusion.md](../../specs/behaviors/spam-exclusion.md) for the full contract. -The tool is `apps/api/scripts/prune-spam.ts` in the **`codeforphilly-ng`** repo (not the data repo). Run it against a bare clone of the data repo that carries both `published` and `spam-detection`, dry-run first, then push: +The tool is `apps/api/scripts/prune-spam.ts` in the **`codeforphilly-ng`** repo (not the data repo). Run it against a bare clone of the data repo (`--data-repo`, carrying `published` with the staff votes) and a clone of the private repo (`--evaluations-repo`), dry-run first, then push: ```bash # From the codeforphilly-ng repo npm run -w apps/api script:prune-spam -- \ --data-repo=/path/to/codeforphilly-data.git \ - --evaluations-ref=spam-detection \ + --evaluations-repo=/path/to/codeforphilly-spam-detection \ --branch=published \ --threshold=0.8 \ --dry-run # drop --dry-run to commit the prune @@ -247,4 +260,4 @@ legit 11,819 (37.6%) uncertain 28 (0.09%) — review backlog ``` -The 28 uncertain are the small set worth eyeballing for tuning the rubric or supplying manual `human:*` overrides. +The 28 uncertain are the small set worth eyeballing for tuning the rubric or supplying manual `human-*` overrides. From 024e7e076240fa0eee3bc4beb8216f391420fbbe Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 18 Sep 2026 15:30:08 -0400 Subject: [PATCH 7/9] feat(web): /admin/members roster with footprint and spam votes Staff-only page: newest signups first, search and vote/sort filters, expandable footprint and vote history, Spam / Not spam buttons with an optional reasoning note. Linked from the user menu for staff. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LFyA5poHwrhAktrnsKrUiQ --- apps/web/src/App.tsx | 3 + apps/web/src/components/AppHeader.tsx | 3 + apps/web/src/lib/api.ts | 79 +++++ apps/web/src/pages/AdminMembers.tsx | 414 ++++++++++++++++++++++++++ 4 files changed, 499 insertions(+) create mode 100644 apps/web/src/pages/AdminMembers.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e794b09..886063a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -35,6 +35,7 @@ import { AccountClaimByPassword } from '@/pages/AccountClaimByPassword'; import { AccountClaimRequestStaffReview } from '@/pages/AccountClaimRequestStaffReview'; import { AccountClaimLegacy } from '@/pages/AccountClaimLegacy'; import { StaffAccountClaimQueue } from '@/pages/StaffAccountClaimQueue'; +import { AdminMembers } from '@/pages/AdminMembers'; const router = createBrowserRouter([ { @@ -74,6 +75,8 @@ const router = createBrowserRouter([ { path: '/account-claim/request-staff-review', element: }, { path: '/account/claim-legacy', element: }, { path: '/staff/account-claim', element: }, + { path: '/admin/members', element: }, + { path: '/admin/members/:slug', element: }, { path: '*', element: }, ], }, diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 2a303e3..f76d939 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -127,6 +127,9 @@ function AuthControls({ mobile = false }: { mobile?: boolean }) { Manage tags + + Members roster + Recent staff actions diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 74174d2..175a875 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -580,6 +580,70 @@ export interface UpdateTagInput { mergeInto?: string; } +// --- Moderation (specs/api/moderation.md) --------------------------------- + +export type VoteVerdict = 'spam' | 'legit'; + +export interface VoteView { + readonly verdict: 'spam' | 'legit' | 'uncertain'; + readonly reasoning: string | null; + readonly voter: { readonly slug: string; readonly fullName: string }; + readonly evaluatedAt: string; +} + +export interface MemberRow { + readonly id: string; + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; + readonly createdAt: string; + readonly deletedAt: string | null; + readonly email: string | null; + readonly hasGitHubLink: boolean; + readonly lastLoginAt: string | null; + readonly bioExcerpt: string; + readonly footprint: { + readonly memberships: number; + readonly updates: number; + readonly buzz: number; + readonly blogPosts: number; + readonly helpWantedInterest: number; + readonly tags: number; + }; + readonly latestVote: VoteView | null; +} + +export interface MemberListParams { + q?: string; + vote?: 'none' | VoteVerdict; + joinedAfter?: string; + joinedBefore?: string; + includeDeactivated?: boolean; + sort?: string; + page?: number; + perPage?: number; +} + +interface ProjectRef { + readonly slug: string; + readonly title: string; +} + +export interface MemberFootprint { + readonly memberships: Array<{ project: ProjectRef; role: string; joinedAt: string }>; + readonly updates: Array<{ project: ProjectRef; number: number; title: string; postedAt: string }>; + readonly buzz: Array<{ project: ProjectRef; slug: string; title: string; postedAt: string }>; + readonly blogPosts: Array<{ slug: string; title: string; postedAt: string }>; + readonly helpWantedInterest: Array<{ project: ProjectRef; role: { title: string }; createdAt: string }>; + readonly tags: Array<{ handle: string; type: string }>; +} + +export interface MemberDetail { + readonly person: PersonDetail; + readonly footprint: MemberFootprint; + readonly votes: VoteView[]; +} + export const api = { preview: (source: string): Promise> => request(`/api/_preview`, { @@ -831,6 +895,21 @@ export const api = { body: JSON.stringify({ claimedSlug, evidence }), }), }, + admin: { + members: (params: MemberListParams = {}): Promise> => + request(`/api/admin/members${buildQuery(params)}`), + member: (slug: string): Promise> => + request(`/api/admin/members/${encodeURIComponent(slug)}`), + vote: ( + slug: string, + verdict: VoteVerdict, + reasoning?: string, + ): Promise> => + request(`/api/admin/members/${encodeURIComponent(slug)}/vote`, { + method: 'POST', + body: JSON.stringify(reasoning ? { verdict, reasoning } : { verdict }), + }), + }, staffAccountClaim: { queue: (): Promise> => request(`/api/staff/account-claim/queue`), diff --git a/apps/web/src/pages/AdminMembers.tsx b/apps/web/src/pages/AdminMembers.tsx new file mode 100644 index 0000000..bd97655 --- /dev/null +++ b/apps/web/src/pages/AdminMembers.tsx @@ -0,0 +1,414 @@ +/** + * /admin/members — staff roster with footprint and human spam votes. + * Per specs/screens/admin-members.md. + */ +import { useEffect, useState, type FormEvent } from 'react'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useAuth } from '@/hooks/useAuth'; +import { api, ApiError, type MemberListParams, type MemberRow, type VoteVerdict, type VoteView } from '@/lib/api'; +import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time'; + +const PER_PAGE = 50; + +function isStaff(level: string | undefined): boolean { + return level === 'staff' || level === 'administrator'; +} + +function VoteBadge({ vote, hiddenByVote }: { vote: VoteView; hiddenByVote: boolean }) { + const spam = vote.verdict === 'spam'; + return ( + + {spam ? 'Spam' : 'Not spam'} · {vote.voter.fullName} · {formatRelativeTime(vote.evaluatedAt)} + {hiddenByVote ? ' · hidden' : ''} + + ); +} + +function VoteButtons({ + slug, + disabled, + onDone, +}: { + slug: string; + disabled: boolean; + onDone: () => Promise; +}) { + const [confirming, setConfirming] = useState(null); + const [reasoning, setReasoning] = useState(''); + const mutation = useMutation({ + mutationFn: ({ verdict, why }: { verdict: VoteVerdict; why: string }) => + api.admin.vote(slug, verdict, why.trim() || undefined), + onSuccess: async (_res, vars) => { + toast.success(vars.verdict === 'spam' ? 'Marked as spam and hidden' : 'Marked as not spam'); + setConfirming(null); + setReasoning(''); + await onDone(); + }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Vote failed'), + }); + + if (confirming) { + const submit = (e: FormEvent) => { + e.preventDefault(); + mutation.mutate({ verdict: confirming, why: reasoning }); + }; + return ( +
+ +