Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dcf7550
feat: add admin dev dashboard at /dev
m1lestones Jun 17, 2026
69889c5
feat: double-click avatar to access /dev dashboard — hidden easter egg
m1lestones Jun 17, 2026
98df0e2
fix: remove isAdmin gate from /dev — access controlled by easter egg
m1lestones Jun 17, 2026
ed07191
feat: increase Iris panel size for demo — wider and taller chat window
m1lestones Jun 20, 2026
822e307
feat: add run-all.js script to batch verify all missing LinkedIn URLs
m1lestones Jun 20, 2026
313c7e9
fix: remove visited link color bleed on contact LinkedIn links
m1lestones Jun 20, 2026
166215f
feat: replace LinkedIn text link with LinkedIn logo icon on contact c…
m1lestones Jun 20, 2026
e025a23
feat: role color badges on contacts + clickable signal cards to source
m1lestones Jun 20, 2026
4faba24
fix: keep website links teal after visiting (visited:text-accent)
m1lestones Jun 20, 2026
2530e2a
fix: Iris panel full-width on mobile, smaller bubble on small screens
m1lestones Jun 20, 2026
cd91d0f
feat: expand/collapse toggle for Iris panel on mobile
m1lestones Jun 20, 2026
ab6153d
feat: Iris expand toggle available on desktop (640px wide, pinned right)
m1lestones Jun 20, 2026
395810b
fix: remove emojis from Iris — prompt button and system prompt
m1lestones Jun 20, 2026
0e68dcb
feat: replace raw email text with envelope icon on contact cards
m1lestones Jun 20, 2026
35ebd37
feat: publication logo via logo.dev on signal card footers
m1lestones Jun 20, 2026
43069ee
perf: cache logo URL per card, lazy load publication logos
m1lestones Jun 20, 2026
80d94ec
revert: remove publication logos from signal cards
m1lestones Jun 20, 2026
3cfb7b3
feat: hover highlight on contact cards
m1lestones Jun 20, 2026
b146f23
fix: move agent run stats out of signal feed footer — dev dashboard only
m1lestones Jun 20, 2026
20085f9
merge: bring in latest main, combine no-emoji + anti-hallucination ru…
m1lestones Jun 20, 2026
20b2ef0
fix: remove Needs review badge — hide LinkedIn icon when URL is null
m1lestones Jun 20, 2026
d9fe682
feat: multi-lane contact restore scripts — 5 source lanes + Claude cr…
m1lestones Jun 20, 2026
09bd18c
Merge remote-tracking branch 'origin/joel/restore-contacts' into feat…
m1lestones Jun 20, 2026
0de0063
feat: contact review queue with Find alternatives — human-in-the-loop…
m1lestones Jun 21, 2026
91e377c
fix: move contact verification queue to /dev dashboard — not public r…
m1lestones Jun 21, 2026
af79f81
feat: multi-source coverage on Danielle's review queue
m1lestones Jun 21, 2026
541470a
feat: rescue button + sort/color review queue by relevance
m1lestones Jun 21, 2026
832007a
fix: improve bottom nav readability — even background, white inactive…
m1lestones Jun 21, 2026
6411f1a
fix: prettier formatting on coverage route, MobileNav, ReviewQueue
m1lestones Jun 21, 2026
0339d44
Merge remote-tracking branch 'origin/main' into feat/mobile-polish
m1lestones Jun 21, 2026
4f9c30b
fix: send action + reviewer_id to signal review endpoint — was sendin…
m1lestones Jun 21, 2026
338e683
docs: add Iris AI chatbot to Juan's role
m1lestones Jun 21, 2026
221bc7e
feat: Run contact search button on /dev — triggers multi-lane AI sear…
m1lestones Jun 21, 2026
4672ae1
feat: contact coverage grid on /dev — shows filled/pending/missing ro…
m1lestones Jun 21, 2026
6417a2e
fix: sort pending contacts by hospital order then role order (CEO/CFO…
m1lestones Jun 21, 2026
6f8f2b7
filter filtered_out signals from main signal feed
m1lestones Jun 21, 2026
068814d
add manual LinkedIn URL field to pending contact cards
m1lestones Jun 21, 2026
31368eb
feat: UTM digest tracking and AE last-viewed roster
m1lestones Jun 22, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The tool replaces **Glean** (the internal AI tool the team uses reactively, only

## Team

- **Juan Franco** — frontend, design system, dashboard UX
- **Juan Franco** — frontend, design system, dashboard UX, Iris AI chatbot
- **Michael Chabler** — partner communications
- **Joel Philip** — backend, agent pipeline, scoping, project management

Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/app/api/contacts/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY!;

export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const body = await req.json();
const { action, overrides } = body;

if (!SUPABASE_URL || !SUPABASE_KEY) {
return NextResponse.json({ error: 'Supabase not configured' }, { status: 500 });
}

if (action === 'approve') {
await fetch(`${SUPABASE_URL}/rest/v1/contacts?id=eq.${id}`, {
method: 'PATCH',
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
is_active: true,
linkedin_verified: true,
...(overrides ?? {}),
}),
});
return NextResponse.json({ ok: true });
}

if (action === 'reject') {
await fetch(`${SUPABASE_URL}/rest/v1/contacts?id=eq.${id}`, {
method: 'DELETE',
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
},
});
return NextResponse.json({ ok: true });
}

return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
104 changes: 104 additions & 0 deletions apps/web/src/app/api/contacts/alternatives/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';

const SERPER_API_KEY = process.env.SERPER_API_KEY!;
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY!;

