Skip to content

Commit 0aa4b8f

Browse files
Merge pull request #192 from CodeForPhilly/develop
Release: v0.5.1
2 parents 322f5e2 + 1e6cbeb commit 0aa4b8f

6 files changed

Lines changed: 101 additions & 9 deletions

File tree

‎apps/api/src/services/moderation.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,6 @@ export class ModerationService {
321321

322322
const includeDeactivated = opts.includeDeactivated ?? true;
323323
const q = opts.q?.trim().toLowerCase() ?? '';
324-
const emailSearch = q.includes('@');
325324

326325
let people = [...this.#state.people.values()];
327326
if (!includeDeactivated) people = people.filter((p) => !p.deletedAt);
@@ -356,10 +355,10 @@ export class ModerationService {
356355
matched.push(p);
357356
continue;
358357
}
359-
if (emailSearch) {
360-
const email = await emailOf(p);
361-
if (email && email.toLowerCase().includes(q)) matched.push(p);
362-
}
358+
// Email is searched for any query, not just ones with an "@" — a bare
359+
// domain or local-part substring is the common case when triaging.
360+
const email = await emailOf(p);
361+
if (email && email.toLowerCase().includes(q)) matched.push(p);
363362
}
364363
people = matched;
365364
}

‎apps/api/tests/moderation.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,14 @@ describe('moderation API', () => {
217217
expect(res.statusCode).toBe(422);
218218
});
219219

