diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts index 971ad5d..84b1d98 100644 --- a/apps/api/src/services/moderation.ts +++ b/apps/api/src/services/moderation.ts @@ -321,7 +321,6 @@ export class ModerationService { 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); @@ -356,10 +355,10 @@ export class ModerationService { matched.push(p); continue; } - if (emailSearch) { - const email = await emailOf(p); - if (email && email.toLowerCase().includes(q)) matched.push(p); - } + // Email is searched for any query, not just ones with an "@" — a bare + // domain or local-part substring is the common case when triaging. + const email = await emailOf(p); + if (email && email.toLowerCase().includes(q)) matched.push(p); } people = matched; } diff --git a/apps/api/tests/moderation.test.ts b/apps/api/tests/moderation.test.ts index 111151f..b9b9f1a 100644 --- a/apps/api/tests/moderation.test.ts +++ b/apps/api/tests/moderation.test.ts @@ -217,6 +217,14 @@ describe('moderation API', () => { expect(res.statusCode).toBe(422); }); + it('search matches an email substring without an @, across the whole roster', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/members?q=regular%40example', ...asStaff(staffCookie) }); + expect(res.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug)).toEqual(['regular']); + const bare = await app.inject({ method: 'GET', url: '/api/admin/members?q=example.org', ...asStaff(staffCookie) }); + const slugs = bare.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug).sort(); + expect(slugs).toEqual(['newest', 'regular']); + }); + 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); diff --git a/apps/web/src/pages/AdminMembers.tsx b/apps/web/src/pages/AdminMembers.tsx index 47ba317..0a04232 100644 --- a/apps/web/src/pages/AdminMembers.tsx +++ b/apps/web/src/pages/AdminMembers.tsx @@ -93,6 +93,31 @@ function Badges({ row }: { row: MemberRow }) { ); } +/** + * The email with its domain as a button that searches `@domain` across the + * whole roster — the quickest way to find every account from a throwaway + * domain once one turns up. + */ +function EmailWithDomainSearch({ email, onSearchDomain }: { email: string; onSearchDomain: (domain: string) => void }) { + const at = email.lastIndexOf('@'); + if (at === -1) return {email}; + const local = email.slice(0, at + 1); + const domain = email.slice(at + 1); + return ( + + {local} + + + ); +} + function rowTint(row: MemberRow): string { const critical = row.signals.some((s) => s === 'github-gone' || s.startsWith('email-bounced')); if (critical || row.attention >= 5) return 'border-l-4 border-l-destructive'; @@ -211,6 +236,7 @@ function MemberRowView({ onPendingHandled, onToggle, onChanged, + onSearchDomain, }: { row: MemberRow; selfSlug: string | undefined; @@ -220,6 +246,7 @@ function MemberRowView({ onPendingHandled: () => void; onToggle: () => void; onChanged: () => Promise; + onSearchDomain: (domain: string) => void; }) { const [changing, setChanging] = useState(false); const ref = useRef(null); @@ -273,7 +300,12 @@ function MemberRowView({
joined {formatRelativeTime(row.createdAt)} · {signIns} - {row.email ? ` · ${row.email}` : ''} + {row.email && ( + <> + {' · '} + + + )}
{row.bioExcerpt &&

{row.bioExcerpt}

}
{counts.join(' · ')}
@@ -497,7 +529,14 @@ export function AdminMembers() { >
- +
@@ -574,6 +613,7 @@ export function AdminMembers() { setExpanded(expanded === row.slug ? null : row.slug); }} onChanged={refresh} + onSearchDomain={(domain) => setParam('q', `@${domain}`)} /> ))} diff --git a/plans/roster-email-search.md b/plans/roster-email-search.md new file mode 100644 index 0000000..a8e215b --- /dev/null +++ b/plans/roster-email-search.md @@ -0,0 +1,43 @@ +--- +status: done +depends: [roster-signals] +specs: + - specs/api/moderation.md + - specs/screens/admin-members.md +issues: [] +pr: 191 +--- + +# Plan: roster search covers email; domain pivot + +## Scope + +Two things found on the first day of triage: searching for an email +substring did nothing unless the query contained an `@` (an accidental gate +in `listMembers`), and once one throwaway domain turns up staff want every +other account from it in one click. + +In: search email for every query; the email's domain on each row is a button +that searches `@domain` across the whole roster. Out: a domain histogram. + +## Implements + +- [api/moderation.md](../specs/api/moderation.md) — `q` matches email for + any query and applies to the whole roster before pagination. +- [screens/admin-members.md](../specs/screens/admin-members.md) — the domain + button. + +## Approach + +Drop the `@` gate in `ModerationService.listMembers`; `EmailWithDomainSearch` +in the page sets `q=@domain`; the search input remounts on `q` so the URL +drives it. + +## Validation + +- API test: `q=example.org` returns both seeded members with that domain. +- `type-check` + `lint`; moderation suite green. + +## Follow-ups + +None. diff --git a/specs/api/moderation.md b/specs/api/moderation.md index 43c44eb..780792e 100644 --- a/specs/api/moderation.md +++ b/specs/api/moderation.md @@ -21,7 +21,7 @@ a signal. | Param | Type | Notes | | ----- | ---- | ----- | -| `q` | string | Full-text on `fullName`, `slug`, `bio`, and (staff-visible) `email`. | +| `q` | string | Substring match on `fullName`, `slug`, `bio`, and (staff-visible) `email` — searched for every query, so `.com` finds every member on that domain. Applies to the whole roster, then paginates. | | `vote` | enum | `none` (no human vote yet) \| `spam` \| `legit`. Filters on the **latest** human vote. | | `origin` | enum | `imported` (has a laddr `legacyId`) \| `signed-up` (created on this site through GitHub). | | `joinedAfter`, `joinedBefore` | ISO date | Inclusive bounds on `createdAt`. | diff --git a/specs/screens/admin-members.md b/specs/screens/admin-members.md index 8e06c2a..b1c4bd4 100644 --- a/specs/screens/admin-members.md +++ b/specs/screens/admin-members.md @@ -36,7 +36,9 @@ See [api/moderation.md](../api/moderation.md). what it hid). - Each row: avatar, `fullName` (link to the public profile, opens in a new tab), `@slug`, then the **badges**, then "joined {createdAt relative}", sign-ins - (`signed in 4× · last 2h ago` or `never signed in`), email (staff-visible), + (`signed in 4× · last 2h ago` or `never signed in`), email (staff-visible; + the domain is a button that searches `@domain` across the whole roster, so + one throwaway address leads to every account from that domain), bio excerpt, compact footprint counts (`2 projects · 1 update · 3 tags`), and the vote state: - no vote → two buttons **Spam** / **Not spam**