Skip to content

feat(web): People tab on job detail — contacts, evidence links, status chips, one-click draft outreach #293

Description

@Taleef7

Key: #293 · Size: M · Epic: #249 · Program: #243 · Blocked by: #292

Context

apps/web is Next.js App Router + Tailwind + shadcn-style components (Base UI primitives under src/components/ui/) + Clerk. The job detail page apps/web/src/app/(app)/jobs/[jobId]/page.tsx is a server component that renders a Tabs block (Analysis / AI agents / Outreach) and server-fetches auxiliary data with .catch() fallbacks so a failure never breaks the page (see how fetchAgentOutputs(jobId).catch(() => []) is wired). Client interactivity lives in 'use client' panels like apps/web/src/components/job-agents-panel.tsx (loading state per action, sonner toast for errors, lucide-react icons, Badge/Button/Card from @/components/ui). All API calls go through apps/web/src/lib/api.tsrequestJson(path, init) which handles auth (Clerk token server-side, /api/proxy/* in the browser) and throws ApiRequestError with the server's error message. Unit tests are Vitest + Testing Library, colocated as *.test.tsx.

#290/#292 shipped the API: GET /api/jobs/:id/contacts{ contacts: JobContactRecord[] }; POST /api/jobs/:id/scout{ contacts, dropped_unverified, queries_used, model_used } (503 when the agent service is off, 429 on budget); POST /api/contacts/:id/draft-outreach with body { message_type: 'referral_request' | 'linkedin_connection' | 'recruiter_email' }{ subject, draft_text, safety_notes, outreach_id, job_id, contact }; PATCH /api/contacts/:id with { status }. A contact record is { id, jobId, name, roleTitle?, evidence: [{ url, note? }], relevance (0-100), status ('found'|'outreach_drafted'|'contacted'), createdAt, updatedAt }.

This ticket adds the People tab (spec §4.4: "UI: a 'People' tab on job detail listing contacts with evidence links and one-click 'draft outreach'"). Drafted outreach shows up in the existing Outreach tab's "Saved draft" card and is approved/sent through the existing flow — this tab drafts, it never sends.

Scope

  • API client types + functions in apps/web/src/lib/api.ts.
  • New client component apps/web/src/components/job-people-panel.tsx.
  • Wire a People tab into apps/web/src/app/(app)/jobs/[jobId]/page.tsx with server-fetched initial contacts.
  • Vitest coverage for the panel.

Out of scope:

  • Any API/agent changes.
  • Editing contacts (name/role/evidence are agent-verified data; only status is user-mutable).
  • Playwright e2e (the existing e2e suite is owner-run; unit tests cover this panel).
  • Notifications/badges outside the job detail page.

Implementation guide

  1. Add to apps/web/src/lib/api.ts (near the agent-output section, following its style):
export type JobContactStatus = 'found' | 'outreach_drafted' | 'contacted';

export interface ContactEvidenceLink {
  url: string;
  note?: string;
}

export interface JobContact {
  id: string;
  jobId: string;
  name: string;
  roleTitle?: string;
  /** Always non-empty: the API drops contacts without public evidence (fail closed). */
  evidence: ContactEvidenceLink[];
  relevance: number;
  status: JobContactStatus;
  createdAt: string;
  updatedAt: string;
}

export type ContactMessageType = 'referral_request' | 'linkedin_connection' | 'recruiter_email';

export interface ScoutRunResponse {
  contacts: JobContact[];
  dropped_unverified: number;
  queries_used: string[];
  model_used: string;
}

export interface ContactOutreachResponse {
  subject: string;
  draft_text: string;
  safety_notes: string;
  outreach_id: string;
  job_id: string;
  contact: JobContact;
}

/** The verified contacts for a job — `GET /api/jobs/:id/contacts`. */
export async function fetchJobContacts(jobId: string): Promise<JobContact[]> {
  const response = await requestJson<{ contacts: JobContact[] }>(
    `/api/jobs/${jobId}/contacts`,
    { cache: 'no-store' },
  );
  return response.contacts;
}

