Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions apps/api/src/services/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
8 changes: 8 additions & 0 deletions apps/api/tests/moderation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
44 changes: 42 additions & 2 deletions apps/web/src/pages/AdminMembers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span>{email}</span>;
const local = email.slice(0, at + 1);
const domain = email.slice(at + 1);
return (
<span>
{local}
<button
type="button"
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
title={`Search for every member with an @${domain} address`}
onClick={() => onSearchDomain(domain)}
>
{domain}
</button>
</span>
);
}

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';
Expand Down Expand Up @@ -211,6 +236,7 @@ function MemberRowView({
onPendingHandled,
onToggle,
onChanged,
onSearchDomain,
}: {
row: MemberRow;
selfSlug: string | undefined;
Expand All @@ -220,6 +246,7 @@ function MemberRowView({
onPendingHandled: () => void;
onToggle: () => void;
onChanged: () => Promise<void>;
onSearchDomain: (domain: string) => void;
}) {
const [changing, setChanging] = useState(false);
const ref = useRef<HTMLLIElement>(null);
Expand Down Expand Up @@ -273,7 +300,12 @@ function MemberRowView({
</div>
<div className="mt-1 text-sm text-muted-foreground">
joined {formatRelativeTime(row.createdAt)} · {signIns}
{row.email ? ` · ${row.email}` : ''}
{row.email && (
<>
{' · '}
<EmailWithDomainSearch email={row.email} onSearchDomain={onSearchDomain} />
</>
)}
</div>
{row.bioExcerpt && <p className="mt-1 text-sm">{row.bioExcerpt}</p>}
<div className="mt-1 text-xs text-muted-foreground">{counts.join(' · ')}</div>
Expand Down Expand Up @@ -497,7 +529,14 @@ export function AdminMembers() {
>
<div className="flex flex-col gap-1">
<Label htmlFor="q" className="text-xs">Search</Label>
<Input id="q" name="q" defaultValue={listParams.q ?? ''} placeholder="name, @slug, bio, email" className="w-64" />
<Input
id="q"
name="q"
key={listParams.q ?? ''}
defaultValue={listParams.q ?? ''}
placeholder="name, @slug, bio, email, @domain"
className="w-64"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="vote" className="text-xs">Vote</Label>
Expand Down Expand Up @@ -574,6 +613,7 @@ export function AdminMembers() {
setExpanded(expanded === row.slug ? null : row.slug);
}}
onChanged={refresh}
onSearchDomain={(domain) => setParam('q', `@${domain}`)}
/>
))}
</ul>
Expand Down
43 changes: 43 additions & 0 deletions plans/roster-email-search.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion specs/api/moderation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
4 changes: 3 additions & 1 deletion specs/screens/admin-members.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
Loading