const HOSPITAL_DOMAINS: Record<string, string> = {
Ascension: 'ascension.org',
'CommonSpirit Health': 'commonspirithealth.org',
'Jefferson Health': 'jeffersonhealth.org',
'NewYork-Presbyterian': 'nyp.org',
'UMass Memorial': 'umassmemorial.org',
'University of Arkansas Medical Sciences': 'uams.edu',
};

async function search(query: string) {
const res = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: { 'X-API-KEY': SERPER_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ q: query, num: 5 }),
});
const data = await res.json();
return (data.organic || []) as { title: string; link: string; snippet: string }[];
}

export async function POST(req: NextRequest) {
const { hospital_name, role } = await req.json();
const domain = HOSPITAL_DOMAINS[hospital_name] ?? '';

// 4 alternative search strategies
const [r1, r2, r3, r4] = await Promise.all([
search(`"${hospital_name}" "${role}" 2025 OR 2026 leadership`),
search(`site:${domain} "${role}" OR "leadership team"`),
search(
`"${hospital_name}" "${role}" site:beckershospitalreview.com OR site:modernhealthcare.com`
),
search(`"${hospital_name}" "appoints" OR "names" OR "hires" "${role}" 2024 OR 2025 OR 2026`),
]);

const allSnippets = [
{ lane: 'Recent News', results: r1 },
{ lane: 'Hospital Website', results: r2 },
{ lane: 'Healthcare Press', results: r3 },
{ lane: 'Appointment Announcements', results: r4 },
]
.map(({ lane, results }) => {
const text = results
.slice(0, 3)
.map((r) => ` - ${r.title} | ${r.link}\n ${r.snippet || ''}`)
.join('\n');
return `[${lane}]\n${text}`;
})
.join('\n\n');

const msg = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: [
{
role: 'user',
content: `Find the current ${role} of ${hospital_name}. Search results from 4 sources:

${allSnippets}

Return up to 3 distinct candidate people for this role. For each, provide evidence from the search results.

Respond ONLY with valid JSON array:
[
{
"full_name": "First Last",
"linkedin_url": "https://www.linkedin.com/in/..." or null,
"prior_employer": "previous company" or null,
"confidence": 0.0,
"evidence": "one sentence explaining why this person"
}
]

Rules:
- Only include real people explicitly mentioned in the search results
- linkedin_url must be /in/ format only
- Order by confidence descending
- If only 1 person found, return array with 1 item
- If no one found, return empty array []`,
},
],
}),
});

const data = await msg.json();
const text = data?.content?.[0]?.text ?? '[]';
try {
const json = text.match(/\[[\s\S]*\]/)?.[0];
const candidates = JSON.parse(json ?? '[]');
return NextResponse.json(candidates);
} catch {
return NextResponse.json([]);
}
}
71 changes: 71 additions & 0 deletions apps/web/src/app/api/contacts/coverage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server';

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY!;

const HOSPITALS = [
{ id: 'a4725891-7354-4187-a6c1-93d7ea9a078f', name: 'Ascension' },
{ id: '7b836e62-3ee8-4d10-b30e-028734a5f812', name: 'CommonSpirit Health' },
{ id: 'a17f653f-8479-4159-9149-63e65d2d50a2', name: 'Jefferson Health' },
{ id: 'f0f6b915-3e9d-4040-ba4d-c89339a1e134', name: 'NewYork-Presbyterian' },
{ id: 'f3ab9c05-4b2b-42e9-9653-2e9dc8f98476', name: 'UMass Memorial' },
{ id: '3aebd89a-1d2c-465c-a22b-08ced9613027', name: 'University of Arkansas Medical Sciences' },
];

const ROLES = ['CEO', 'CFO', 'CRO', 'VP Revenue Cycle'];

function matchRole(role: string): string | null {
const r = role.toLowerCase();
if (
r.includes('chief executive') ||
r === 'ceo' ||
r.includes('president and ceo') ||
r.includes('president & chief executive')
)
return 'CEO';
if (r.includes('chief financial') || r === 'cfo' || r.includes('evp & cfo')) return 'CFO';
if (r.includes('chief revenue') || r === 'cro' || (r.includes('revenue') && r.includes('cro')))
return 'CRO';
if (
r.includes('revenue cycle') &&
(r.includes('vp') || r.includes('vice president') || r.includes('svp'))
)
return 'VP Revenue Cycle';
return null;
}

export async function GET() {
const res = await fetch(
`${SUPABASE_URL}/rest/v1/contacts?select=full_name,role,hospital_id,is_active,linkedin_url`,
{ headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` } }
);
const all = (await res.json()) as {
full_name: string;
role: string;
hospital_id: string;
is_active: boolean;
linkedin_url: string | null;
}[];

const coverage = HOSPITALS.map((hospital) => {
const hospitalContacts = all.filter((c) => c.hospital_id === hospital.id);

const roles = ROLES.map((roleLabel) => {
const active = hospitalContacts.find((c) => c.is_active && matchRole(c.role) === roleLabel);
const pending = !active
? hospitalContacts.find((c) => !c.is_active && matchRole(c.role) === roleLabel)
: null;

return {
role: roleLabel,
status: active ? 'filled' : pending ? 'pending' : 'missing',
name: active?.full_name ?? pending?.full_name ?? null,
linkedin_url: active?.linkedin_url ?? null,
};
});

return { id: hospital.id, name: hospital.name, roles };
});

return NextResponse.json(coverage);
}
Loading
Loading