/** Run the connection scout for a job — `POST /api/jobs/:id/scout`. */
export async function scoutConnections(jobId: string): Promise<ScoutRunResponse> {
  return requestJson<ScoutRunResponse>(`/api/jobs/${jobId}/scout`, {
    method: 'POST',
    body: '{}',
  });
}

/** Draft outreach to a scouted contact — `POST /api/contacts/:id/draft-outreach`. */
export async function draftContactOutreach(
  contactId: string,
  messageType: ContactMessageType,
): Promise<ContactOutreachResponse> {
  return requestJson<ContactOutreachResponse>(`/api/contacts/${contactId}/draft-outreach`, {
    method: 'POST',
    body: JSON.stringify({ message_type: messageType }),
  });
}

/** Update a contact's status — `PATCH /api/contacts/:id`. */
export async function updateContactStatus(
  contactId: string,
  status: JobContactStatus,
): Promise<JobContact> {
  const response = await requestJson<{ contact: JobContact }>(`/api/contacts/${contactId}`, {
    method: 'PATCH',
    body: JSON.stringify({ status }),
  });
  return response.contact;
}
  1. Create apps/web/src/components/job-people-panel.tsx. Structure (follow job-agents-panel.tsx conventions — 'use client', per-action useState loading flags, toast.error((error as ApiRequestError).message) on failure, Loader2 spinner class animate-spin):
'use client';

import { ExternalLink, Loader2, UserSearch } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
  ApiRequestError,
  draftContactOutreach,
  scoutConnections,
  updateContactStatus,
  type ContactMessageType,
  type JobContact,
  type JobContactStatus,
} from '@/lib/api';

const STATUS_LABEL: Record<JobContactStatus, string> = {
  found: 'Found',
  outreach_drafted: 'Outreach drafted',
  contacted: 'Contacted',
};

const MESSAGE_TYPES: Array<{ type: ContactMessageType; label: string }> = [
  { type: 'referral_request', label: 'Referral request' },
  { type: 'linkedin_connection', label: 'LinkedIn note' },
  { type: 'recruiter_email', label: 'Recruiter email' },
];

function hostOf(url: string): string {
  try {
    return new URL(url).hostname.replace(/^www\./, '');
  } catch {
    return url;
  }
}

export function JobPeoplePanel({
  jobId,
  initialContacts,
}: {
  jobId: string;
  initialContacts: JobContact[];
}) {
  const [contacts, setContacts] = useState<JobContact[]>(initialContacts);
  const [scouting, setScouting] = useState(false);
  const [droppedNote, setDroppedNote] = useState<number | null>(null);
  const [busyContactId, setBusyContactId] = useState<string | null>(null);

  async function handleScout() { /* setScouting; await scoutConnections(jobId);
    setContacts(result.contacts); setDroppedNote(result.dropped_unverified);
    toast.success(`Found ${result.contacts.length} people`); catch -> toast.error(message);
    finally setScouting(false) */ }

  async function handleDraft(contact: JobContact, messageType: ContactMessageType) {
    /* setBusyContactId(contact.id); await draftContactOutreach(contact.id, messageType);
       replace the contact in state with response.contact;
       toast.success('Draft saved — review it in the Outreach tab');
       catch -> toast.error; finally setBusyContactId(null) */
  }

  async function handleMarkContacted(contact: JobContact) {
    /* updateContactStatus(contact.id, 'contacted'); replace in state; toast on error */
  }

  // Render:
  // - Header row: title 'People' explainer 'Recruiters, hiring managers and likely
  //   teammates found on the public web. Every person carries a public source link.'
  //   + Button onClick={handleScout} disabled={scouting}:
  //   {scouting ? <Loader2 className="size-4 animate-spin" /> : <UserSearch className="size-4" />}
  //   {contacts.length ? 'Re-scout' : 'Scout connections'}
  // - droppedNote !== null && droppedNote > 0: muted line
  //   `${droppedNote} unverifiable candidate(s) were dropped — only people with public evidence are shown.`
  // - Empty state (no contacts, not scouting): muted paragraph 'No people found yet.
  //   Run the scout to search the public web for this company's recruiters and team.'
  // - Contact list: for each contact a bordered row/card with
  //   * name (font-medium) + roleTitle (text-muted-foreground text-sm)
  //   * <Badge variant="secondary">{contact.relevance}% match</Badge>
  //   * <Badge variant="outline">{STATUS_LABEL[contact.status]}</Badge>
  //   * evidence links: contact.evidence.map(e =>
  //       <a key={e.url} href={e.url} target="_blank" rel="noopener noreferrer"
  //          className="text-primary inline-flex items-center gap-1 text-xs underline underline-offset-2">
  //         {hostOf(e.url)} <ExternalLink className="size-3" aria-hidden />
  //       </a>)
  //   * actions: three small outline Buttons from MESSAGE_TYPES calling
  //     handleDraft(contact, type), disabled while busyContactId === contact.id,
  //     plus a ghost 'Mark contacted' Button when status !== 'contacted'.
}

