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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ APOLLO_API_KEY=
PDL_API_KEY=

GITHUB_TOKEN=

# Finds a lead's photo (and their LinkedIn profile URL, as research) by
# searching their name, title and company. One image search per person,
# roughly a quarter of a cent each. Unset, only Gravatar supplies pictures.
VALUESERP_API_KEY=
# Ceiling on photo lookups per workspace per day. Default 300.
PHOTO_LOOKUPS_PER_DAY=

BLUESKY_IDENTIFIER=
BLUESKY_APP_PASSWORD=
X_API_KEY=
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1683,7 +1683,8 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
const limit = clampLimit(c.req.query('limit'));

const rows = await c.get('db').execute({
sql: `SELECT p.id, p.display_name, p.current_title, p.identity_confidence, p.status,
sql: `SELECT p.id, p.display_name, p.current_title, p.avatar_url, p.identity_confidence,
p.status,
co.name AS current_company,
cp.status AS prospect_status, cp.interaction_state,
s.opportunity, s.icp_fit, s.intent, s.reachability,
Expand Down
25 changes: 20 additions & 5 deletions apps/api/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface PersonRow {
current_title: string | null;
current_company_id: string | null;
location: string | null;
avatar_url?: string | null;
identity_confidence: number;
status: string;
outreach_eligible: number;
Expand Down Expand Up @@ -190,7 +191,7 @@ export async function listPendingRecommendations(

return queryAll(
db,
`SELECT r.*, p.display_name, p.current_title, p.identity_confidence,
`SELECT r.*, p.display_name, p.current_title, p.avatar_url, p.identity_confidence,
s.summary AS signal_summary, s.source_url AS signal_url,
s.source_timestamp AS signal_at,
d.body AS draft_body, d.subject AS draft_subject,
Expand Down Expand Up @@ -475,6 +476,15 @@ export async function resolveContactAddress(
);
if (personal?.handle) return { address: personal.handle.trim().toLowerCase(), shared: false };

// An imported address is the person's own too: they gave it to us, with
// consent recorded on the import. Personal, not shared.
const imported = await queryOne<{ address: string }>(
db,
`SELECT address FROM person_emails WHERE person_id = ? ORDER BY created_at LIMIT 1`,
[personId],
);
if (imported?.address) return { address: imported.address.trim().toLowerCase(), shared: false };

const company = await queryOne<{ contact_email: string }>(
db,
`SELECT co.contact_email FROM people p
Expand Down Expand Up @@ -578,10 +588,15 @@ export async function pendingAddressUsage(
`WITH candidate AS (
SELECT r.id AS recommendation_id, r.campaign_id AS campaign_id,
EXISTS (SELECT 1 FROM drafts d WHERE d.recommendation_id = r.id) AS has_draft,
(SELECT lower(trim(si.handle)) FROM social_identities si
WHERE si.person_id = r.person_id AND si.network = 'email'
AND si.handle IS NOT NULL AND trim(si.handle) <> ''
ORDER BY si.confidence DESC LIMIT 1) AS personal,
COALESCE(
(SELECT lower(trim(si.handle)) FROM social_identities si
WHERE si.person_id = r.person_id AND si.network = 'email'
AND si.handle IS NOT NULL AND trim(si.handle) <> ''
ORDER BY si.confidence DESC LIMIT 1),
(SELECT lower(trim(pe.address)) FROM person_emails pe
WHERE pe.person_id = r.person_id AND pe.workspace_id = r.workspace_id
ORDER BY pe.created_at LIMIT 1)
) AS personal,
(SELECT lower(trim(co.contact_email)) FROM people p
JOIN companies co ON co.id = p.current_company_id
WHERE p.id = r.person_id AND co.contact_email IS NOT NULL
Expand Down
55 changes: 55 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ import {
pruneWorkflowEvents,
regenerateRecommendations,
rescoreProspect,
reseedIdleCampaigns,
runAutopilot,
sweepProfilePhotos,
workspacesAwaitingPhotos,
runCadences,
runCrawlJob,
runDiscoveryJob,
Expand All @@ -67,6 +70,7 @@ import {
RedditSource,
RssSource,
SiteProvider,
ValueSerpClient,
type FeedSource,
} from '@outreachgraph/providers';

Expand Down Expand Up @@ -237,6 +241,21 @@ if (!encryptionKey) {
console.log('no SECRET_ENCRYPTION_KEY: workspaces cannot connect their own sending mailbox');
}

/**
* Pictures of leads, by search.
*
* Optional, like every provider: unset, Gravatar is the only source and most
* people stay as initials. Each lookup is a paid request, so the sweep is
* capped per workspace per day as well as per tick.
*/
const photoFinder = process.env.VALUESERP_API_KEY
? new ValueSerpClient({ apiKey: process.env.VALUESERP_API_KEY })
: undefined;

const photoLookupsPerDay = Number(process.env.PHOTO_LOOKUPS_PER_DAY ?? 300);

if (!photoFinder) console.log('no VALUESERP_API_KEY: lead photos come from Gravatar only');

/**
* The feed clients for one campaign's own targets.
*
Expand Down Expand Up @@ -682,6 +701,42 @@ async function tick(): Promise<void> {
}
}

// A face for the people about to be written to. Bounded per tick and per
// day inside the sweep; this loop only decides who is looked at.
if (photoFinder) {
for (const workspaceId of await workspacesAwaitingPhotos(db)) {
try {
const swept = await sweepProfilePhotos(
{ db, finder: photoFinder, dailyCap: photoLookupsPerDay },
{ workspaceId },
);

if (swept.looked > 0) {
console.log(
`photos: looked up ${swept.looked} in ${workspaceId}, ${swept.found} found, ` +
`${swept.profiles} LinkedIn profile(s) recorded, ${swept.remainingToday} left today`,
);
}
} catch (error) {
console.error(`photo sweep failed for ${workspaceId}`, error);
}
}
}

// Campaigns whose seed has not been read in a week are asked again. This is
// the only thing that puts work back into an idle queue: once the first crop
// of research cards is cleared, nothing else ever asks the seed for more.
for (const workspace of workspaces) {
try {
const reseeded = await reseedIdleCampaigns(db, { workspaceId: workspace.id });
if (reseeded.queued > 0) {
console.log(`reseeded ${reseeded.queued} idle campaign(s) in ${workspace.id}`);
}
} catch (error) {
console.error(`reseed failed for ${workspace.id}`, error);
}
}

// The queue drains before the send sweep, so anything discovered this tick
// can go out on the same tick rather than waiting for the next one.
const drained = await drainQueue(db, runJob, { isOutage: isBudgetExhausted });
Expand Down
16 changes: 10 additions & 6 deletions apps/web/app/(app)/prospects/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { Avatar } from '../../../../components/avatar';
import { EmailCandidates } from '../../../../components/email-candidates';
import { EnrolButton } from '../../../../components/enrol-button';
import { PageGuide } from '../../../../components/page-guide';
Expand Down Expand Up @@ -65,12 +66,15 @@ export default async function ProspectPage({ params }: { params: Promise<{ id: s
← Prospects
</Link>

<header className="mt-3">
<h1 className="text-xl font-semibold">{person.display_name}</h1>
<p className="text-ink-muted text-sm">{person.current_title ?? '—'}</p>
<p className="text-ink-muted mt-1 text-xs">
Identity confidence {Math.round((person.identity_confidence ?? 0) * 100)}%
</p>
<header className="mt-3 flex items-center gap-4">
<Avatar name={person.display_name} src={person.avatar_url} size="lg" />
<div className="min-w-0">
<h1 className="text-xl font-semibold">{person.display_name}</h1>
<p className="text-ink-muted text-sm">{person.current_title ?? '—'}</p>
<p className="text-ink-muted mt-1 text-xs">
Identity confidence {Math.round((person.identity_confidence ?? 0) * 100)}%
</p>
</div>
</header>

<div className="mt-4">
Expand Down
19 changes: 12 additions & 7 deletions apps/web/app/(app)/prospects/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Avatar } from '../../../components/avatar';
import { PageGuide } from '../../../components/page-guide';
import { ApiUnavailableError, NotAuthenticatedError, fetchProspects } from '../../../lib/api';
import type { ProspectRow } from '../../../lib/types';
Expand Down Expand Up @@ -75,15 +76,19 @@ function ProspectItem({ person }: { person: ProspectRow }) {
href={`/prospects/${person.id}`}
className="border-border bg-surface-raised block rounded-2xl border p-4"
>
<div className="flex items-baseline justify-between gap-3">
<span className="truncate font-semibold">{person.display_name}</span>
<span className="text-accent shrink-0 font-semibold tabular-nums">
{person.opportunity ?? '—'}
</span>
<div className="flex items-center gap-3">
<Avatar name={person.display_name} src={person.avatar_url} size="md" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate font-semibold">{person.display_name}</span>
<span className="text-accent shrink-0 font-semibold tabular-nums">
{person.opportunity ?? '—'}
</span>
</div>
<p className="text-ink-muted mt-1 truncate text-sm">{subtitle || '—'}</p>
</div>
</div>

<p className="text-ink-muted mt-1 truncate text-sm">{subtitle || '—'}</p>

<dl className="text-ink-muted mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<div className="flex gap-1">
<dt>Signals</dt>
Expand Down
10 changes: 7 additions & 3 deletions apps/web/components/approval-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useState } from 'react';
// Imported from the server-safe modules, not lib/api: that pulls in
// next/headers and cannot be bundled for the browser.
import { relativeTime } from '../lib/format';
import { Avatar } from './avatar';
import { ShareButtons } from './share-buttons';
import type { ApprovalCard as Card } from '../lib/types';

Expand Down Expand Up @@ -166,9 +167,12 @@ export function ApprovalCard({ card }: { card: Card }) {
return (
<article className="border-border bg-surface-raised rounded-2xl border p-4">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold">{card.display_name}</h2>
<p className="text-ink-muted truncate text-sm">{card.current_title ?? '—'}</p>
<div className="flex min-w-0 items-center gap-3">
<Avatar name={card.display_name} src={card.avatar_url} size="md" />
<div className="min-w-0">
<h2 className="truncate text-base font-semibold">{card.display_name}</h2>
<p className="text-ink-muted truncate text-sm">{card.current_title ?? '—'}</p>
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-accent text-lg leading-none font-semibold tabular-nums">
Expand Down
61 changes: 61 additions & 0 deletions apps/web/components/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* A face, or the initials standing in for one.
*
* The picture is a URL the person published — Gravatar, their LinkedIn
* profile, their company's team page — and it is hot-linked rather than
* copied, so it can vanish. When it does, or when there never was one, the
* initials take the same space, so a list of people does not jump around
* depending on who has a photograph.
*/

const SIZES = {
sm: 'h-8 w-8 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-16 w-16 text-lg',
} as const;

export function Avatar({
name,
src,
size = 'md',
className = '',
}: {
name: string;
src?: string | null;
size?: keyof typeof SIZES;
className?: string;
}) {
const box = `${SIZES[size]} shrink-0 rounded-full ${className}`;

if (src) {
return (
// eslint-disable-next-line @next/next/no-img-element -- remote hosts are not known ahead of time
<img
src={src}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className={`${box} bg-surface object-cover`}
/>
);
}

return (
<span
aria-hidden="true"
className={`${box} bg-surface text-ink-muted border-border flex items-center justify-center border font-semibold`}
>
{initials(name)}
</span>
);
}

export function initials(name: string): string {
const parts = name
.trim()
.split(/\s+/)
.filter((part) => /[\p{L}\p{N}]/u.test(part));
const first = parts[0]?.[0] ?? '';
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';
return `${first}${last}`.toUpperCase() || '?';
}
3 changes: 3 additions & 0 deletions apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ApprovalCard {
person_id: string;
display_name: string;
current_title: string | null;
avatar_url?: string | null;
action: string;
network: string;
priority: number;
Expand Down Expand Up @@ -154,6 +155,7 @@ export interface ProspectRow {
id: string;
display_name: string;
current_title: string | null;
avatar_url?: string | null;
current_company: string | null;
identity_confidence: number;
prospect_status: string;
Expand Down Expand Up @@ -196,6 +198,7 @@ export interface ProspectDetail {
id: string;
display_name: string;
current_title: string | null;
avatar_url?: string | null;
identity_confidence: number;
status: string;
};
Expand Down
29 changes: 29 additions & 0 deletions migrations/0033_avatars_and_reseed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- 0033_avatars_and_reseed.sql
--
-- Two things the digest could not show and one thing the pipeline could not do.
--
-- A face. The digest and the queue list people by name and title, and a name
-- is a poor handle on a stranger. Gravatar already returned a `thumbnailUrl`
-- for every profile it found and the enrichment step threw it away; a search
-- adapter can find the rest. The URL is stored rather than the bytes: it is a
-- pointer to a picture the person published, not a copy of it, and it can be
-- dropped by clearing one column.
--
-- `photo_looked_up_at` is a timestamp for the same reason `contact_enriched_at`
-- is: most lookups miss, and a boolean would make the sweep retry every miss
-- forever. A miss stamps the column; only a hit fills `avatar_url`.
--
-- `reseeded_at` records the last time an active campaign's seed was read
-- again. Every campaign was crawled exactly once, at creation, and a directory
-- that gains members after that day never produced another lead: "sites read
-- 0, new people 0" for ten days straight while six campaigns sat active.

ALTER TABLE people ADD COLUMN avatar_url TEXT;
-- gravatar | search | site — where the picture came from, for the audit trail.
ALTER TABLE people ADD COLUMN avatar_source TEXT;
ALTER TABLE people ADD COLUMN photo_looked_up_at TEXT;

CREATE INDEX IF NOT EXISTS idx_people_photo_lookup
ON people(photo_looked_up_at) WHERE photo_looked_up_at IS NULL;

ALTER TABLE campaigns ADD COLUMN reseeded_at TEXT;
Loading
Loading