220+
it('search matches an email substring without an @, across the whole roster', async () => {
221+
const res = await app.inject({ method: 'GET', url: '/api/admin/members?q=regular%40example', ...asStaff(staffCookie) });
222+
expect(res.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug)).toEqual(['regular']);
223+
const bare = await app.inject({ method: 'GET', url: '/api/admin/members?q=example.org', ...asStaff(staffCookie) });
224+
const slugs = bare.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug).sort();
225+
expect(slugs).toEqual(['newest', 'regular']);
226+
});
227+
220228
it('rejects an unknown sort key', async () => {
221229
const res = await app.inject({ method: 'GET', url: '/api/admin/members?sort=bogus', ...asStaff(staffCookie) });
222230
expect(res.statusCode).toBe(422);

‎apps/web/src/pages/AdminMembers.tsx‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ function Badges({ row }: { row: MemberRow }) {
9393
);
9494
}
9595

96+
/**
97+
* The email with its domain as a button that searches `@domain` across the
98+
* whole roster — the quickest way to find every account from a throwaway
99+
* domain once one turns up.
100+
*/
101+
function EmailWithDomainSearch({ email, onSearchDomain }: { email: string; onSearchDomain: (domain: string) => void }) {
102+
const at = email.lastIndexOf('@');
103+
if (at === -1) return <span>{email}</span>;
104+
const local = email.slice(0, at + 1);
105+
const domain = email.slice(at + 1);
106+
return (
107+
<span>
108+
{local}
109+
<button
110+
type="button"
111+
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
112+
title={`Search for every member with an @${domain} address`}
113+
onClick={() => onSearchDomain(domain)}
114+
>
115+
{domain}
116+
</button>
117+
</span>
118+
);
119+
}
120+
96121
function rowTint(row: MemberRow): string {
97122
const critical = row.signals.some((s) => s === 'github-gone' || s.startsWith('email-bounced'));
98123
if (critical || row.attention >= 5) return 'border-l-4 border-l-destructive';
@@ -211,6 +236,7 @@ function MemberRowView({
211236
onPendingHandled,
212237
onToggle,
213238
onChanged,
239+
onSearchDomain,
214240
}: {
215241
row: MemberRow;
216242
selfSlug: string | undefined;
@@ -220,6 +246,7 @@ function MemberRowView({
220246
onPendingHandled: () => void;
221247
onToggle: () => void;
222248
onChanged: () => Promise<void>;
249+
onSearchDomain: (domain: string) => void;
223250
}) {
224251
const [changing, setChanging] = useState(false);
225252
const ref = useRef<HTMLLIElement>(null);
@@ -273,7 +300,12 @@ function MemberRowView({
273300
</div>
274301
<div className="mt-1 text-sm text-muted-foreground">
275302
joined {formatRelativeTime(row.createdAt)} · {signIns}
276-
{row.email ? ` · ${row.email}` : ''}
303+
{row.email && (
304+
<>
305+
{' · '}
306+
<EmailWithDomainSearch email={row.email} onSearchDomain={onSearchDomain} />
307+
</>
308+
)}
277309
</div>
278310
{row.bioExcerpt && <p className="mt-1 text-sm">{row.bioExcerpt}</p>}
279311
<div className="mt-1 text-xs text-muted-foreground">{counts.join(' · ')}</div>
@@ -497,7 +529,14 @@ export function AdminMembers() {
497529
>
498530
<div className="flex flex-col gap-1">
499531
<Label htmlFor="q" className="text-xs">Search</Label>
500-
<Input id="q" name="q" defaultValue={listParams.q ?? ''} placeholder="name, @slug, bio, email" className="w-64" />
532+
<Input
533+
id="q"
534+
name="q"
535+
key={listParams.q ?? ''}
536+
defaultValue={listParams.q ?? ''}
537+
placeholder="name, @slug, bio, email, @domain"
538+
className="w-64"
539+
/>
501540
</div>
502541
<div className="flex flex-col gap-1">
503542
<Label htmlFor="vote" className="text-xs">Vote</Label>
@@ -574,6 +613,7 @@ export function AdminMembers() {
574613
setExpanded(expanded === row.slug ? null : row.slug);
575614
}}
576615
onChanged={refresh}
616+
onSearchDomain={(domain) => setParam('q', `@${domain}`)}
577617
/>
578618
))}
579619
</ul>

‎plans/roster-email-search.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
status: done
3+
depends: [roster-signals]
4+
specs:
5+
- specs/api/moderation.md
6+
- specs/screens/admin-members.md
7+
issues: []
8+
pr: 191
9+
---
10+
11+
# Plan: roster search covers email; domain pivot
12+
13+
## Scope
14+
15+
Two things found on the first day of triage: searching for an email
16+
substring did nothing unless the query contained an `@` (an accidental gate
17+
in `listMembers`), and once one throwaway domain turns up staff want every
18+
other account from it in one click.
19+
20+
In: search email for every query; the email's domain on each row is a button
21+
that searches `@domain` across the whole roster. Out: a domain histogram.
22+
23+
## Implements
24+
25+
- [api/moderation.md](../specs/api/moderation.md) — `q` matches email for
26+
any query and applies to the whole roster before pagination.
27+
- [screens/admin-members.md](../specs/screens/admin-members.md) — the domain
28+
button.
29+
30+
## Approach
31+
32+
Drop the `@` gate in `ModerationService.listMembers`; `EmailWithDomainSearch`
33+
in the page sets `q=@domain`; the search input remounts on `q` so the URL
34+
drives it.
35+
36+
## Validation
37+
38+
- API test: `q=example.org` returns both seeded members with that domain.
39+
- `type-check` + `lint`; moderation suite green.
40+
41+
## Follow-ups
42+
43+
None.

‎specs/api/moderation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ a signal.
2121

2222
| Param | Type | Notes |
2323
| ----- | ---- | ----- |
24-
| `q` | string | Full-text on `fullName`, `slug`, `bio`, and (staff-visible) `email`. |
24+
| `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. |
2525
| `vote` | enum | `none` (no human vote yet) \| `spam` \| `legit`. Filters on the **latest** human vote. |
2626
| `origin` | enum | `imported` (has a laddr `legacyId`) \| `signed-up` (created on this site through GitHub). |
2727
| `joinedAfter`, `joinedBefore` | ISO date | Inclusive bounds on `createdAt`. |

‎specs/screens/admin-members.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ See [api/moderation.md](../api/moderation.md).
3636
what it hid).
3737
- Each row: avatar, `fullName` (link to the public profile, opens in a new tab),
3838
`@slug`, then the **badges**, then "joined {createdAt relative}", sign-ins
39-
(`signed in 4× · last 2h ago` or `never signed in`), email (staff-visible),
39+
(`signed in 4× · last 2h ago` or `never signed in`), email (staff-visible;
40+
the domain is a button that searches `@domain` across the whole roster, so
41+
one throwaway address leads to every account from that domain),
4042
bio excerpt, compact footprint counts (`2 projects · 1 update · 3 tags`), and
4143
the vote state:
4244
- no vote → two buttons **Spam** / **Not spam**

0 commit comments

Comments
 (0)