Implement the commented pseudocode as real code — the handlers are fully specified above; there is no additional behavior to invent. Every evidence anchor must have target="_blank" rel="noopener noreferrer".

  1. Wire the tab into apps/web/src/app/(app)/jobs/[jobId]/page.tsx:
  • Import: import { JobPeoplePanel } from '@/components/job-people-panel'; and add fetchJobContacts to the existing @/lib/api import.
  • Extend the parallel fetch (contacts are not load-bearing, same as agent outputs):
const [{ job, source }, profile, agentOutputs, contacts] = await Promise.all([
  loadJob(jobId),
  fetchProfile().catch(() => null),
  fetchAgentOutputs(jobId).catch(() => []),
  fetchJobContacts(jobId).catch(() => []),
]);
  • Add the trigger after Outreach: <TabsTrigger value="people">People</TabsTrigger> and the content:
<TabsContent value="people">
  <Card className="p-5">
    <JobPeoplePanel jobId={job.id} initialContacts={contacts} />
  </Card>
</TabsContent>

Testing

apps/web/src/components/job-people-panel.test.tsx (Vitest + Testing Library; mock the API module the way job-agents-panel.test.tsx and assistant-panel.test.tsx mock @/lib/api — copy their vi.mock setup):

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { JobPeoplePanel } from './job-people-panel';
import * as api from '@/lib/api';

vi.mock('@/lib/api', async (importOriginal) => ({
  ...(await importOriginal<typeof import('@/lib/api')>()),
  scoutConnections: vi.fn(),
  draftContactOutreach: vi.fn(),
  updateContactStatus: vi.fn(),
}));

const contact: api.JobContact = {
  id: 'c1', jobId: 'j1', name: 'Jane Doe', roleTitle: 'Technical Recruiter',
  evidence: [{ url: 'https://acme.com/team', note: 'returned by web search' }],
  relevance: 85, status: 'found',
  createdAt: '2026-08-12T00:00:00Z', updatedAt: '2026-08-12T00:00:00Z',
};

describe('JobPeoplePanel', () => {
  beforeEach(() => vi.clearAllMocks());

  it('renders the empty state with a scout button', () => {
    render(<JobPeoplePanel jobId="j1" initialContacts={[]} />);
    expect(screen.getByRole('button', { name: /scout connections/i })).toBeInTheDocument();
    expect(screen.getByText(/no people found yet/i)).toBeInTheDocument();
  });

  it('renders a contact with evidence link, relevance and status chip', () => {
    render(<JobPeoplePanel jobId="j1" initialContacts={[contact]} />);
    expect(screen.getByText('Jane Doe')).toBeInTheDocument();
    expect(screen.getByText('85% match')).toBeInTheDocument();
    expect(screen.getByText('Found')).toBeInTheDocument();
    const link = screen.getByRole('link', { name: /acme\.com/i });
    expect(link).toHaveAttribute('href', 'https://acme.com/team');
    expect(link).toHaveAttribute('rel', 'noopener noreferrer');
    expect(link).toHaveAttribute('target', '_blank');
  });

  it('scouts and shows returned contacts plus the dropped note', async () => {
    vi.mocked(api.scoutConnections).mockResolvedValue({
      contacts: [contact], dropped_unverified: 2, queries_used: ['q'], model_used: 'm',
    });
    render(<JobPeoplePanel jobId="j1" initialContacts={[]} />);
    await userEvent.click(screen.getByRole('button', { name: /scout connections/i }));
    await waitFor(() => expect(screen.getByText('Jane Doe')).toBeInTheDocument());
    expect(screen.getByText(/2 unverifiable candidate/i)).toBeInTheDocument();
  });

  it('drafts outreach and updates the status chip', async () => {
    vi.mocked(api.draftContactOutreach).mockResolvedValue({
      subject: 'S', draft_text: 'D', safety_notes: '', outreach_id: 'o1', job_id: 'j1',
      contact: { ...contact, status: 'outreach_drafted' },
    });
    render(<JobPeoplePanel jobId="j1" initialContacts={[contact]} />);
    await userEvent.click(screen.getByRole('button', { name: /referral request/i }));
    await waitFor(() =>
      expect(api.draftContactOutreach).toHaveBeenCalledWith('c1', 'referral_request'));
    expect(await screen.findByText('Outreach drafted')).toBeInTheDocument();
  });

  it('surfaces API errors as toasts without crashing', async () => {
    vi.mocked(api.scoutConnections).mockRejectedValue(
      new api.ApiRequestError('Daily AI budget reached', 429));
    render(<JobPeoplePanel jobId="j1" initialContacts={[]} />);
    await userEvent.click(screen.getByRole('button', { name: /scout connections/i }));
    await waitFor(() =>
      expect(screen.getByRole('button', { name: /scout connections/i })).toBeEnabled());
  });
});

Run (from apps/web/): npm test (Vitest) → new suite passes; npm run typecheck; npm run lint; npm run build compiles. Manual: npm run dev, open a job, confirm the People tab renders, empty state → scout (with the API running) → contacts with working evidence links → draft → the draft appears under the Outreach tab's "Saved draft" card.

Acceptance criteria

  • Job detail shows a fourth tab People; a contacts-fetch failure does not break the page (.catch(() => [])).
  • Contacts render name, role, NN% match relevance badge, status chip (Found / Outreach drafted / Contacted), and every evidence URL as an external link with target="_blank" + rel="noopener noreferrer".
  • "Scout connections" runs the scout with a spinner, replaces the list with the response, and shows the dropped_unverified count when > 0 (the fail-closed gate is visible, not hidden).
  • One-click draft buttons (Referral request / LinkedIn note / Recruiter email) call POST /api/contacts/:id/draft-outreach, flip the chip to Outreach drafted, and direct the user to the Outreach tab for review — the panel contains no send action of any kind.
  • "Mark contacted" calls PATCH /api/contacts/:id and updates the chip.
  • API errors (429 budget, 503 agent disabled) surface as toasts with the server message; buttons re-enable.
  • npm test, npm run typecheck, npm run lint, npm run build green in apps/web; CI green.

Dependencies

Blocked by: #292 (endpoints), and transitively #290. Unblocks: nothing (epic leaf). Completes Epic 6's user-facing surface.

Invariants

From spec §12 (non-negotiable):

  • "1. Nothing auto-sends. Ever." — this tab drafts only; approval and sending stay in the existing Outreach flow.
  • "2. Fail closed" — the UI shows the dropped-unverified count instead of hiding it; only evidence-backed contacts are ever displayed.
    From spec §4.4: "UI: a 'People' tab on job detail listing contacts with evidence links and one-click 'draft outreach'. No LinkedIn scraping or automation; nothing sends without approval (existing outreach invariant unchanged)."

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestjobright-parityJobright-parity program (2026-08 spec)parity-6-scoutParity epic 6: connection scout

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions