diff --git a/README.md b/README.md index e9114c6..b4dd467 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/web/src/app/api/contacts/[id]/route.ts b/apps/web/src/app/api/contacts/[id]/route.ts new file mode 100644 index 0000000..6a8b64e --- /dev/null +++ b/apps/web/src/app/api/contacts/[id]/route.ts @@ -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 }); +} diff --git a/apps/web/src/app/api/contacts/alternatives/route.ts b/apps/web/src/app/api/contacts/alternatives/route.ts new file mode 100644 index 0000000..39547f2 --- /dev/null +++ b/apps/web/src/app/api/contacts/alternatives/route.ts @@ -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 = { + 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([]); + } +} diff --git a/apps/web/src/app/api/contacts/coverage/route.ts b/apps/web/src/app/api/contacts/coverage/route.ts new file mode 100644 index 0000000..51d653b --- /dev/null +++ b/apps/web/src/app/api/contacts/coverage/route.ts @@ -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); +} diff --git a/apps/web/src/app/api/contacts/restore/route.ts b/apps/web/src/app/api/contacts/restore/route.ts new file mode 100644 index 0000000..e81045f --- /dev/null +++ b/apps/web/src/app/api/contacts/restore/route.ts @@ -0,0 +1,289 @@ +import { NextResponse } from 'next/server'; + +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY!; +const SERPER_API_KEY = process.env.SERPER_API_KEY!; +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', domain: 'ascension.org' }, + { + id: '7b836e62-3ee8-4d10-b30e-028734a5f812', + name: 'CommonSpirit Health', + domain: 'commonspirithealth.org', + }, + { + id: 'a17f653f-8479-4159-9149-63e65d2d50a2', + name: 'Jefferson Health', + domain: 'jeffersonhealth.org', + }, + { id: 'f0f6b915-3e9d-4040-ba4d-c89339a1e134', name: 'NewYork-Presbyterian', domain: 'nyp.org' }, + { + id: 'f3ab9c05-4b2b-42e9-9653-2e9dc8f98476', + name: 'UMass Memorial', + domain: 'umassmemorial.org', + }, + { + id: '3aebd89a-1d2c-465c-a22b-08ced9613027', + name: 'University of Arkansas Medical Sciences', + domain: 'uams.edu', + }, +]; + +const ROLES = [ + { title: 'CEO', keywords: 'Chief Executive Officer' }, + { title: 'CFO', keywords: 'Chief Financial Officer' }, + { title: 'CRO', keywords: 'Chief Revenue Officer' }, + { title: 'VP Revenue Cycle', keywords: 'Vice President Revenue Cycle' }, +]; + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function searchSerper(query: string) { + try { + 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 }[]; + } catch { + return []; + } +} + +async function multiLaneSearch(hospital: (typeof HOSPITALS)[0], role: (typeof ROLES)[0]) { + const { title, keywords } = role; + const allResults: { + lane: string; + results: { title: string; link: string; snippet: string }[]; + }[] = []; + + const [l1, l2, l3, l4, l5] = await Promise.all([ + searchSerper(`"${hospital.name}" "${keywords}" site:linkedin.com/in`), + searchSerper(`"${hospital.name}" "appoints" OR "names" "${keywords}" OR "${title}"`), + searchSerper( + `"${hospital.name}" "${title}" site:beckershospitalreview.com OR site:modernhealthcare.com OR site:fiercehealthcare.com` + ), + searchSerper( + `site:${hospital.domain} leadership OR "executive team" "${title}" OR "${keywords}"` + ), + searchSerper(`"${hospital.name}" current "${keywords}" 2024 OR 2025 OR 2026`), + ]); + + if (l1.length) allResults.push({ lane: 'LinkedIn', results: l1 }); + if (l2.length) allResults.push({ lane: 'Press Release', results: l2 }); + if (l3.length) allResults.push({ lane: 'Healthcare News', results: l3 }); + if (l4.length) allResults.push({ lane: 'Hospital Website', results: l4 }); + if (l5.length) allResults.push({ lane: 'General Search', results: l5 }); + + return allResults; +} + +async function verifyWithClaude( + hospital: (typeof HOSPITALS)[0], + role: (typeof ROLES)[0], + laneResults: { lane: string; results: { title: string; link: string; snippet: string }[] }[] +) { + const formatted = laneResults + .map(({ lane, results }) => { + const snippets = results + .slice(0, 3) + .map((r) => ` - ${r.title} | ${r.link}\n ${r.snippet || ''}`) + .join('\n'); + return `[${lane}]\n${snippets}`; + }) + .join('\n\n'); + + const res = 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: 768, + messages: [ + { + role: 'user', + content: `You are verifying the current ${role.title} (${role.keywords}) of ${hospital.name}. + +Search results from 5 sources: + +${formatted} + +Identify the most likely current ${role.title} of ${hospital.name}. +- Look for name consensus across multiple lanes +- Prefer recent appointments (2024-2026) +- A LinkedIn /in/ URL is a strong signal + +Respond ONLY with valid JSON: +{ + "full_name": "First Last", + "role": "${role.title}", + "linkedin_url": "https://www.linkedin.com/in/..." or null, + "prior_employer": "previous company" or null, + "confidence": 0.0, + "sources_agreed": 0, + "reasoning": "one sentence" +} + +Rules: +- linkedin_url must be /in/ format only +- confidence: 0.9+ if 3+ lanes agree, 0.7-0.9 if 2 lanes agree, 0.5-0.7 if 1 strong source, below 0.5 if uncertain +- If uncertain, set confidence below 0.5`, + }, + ], + }), + }); + + const data = await res.json(); + const text = data?.content?.[0]?.text ?? ''; + try { + const json = text.match(/\{[\s\S]*\}/)?.[0]; + return JSON.parse(json ?? 'null'); + } catch { + return null; + } +} + +async function getActiveContacts() { + const res = await fetch( + `${SUPABASE_URL}/rest/v1/contacts?is_active=eq.true&select=hospital_id,role`, + { + headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` }, + } + ); + return (await res.json()) as { hospital_id: string; role: string }[]; +} + +async function upsertContact( + hospitalId: string, + contact: { + full_name: string; + role: string; + linkedin_url: string | null; + prior_employer: string | null; + reasoning: string; + }, + pending: boolean +) { + const checkRes = await fetch( + `${SUPABASE_URL}/rest/v1/contacts?hospital_id=eq.${hospitalId}&role=eq.${encodeURIComponent(contact.role)}&select=id,is_active`, + { headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` } } + ); + const existing = await checkRes.json(); + + if (existing.length > 0 && existing[0].is_active && pending) return 'skipped'; + + if (existing.length > 0) { + await fetch(`${SUPABASE_URL}/rest/v1/contacts?id=eq.${existing[0].id}`, { + method: 'PATCH', + headers: { + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + full_name: contact.full_name, + linkedin_url: contact.linkedin_url, + prior_employer: contact.prior_employer, + linkedin_verified: !!contact.linkedin_url, + is_active: !pending, + review_note: contact.reasoning, + updated_at: new Date().toISOString(), + }), + }); + return pending ? 'pending' : 'updated'; + } else { + await fetch(`${SUPABASE_URL}/rest/v1/contacts`, { + method: 'POST', + headers: { + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + hospital_id: hospitalId, + full_name: contact.full_name, + role: contact.role, + linkedin_url: contact.linkedin_url, + prior_employer: contact.prior_employer, + linkedin_verified: !!contact.linkedin_url, + is_active: !pending, + review_note: contact.reasoning, + }), + }); + return pending ? 'pending' : 'inserted'; + } +} + +export async function POST() { + if (!ANTHROPIC_API_KEY || !SERPER_API_KEY || !SUPABASE_URL || !SUPABASE_KEY) { + return NextResponse.json({ error: 'Missing env vars' }, { status: 500 }); + } + + const activeContacts = await getActiveContacts(); + const activeSet = new Set(activeContacts.map((c) => `${c.hospital_id}::${c.role.toLowerCase()}`)); + + const results: { + hospital: string; + role: string; + name: string; + action: string; + confidence: number; + }[] = []; + let searched = 0; + + for (const hospital of HOSPITALS) { + for (const role of ROLES) { + const key = `${hospital.id}::${role.title.toLowerCase()}`; + if (activeSet.has(key)) continue; // already have an active contact + + searched++; + const laneResults = await multiLaneSearch(hospital, role); + if (!laneResults.length) { + results.push({ + hospital: hospital.name, + role: role.title, + name: '—', + action: 'no results', + confidence: 0, + }); + await sleep(300); + continue; + } + + const contact = await verifyWithClaude(hospital, role, laneResults); + if (!contact || contact.confidence < 0.3) { + results.push({ + hospital: hospital.name, + role: role.title, + name: contact?.full_name ?? '—', + action: 'skipped (low confidence)', + confidence: contact?.confidence ?? 0, + }); + await sleep(500); + continue; + } + + const isPending = contact.confidence < 0.55; + const action = await upsertContact(hospital.id, contact, isPending); + results.push({ + hospital: hospital.name, + role: role.title, + name: contact.full_name, + action, + confidence: contact.confidence, + }); + await sleep(800); + } + } + + return NextResponse.json({ searched, results }); +} diff --git a/apps/web/src/app/api/contacts/route.ts b/apps/web/src/app/api/contacts/route.ts new file mode 100644 index 0000000..1414609 --- /dev/null +++ b/apps/web/src/app/api/contacts/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; + +const SUPABASE_URL = process.env.SUPABASE_URL!; +const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY!; + +const HOSPITAL_ORDER: Record = { + 'a4725891-7354-4187-a6c1-93d7ea9a078f': 0, // Ascension + '7b836e62-3ee8-4d10-b30e-028734a5f812': 1, // CommonSpirit Health + 'a17f653f-8479-4159-9149-63e65d2d50a2': 2, // Jefferson Health + 'f0f6b915-3e9d-4040-ba4d-c89339a1e134': 3, // NewYork-Presbyterian + 'f3ab9c05-4b2b-42e9-9653-2e9dc8f98476': 4, // UMass Memorial + '3aebd89a-1d2c-465c-a22b-08ced9613027': 5, // University of Arkansas Medical Sciences +}; + +const HOSPITAL_NAMES: Record = { + 'a4725891-7354-4187-a6c1-93d7ea9a078f': 'Ascension', + '7b836e62-3ee8-4d10-b30e-028734a5f812': 'CommonSpirit Health', + 'a17f653f-8479-4159-9149-63e65d2d50a2': 'Jefferson Health', + 'f0f6b915-3e9d-4040-ba4d-c89339a1e134': 'NewYork-Presbyterian', + 'f3ab9c05-4b2b-42e9-9653-2e9dc8f98476': 'UMass Memorial', + '3aebd89a-1d2c-465c-a22b-08ced9613027': 'University of Arkansas Medical Sciences', +}; + +const ROLE_ORDER: Record = { + ceo: 0, + cfo: 1, + cro: 2, + 'vp revenue cycle': 3, +}; + +function roleOrder(role: string): number { + const r = role.toLowerCase(); + if (r.includes('chief executive') || r === 'ceo') return 0; + if (r.includes('chief financial') || r === 'cfo') return 1; + if (r.includes('chief revenue') || r === 'cro') return 2; + if (r.includes('revenue cycle')) return 3; + return ROLE_ORDER[r] ?? 99; +} + +export async function GET() { + if (!SUPABASE_URL || !SUPABASE_KEY) { + return NextResponse.json([]); + } + try { + const res = await fetch( + `${SUPABASE_URL}/rest/v1/contacts?is_active=eq.false&order=created_at.desc`, + { + headers: { + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + }, + } + ); + const contacts = await res.json(); + const enriched = contacts.map((c: { hospital_id: string; role: string }) => ({ + ...c, + hospital_name: HOSPITAL_NAMES[c.hospital_id] ?? 'Unknown', + })); + + enriched.sort( + (a: { hospital_id: string; role: string }, b: { hospital_id: string; role: string }) => { + const hospitalDiff = + (HOSPITAL_ORDER[a.hospital_id] ?? 99) - (HOSPITAL_ORDER[b.hospital_id] ?? 99); + if (hospitalDiff !== 0) return hospitalDiff; + return roleOrder(a.role) - roleOrder(b.role); + } + ); + + return NextResponse.json(enriched); + } catch { + return NextResponse.json([]); + } +} diff --git a/apps/web/src/app/api/copilot/route.ts b/apps/web/src/app/api/copilot/route.ts index ad1aaed..4ceea85 100644 --- a/apps/web/src/app/api/copilot/route.ts +++ b/apps/web/src/app/api/copilot/route.ts @@ -7,7 +7,7 @@ Your job is to help account executives interpret signals, prioritize outreach, a Always tie answers to RCM — denial rates, billing operations, Epic migrations, CFO transitions, vendor evaluations, revenue cycle staffing. Adonis sells RCM automation and revenue cycle optimization to hospitals. -Be direct and confident. If no signals or contacts are available in the system context, state exactly that. Do NOT fabricate or hallucinate any signals, contacts, or URLs. +Be direct and confident. Do not use emojis — keep all output professional and text-only. If no signals or contacts are available in the system context, state exactly that. Do NOT fabricate or hallucinate any signals, contacts, or URLs. When citing a signal, include the source as a markdown link: [Source Name](url). Only link sources explicitly provided in the CURRENT LIVE SIGNALS IN THE SYSTEM context. Do not invent links. diff --git a/apps/web/src/app/api/digest-view/route.ts b/apps/web/src/app/api/digest-view/route.ts new file mode 100644 index 0000000..caebe6e --- /dev/null +++ b/apps/web/src/app/api/digest-view/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const SUPABASE_URL = process.env.SUPABASE_URL!; +const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY!; +const RAILWAY_URL = 'https://adonisagents-production.up.railway.app/api/v1'; + +export async function POST(req: NextRequest) { + const { ae_id, digest_id } = await req.json(); + + if (!ae_id) return NextResponse.json({ error: 'ae_id required' }, { status: 400 }); + + // Store in Supabase (upsert by ae_id — keeps only the latest view per AE) + if (SUPABASE_URL && SUPABASE_KEY) { + await fetch(`${SUPABASE_URL}/rest/v1/digest_views`, { + method: 'POST', + headers: { + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + 'Content-Type': 'application/json', + Prefer: 'resolution=merge-duplicates', + }, + body: JSON.stringify({ + ae_id, + digest_id: digest_id ?? null, + viewed_at: new Date().toISOString(), + }), + }).catch(() => {}); + } + + // Also fire Joel's endpoint (fails silently if not live yet) + fetch(`${RAILWAY_URL}/digest-views`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-User-Id': ae_id }, + body: JSON.stringify({ digest_id: digest_id ?? null }), + }).catch(() => {}); + + return NextResponse.json({ ok: true }); +} + +export async function GET() { + if (!SUPABASE_URL || !SUPABASE_KEY) return NextResponse.json([]); + + const res = await fetch( + `${SUPABASE_URL}/rest/v1/digest_views?select=ae_id,viewed_at,digest_id&order=viewed_at.desc`, + { + headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` }, + } + ); + + if (!res.ok) return NextResponse.json([]); + const data = await res.json(); + return NextResponse.json(Array.isArray(data) ? data : []); +} diff --git a/apps/web/src/app/api/signals/coverage/route.ts b/apps/web/src/app/api/signals/coverage/route.ts new file mode 100644 index 0000000..c3687f1 --- /dev/null +++ b/apps/web/src/app/api/signals/coverage/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const SERPER_API_KEY = process.env.SERPER_API_KEY!; + +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 }[]; +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace('www.', ''); + } catch { + return url; + } +} + +export async function POST(req: NextRequest) { + const { title, hospital_name } = await req.json(); + + const [r1, r2, r3] = await Promise.all([ + search( + `"${hospital_name}" ${title} site:beckershospitalreview.com OR site:modernhealthcare.com OR site:fiercehealthcare.com` + ), + search( + `"${hospital_name}" ${title} site:healthcarefinancenews.com OR site:healthsystemcio.com OR site:healthcareitnews.com` + ), + search(`"${hospital_name}" ${title} -site:linkedin.com -site:facebook.com`), + ]); + + const seen = new Set(); + const results: { title: string; url: string; snippet: string; source: string }[] = []; + + for (const r of [...r1, ...r2, ...r3]) { + if (seen.has(r.link)) continue; + seen.add(r.link); + results.push({ + title: r.title, + url: r.link, + snippet: r.snippet || '', + source: extractDomain(r.link), + }); + if (results.length >= 5) break; + } + + return NextResponse.json(results); +} diff --git a/apps/web/src/app/dev/page.tsx b/apps/web/src/app/dev/page.tsx new file mode 100644 index 0000000..f72603c --- /dev/null +++ b/apps/web/src/app/dev/page.tsx @@ -0,0 +1,614 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useUser } from '@/components/UserProvider'; +import ContactReviewQueue from '@/components/ContactReviewQueue'; +import ContactCoverage from '@/components/ContactCoverage'; + +const BASE_URL = 'https://adonisagents-production.up.railway.app/api/v1'; +const HEADERS = (userId: string) => ({ 'X-User-Id': userId }); + +interface ApiStatus { + last_scraper_run: string | null; + next_scraper_run: string | null; + total_signals_stored: number; + pending_review_count: number; + urgent_count: number; + urgent_delta: number; + urgent_delta_direction: 'up' | 'down' | 'flat'; + worth_knowing_count: number; + worth_knowing_delta: number; + worth_knowing_delta_direction: 'up' | 'down' | 'flat'; +} + +interface Hospital { + id: string; + name: string; +} + +interface Signal { + id: string; + hospital_id: string; + signal_type: string; + tier: string; + title: string | null; + source_url: string; +} + +interface Contact { + id: string; + full_name: string; + linkedin_url: string | null; + role: string | null; +} + +interface GithubIssue { + number: number; + title: string; + assignees: { login: string }[]; + html_url: string; +} + +interface AuditEntry { + time: Date; + type: 'error' | 'success' | 'warning' | 'info'; + message: string; +} + +function fmt(d: Date) { + return `${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} ${d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`; +} + +export default function DevDashboardPage() { + const { userId } = useUser(); + const [dark, setDark] = useState(false); + const [lastRefresh, setLastRefresh] = useState(new Date()); + + const [apiHealth, setApiHealth] = useState<'loading' | 'ok' | 'down'>('loading'); + const [apiDownSince, setApiDownSince] = useState(null); + const [statusData, setStatusData] = useState(null); + const [hospitals, setHospitals] = useState([]); + const [signalsByHospital, setSignalsByHospital] = useState>({}); + const [contactsByHospital, setContactsByHospital] = useState>({}); + const [githubIssues, setGithubIssues] = useState([]); + const [allSignals, setAllSignals] = useState([]); + const [auditLog, setAuditLog] = useState([]); + const [restoring, setRestoring] = useState(false); + const [restoreResults, setRestoreResults] = useState< + { hospital: string; role: string; name: string; action: string; confidence: number }[] | null + >(null); + const [prevApiHealth, setPrevApiHealth] = useState<'loading' | 'ok' | 'down'>('loading'); + + const addAudit = useCallback((entry: AuditEntry) => { + setAuditLog((prev) => [entry, ...prev].slice(0, 100)); + }, []); + + const fetchData = useCallback(async () => { + const uid = userId || 'df7c14fd-cde3-4025-be00-ca42f4d31741'; + const h = HEADERS(uid); + + // API health + try { + const res = await fetch(`${BASE_URL}/status`, { headers: h, cache: 'no-store' }); + if (!res.ok) throw new Error('not ok'); + const data: ApiStatus = await res.json(); + setStatusData(data); + setApiHealth('ok'); + setApiDownSince(null); + } catch { + setApiHealth('down'); + setApiDownSince((prev) => prev ?? new Date()); + } + + // Hospitals + per-hospital signals + contacts + try { + const res = await fetch(`${BASE_URL}/hospitals`, { headers: h, cache: 'no-store' }); + if (!res.ok) throw new Error('not ok'); + const data: Hospital[] = await res.json(); + setHospitals(data); + + const sigMap: Record = {}; + const conMap: Record = {}; + + await Promise.all( + data.map(async (hosp) => { + try { + const [sr, cr] = await Promise.all([ + fetch(`${BASE_URL}/hospitals/${hosp.id}/signals`, { headers: h, cache: 'no-store' }), + fetch(`${BASE_URL}/hospitals/${hosp.id}/contacts`, { headers: h, cache: 'no-store' }), + ]); + if (sr.ok) sigMap[hosp.id] = await sr.json(); + if (cr.ok) conMap[hosp.id] = await cr.json(); + } catch {} + }) + ); + + setSignalsByHospital(sigMap); + setContactsByHospital(conMap); + } catch {} + + // All signals for quality checks + try { + const res = await fetch(`${BASE_URL}/signals?limit=200`, { headers: h, cache: 'no-store' }); + if (res.ok) setAllSignals(await res.json()); + } catch {} + + // GitHub issues + try { + const res = await fetch( + 'https://api.github.com/repos/jp-bmn/adonisagent/issues?state=open&per_page=30' + ); + if (res.ok) setGithubIssues(await res.json()); + } catch {} + + setLastRefresh(new Date()); + }, [userId]); + + // Audit: detect API state transitions + useEffect(() => { + if (prevApiHealth === 'loading' && apiHealth === 'down') { + addAudit({ time: new Date(), type: 'error', message: 'API offline — returning 502' }); + } else if (prevApiHealth === 'down' && apiHealth === 'ok') { + addAudit({ time: new Date(), type: 'success', message: 'API came back online' }); + } else if (prevApiHealth === 'loading' && apiHealth === 'ok') { + addAudit({ time: new Date(), type: 'info', message: 'API online — dashboard loaded' }); + } + setPrevApiHealth(apiHealth); + }, [apiHealth, prevApiHealth, addAudit]); + + useEffect(() => { + const saved = localStorage.getItem('adonis-dev-dark'); + if (saved === 'true') setDark(true); + addAudit({ time: new Date(), type: 'info', message: 'Dev dashboard session started' }); + }, [addAudit]); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 30_000); + return () => clearInterval(interval); + }, [fetchData]); + + const toggleDark = () => { + setDark((d) => { + localStorage.setItem('adonis-dev-dark', String(!d)); + return !d; + }); + }; + + // Signal quality checks + const htmlSignals = allSignals.filter((s) => s.title && /<[^>]+>/.test(s.title)); + const testSignals = allSignals.filter((s) => s.title && /ignore|test signal/i.test(s.title)); + const allContacts = Object.values(contactsByHospital).flat(); + const nullLinkedin = allContacts.filter((c) => !c.linkedin_url); + const postUrlLinkedin = allContacts.filter( + (c) => c.linkedin_url && c.linkedin_url.includes('/posts/') + ); + + // Theme classes + const bg = dark ? 'bg-gray-950' : 'bg-paper'; + const card = dark ? 'bg-gray-900 border-gray-800' : 'bg-white border-line'; + const text = dark ? 'text-gray-100' : 'text-ink'; + const sub = dark ? 'text-gray-400' : 'text-slate-500'; + const border = dark ? 'border-gray-800' : 'border-line'; + + return ( +
+ {/* Header */} +
+
+

Dev Dashboard

+

Refreshed {fmt(lastRefresh)} · Admin only

+
+
+ {/* API status pill */} + + + {apiHealth === 'ok' + ? 'API Online' + : apiHealth === 'down' + ? `API Down${apiDownSince ? ` since ${apiDownSince.toLocaleTimeString()}` : ''}` + : 'Checking…'} + + + {/* Dark mode toggle */} +
+ ☀️ + + 🌙 +
+ + +
+
+ +
+ {/* Row 1 — 3 stat tiles */} +
+
+
+ API Health +
+
+ {apiHealth === 'ok' ? 'Online' : apiHealth === 'down' ? 'Down' : '…'} +
+
+ {apiHealth === 'down' && apiDownSince + ? `Since ${apiDownSince.toLocaleTimeString()}` + : apiHealth === 'ok' + ? 'All endpoints responding' + : 'Checking…'} +
+
+ +
+
+ Total Signals +
+
+ {statusData?.total_signals_stored ?? '—'} +
+
+ {statusData?.last_scraper_run + ? `Last run: ${new Date(statusData.last_scraper_run).toLocaleDateString()}` + : 'Last run: unknown'} +
+
+ +
+
+ Pending Review +
+
0 ? 'text-urgent' : 'text-green-600' + }`} + > + {statusData?.pending_review_count ?? '—'} +
+
+ {(statusData?.pending_review_count ?? 0) > 0 ? 'Waiting for Danielle' : 'Queue clear'} +
+
+
+ + {/* Row 2 — Hospital Coverage + Contact Health */} +
+
+
+ Hospital Coverage +
+ {apiHealth === 'down' ? ( +

API offline — cannot load

+ ) : hospitals.length === 0 ? ( +

Loading…

+ ) : ( +
+ {hospitals.map((hosp) => { + const count = signalsByHospital[hosp.id]?.length ?? 0; + const icon = count >= 5 ? '✅' : count >= 3 ? '⚠️' : '❌'; + const color = + count >= 5 ? 'text-green-600' : count >= 3 ? 'text-yellow-600' : 'text-urgent'; + return ( +
+ {hosp.name} + + {icon} {count} signals + +
+ ); + })} +
+ )} +
+ +
+
+ Contact Health +
+ {apiHealth === 'down' ? ( +

API offline — cannot load

+ ) : hospitals.length === 0 ? ( +

Loading…

+ ) : ( +
+ {hospitals.map((hosp) => { + const contacts = contactsByHospital[hosp.id] ?? []; + const total = contacts.length; + const broken = contacts.filter( + (c) => !c.linkedin_url || c.linkedin_url.includes('/posts/') + ).length; + const icon = total === 0 ? '❌' : broken > 0 ? '⚠️' : '✅'; + const color = + total === 0 ? 'text-urgent' : broken > 0 ? 'text-yellow-600' : 'text-green-600'; + return ( +
+ {hosp.name} + + {icon} {total} contacts{broken > 0 ? ` · ${broken} broken` : ''} + +
+ ); + })} +
+ )} +
+
+ + {/* Row 3 — Signal Quality + GitHub Issues */} +
+
+
+ Signal Quality +
+ {apiHealth === 'down' ? ( +

API offline — cannot load

+ ) : allSignals.length === 0 ? ( +

Loading…

+ ) : ( +
+ {[ + { + label: 'HTML in titles', + count: htmlSignals.length, + level: htmlSignals.length > 0 ? 'error' : 'ok', + }, + { + label: 'Test signals in prod', + count: testSignals.length, + level: testSignals.length > 0 ? 'error' : 'ok', + }, + { + label: 'Null LinkedIn URLs', + count: nullLinkedin.length, + level: nullLinkedin.length > 0 ? 'warn' : 'ok', + }, + { + label: 'LinkedIn post URLs', + count: postUrlLinkedin.length, + level: postUrlLinkedin.length > 0 ? 'error' : 'ok', + }, + ].map((row) => ( +
+ {row.label} + + {row.level === 'error' ? '❌' : row.level === 'warn' ? '⚠️' : '✅'}{' '} + {row.count} + +
+ ))} +
+ Signals checked + {allSignals.length} +
+
+ )} +
+ +
+
+ Open GitHub Issues +
+ {githubIssues.length === 0 ? ( +

Loading…

+ ) : ( +
+ {( + [ + { login: 'jp-bmn', name: 'Joel' }, + { login: 'newbloomwon', name: 'Michael' }, + { login: 'm1lestones', name: 'Juan' }, + ] as const + ).map(({ login, name }) => { + const assigned = githubIssues.filter((i) => + i.assignees.some((a) => a.login === login) + ); + if (assigned.length === 0) return null; + return ( +
+
+ {name} ({assigned.length}) +
+
+ {assigned.map((issue) => ( + + #{issue.number} {issue.title} + + ))} +
+
+ ); + })} + {githubIssues.every( + (i) => + !i.assignees.some((a) => + ['jp-bmn', 'newbloomwon', 'm1lestones'].includes(a.login) + ) + ) &&

No assigned open issues

} +
+ )} +
+
+ + {/* Row 4 — Contact Verification */} +
+
+
+ Contact Verification · Pending Review +
+ +
+ + {restoreResults && ( +
+

+ { + restoreResults.filter((r) => r.action === 'inserted' || r.action === 'updated') + .length + }{' '} + inserted/updated · {restoreResults.filter((r) => r.action === 'pending').length}{' '} + pending review ·{' '} + { + restoreResults.filter( + (r) => r.action.includes('skipped') || r.action === 'no results' + ).length + }{' '} + skipped +

+
+ {restoreResults.map((r, i) => ( +
+ + {r.action === 'inserted' || r.action === 'updated' + ? '✓' + : r.action === 'pending' + ? '?' + : '–'} + + {r.hospital} + {r.role} + + {r.name} + + + {r.action} {r.confidence > 0 ? `(${(r.confidence * 100).toFixed(0)}%)` : ''} + +
+ ))} +
+
+ )} + +
+
+ Coverage by Hospital +
+ +
+ +
+
+ Pending Approval +
+ +
+
+ + {/* Row 5 — Audit Log */} +
+
+ Audit Log · This Session +
+ {auditLog.length === 0 ? ( +

No events yet

+ ) : ( +
+ {auditLog.map((entry, i) => ( +
+ + {fmt(entry.time)} + + + {entry.type === 'error' + ? '❌' + : entry.type === 'success' + ? '✅' + : entry.type === 'warning' + ? '⚠️' + : 'ℹ️'}{' '} + {entry.message} + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 0d3bd9d..52ad1a5 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -9,3 +9,7 @@ body { color: theme('colors.ink'); font-feature-settings: 'cv11'; } + +a:visited { + color: inherit; +} diff --git a/apps/web/src/app/hospitals/[id]/page.tsx b/apps/web/src/app/hospitals/[id]/page.tsx index 436f39a..31b8769 100644 --- a/apps/web/src/app/hospitals/[id]/page.tsx +++ b/apps/web/src/app/hospitals/[id]/page.tsx @@ -94,35 +94,66 @@ export default async function HospitalProfilePage({ params }: PageProps) { ) : (
{contacts.map((c) => ( -
-
- {c.full_name || 'Unknown contact'} +
+
+
+ {c.full_name || 'Unknown contact'} +
+ {c.role && ( + + {c.role} + + )}
- {c.role &&
{c.role}
} {c.prior_employer && (
prev. {c.prior_employer}
)}
- {c.linkedin_url ? ( + {c.linkedin_url && ( - ↗ LinkedIn + + + - ) : ( - - Needs review - )} {c.email && ( - {c.email} + + + + )}
diff --git a/apps/web/src/app/hospitals/page.tsx b/apps/web/src/app/hospitals/page.tsx index 3095e32..ffedf33 100644 --- a/apps/web/src/app/hospitals/page.tsx +++ b/apps/web/src/app/hospitals/page.tsx @@ -65,7 +65,7 @@ export default async function HospitalsPage() { href={h.website_url} target="_blank" rel="noreferrer" - className="text-accent hover:underline" + className="text-accent visited:text-accent hover:underline" > {h.website_url.replace(/^https?:\/\//, '')} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f5c55fb..504c8f2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -9,6 +9,7 @@ import { } from '@/lib/api'; import { SignalCard, TerritoryFilter } from '@/components'; import SignalFilters from '@/components/SignalFilters'; +import DigestRoster from '@/components/DigestRoster'; interface PageProps { searchParams: Promise<{ ae_id?: string; category?: string; sort?: string }>; @@ -27,10 +28,15 @@ export default async function HomePage({ searchParams }: PageProps) { const hospitalMap = Object.fromEntries(hospitals.map((h) => [h.id, h.name])); + // Strip garbage — filtered_out signals should never appear in the feed + const cleanSignals = allSignals.filter( + (s) => s.tier !== 'filtered_out' && s.signal_type !== 'filtered_out' + ); + // Filter by category let signals = category - ? allSignals.filter((s) => s.signal_type === (category as SignalType)) - : allSignals; + ? cleanSignals.filter((s) => s.signal_type === (category as SignalType)) + : cleanSignals; // Sort signals = [...signals].sort((a, b) => { @@ -145,29 +151,21 @@ export default async function HomePage({ searchParams }: PageProps) {
)} + {!ae_id && aes.length > 0 && ( +
+ +
+ )} +
View all hospitals → -
- {status.pending_review_count > 0 && ( - - {status.pending_review_count} pending review - - )} - - last run:{' '} - {status.last_scraper_run ? formatDate(status.last_scraper_run) : 'never'} - - - next run:{' '} - {status.next_scraper_run ? formatDate(status.next_scraper_run) : 'scheduled'} - - - stored: {status.total_signals_stored} - - v{status.api_version} -
+ {status.pending_review_count > 0 && ( + + {status.pending_review_count} pending review + + )}
); diff --git a/apps/web/src/app/review/page.tsx b/apps/web/src/app/review/page.tsx index 77c3fcf..877eacb 100644 --- a/apps/web/src/app/review/page.tsx +++ b/apps/web/src/app/review/page.tsx @@ -1,8 +1,21 @@ import { fetchPendingReview } from '@/lib/api'; import { ReviewQueue } from '@/components'; +const HOSPITAL_NAMES: Record = { + 'a4725891-7354-4187-a6c1-93d7ea9a078f': 'Ascension', + '7b836e62-3ee8-4d10-b30e-028734a5f812': 'CommonSpirit Health', + 'a17f653f-8479-4159-9149-63e65d2d50a2': 'Jefferson Health', + 'f0f6b915-3e9d-4040-ba4d-c89339a1e134': 'NewYork-Presbyterian', + 'f3ab9c05-4b2b-42e9-9653-2e9dc8f98476': 'UMass Memorial', + '3aebd89a-1d2c-465c-a22b-08ced9613027': 'University of Arkansas Medical Sciences', +}; + export default async function ReviewPage() { - const signals = await fetchPendingReview(); + const rawSignals = await fetchPendingReview(); + const signals = rawSignals.map((s) => ({ + ...s, + hospital_name: HOSPITAL_NAMES[s.hospital_id] ?? s.hospital_id, + })); return (
@@ -15,7 +28,6 @@ export default async function ReviewPage() { )}

-
); diff --git a/apps/web/src/components/CoPilot.tsx b/apps/web/src/components/CoPilot.tsx index ff125f0..00a81ee 100644 --- a/apps/web/src/components/CoPilot.tsx +++ b/apps/web/src/components/CoPilot.tsx @@ -103,9 +103,9 @@ const PILL_KEY = 'adonis-iris-pill-shown'; const OPENED_KEY = 'adonis-iris-opened'; const MAX_STORED = 200; const MAX_CONTEXT = 40; -const BUBBLE_SIZE = 56; -const PANEL_WIDTH = 320; -const PANEL_HEIGHT = 420; // approximate — used for initial placement only +const BUBBLE_SIZE = typeof window !== 'undefined' && window.innerWidth < 640 ? 44 : 56; +const PANEL_WIDTH = 480; +const PANEL_HEIGHT = 560; // approximate — used for initial placement only const STUB_REPLY = "I can answer questions about your accounts and recent signals. Try asking: 'What happened with NYP this week?' or 'Summarize urgent signals.'"; @@ -137,6 +137,7 @@ function defaultPos() { export default function CoPilot() { const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(false); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); @@ -311,11 +312,35 @@ export default function CoPilot() { // Panel position: appear above/left of bubble, clamped to viewport function panelStyle(): React.CSSProperties { if (!pos) return { display: 'none' }; + const mobile = window.innerWidth < 640; + + if (expanded) { + return { + position: 'fixed', + top: mobile ? 0 : 16, + left: mobile ? 0 : 'auto', + right: mobile ? 0 : 16, + bottom: mobile ? 0 : 16, + zIndex: 50, + width: mobile ? 'auto' : 640, + borderRadius: mobile ? 0 : undefined, + }; + } + + if (mobile) { + return { + position: 'fixed', + bottom: 80, + left: 8, + right: 8, + zIndex: 50, + width: 'auto', + }; + } + const gap = 8; - // Prefer opening upward let top = pos.y - PANEL_HEIGHT - gap; - if (top < 8) top = pos.y + BUBBLE_SIZE + gap; // flip below if near top - // Align right edge of panel with right edge of bubble, clamp to viewport + if (top < 8) top = pos.y + BUBBLE_SIZE + gap; let left = pos.x + BUBBLE_SIZE - PANEL_WIDTH; left = Math.max(8, Math.min(window.innerWidth - PANEL_WIDTH - 8, left)); return { position: 'fixed', top, left, zIndex: 50, width: PANEL_WIDTH }; @@ -455,6 +480,45 @@ export default function CoPilot() { clear )} +
{/* Messages */} -
+
{messages.length === 0 && (
{[ 'Who should I call this week?', diff --git a/apps/web/src/components/ContactCoverage.tsx b/apps/web/src/components/ContactCoverage.tsx new file mode 100644 index 0000000..9a88a4c --- /dev/null +++ b/apps/web/src/components/ContactCoverage.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface RoleCoverage { + role: string; + status: 'filled' | 'pending' | 'missing'; + name: string | null; + linkedin_url: string | null; +} + +interface HospitalCoverage { + id: string; + name: string; + roles: RoleCoverage[]; +} + +const STATUS_STYLE = { + filled: { dot: 'bg-green-500', text: 'text-green-700', label: 'bg-green-50 border-green-200' }, + pending: { + dot: 'bg-yellow-400', + text: 'text-yellow-700', + label: 'bg-yellow-50 border-yellow-200', + }, + missing: { dot: 'bg-red-400', text: 'text-red-600', label: 'bg-red-50 border-red-200' }, +}; + +export default function ContactCoverage() { + const [coverage, setCoverage] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/contacts/coverage') + .then((r) => r.json()) + .then((data) => { + setCoverage(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + if (loading) return

Loading coverage...

; + + return ( +
+ {coverage.map((hospital) => { + const filled = hospital.roles.filter((r) => r.status === 'filled').length; + const pending = hospital.roles.filter((r) => r.status === 'pending').length; + const missing = hospital.roles.filter((r) => r.status === 'missing').length; + + return ( +
+ {/* Hospital header */} +
+ {hospital.name} +
+ {filled} filled + {pending > 0 && {pending} pending} + {missing > 0 && {missing} missing} +
+
+ + {/* Role rows */} +
+ {hospital.roles.map((r) => { + const style = STATUS_STYLE[r.status]; + return ( +
+ + + {r.role} + + {r.name ? ( +
+ + {r.name} + + {r.status === 'pending' && ( + + pending review + + )} + {r.linkedin_url && ( + + LinkedIn + + )} +
+ ) : ( + Not found + )} +
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/ContactReviewQueue.tsx b/apps/web/src/components/ContactReviewQueue.tsx new file mode 100644 index 0000000..5dd3ac0 --- /dev/null +++ b/apps/web/src/components/ContactReviewQueue.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import type { PendingContact } from '@/lib/api'; + +interface Candidate { + full_name: string; + linkedin_url: string | null; + prior_employer: string | null; + confidence: number; + evidence: string; +} + +const ROLE_STYLE: Record = { + ceo: { background: '#FBEDEB', color: '#C44A2C' }, + cfo: { background: '#EBF0FB', color: '#1F4FA8' }, + cro: { background: '#E9F4ED', color: '#1F7A3E' }, +}; + +function roleStyle(role: string | null) { + if (!role) return { background: '#F0EBF8', color: '#6B3FA0' }; + const key = Object.keys(ROLE_STYLE).find((k) => role.toLowerCase().includes(k)); + return key ? ROLE_STYLE[key] : { background: '#F0EBF8', color: '#6B3FA0' }; +} + +export default function ContactReviewQueue() { + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(true); + const [acting, setActing] = useState(null); + const [searching, setSearching] = useState(null); + const [alternatives, setAlternatives] = useState>({}); + const [linkedinInputs, setLinkedinInputs] = useState>({}); + + useEffect(() => { + fetch('/api/contacts') + .then((r) => r.json()) + .then((data) => { + setContacts(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + async function handleAction(id: string, action: 'approve' | 'reject') { + setActing(id); + const manualUrl = linkedinInputs[id]?.trim() || null; + try { + await fetch(`/api/contacts/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action, + ...(action === 'approve' && manualUrl + ? { overrides: { linkedin_url: manualUrl, linkedin_verified: true } } + : {}), + }), + }); + setContacts((prev) => prev.filter((c) => c.id !== id)); + setAlternatives((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } finally { + setActing(null); + } + } + + async function findAlternatives(contact: PendingContact) { + setSearching(contact.id); + try { + const res = await fetch('/api/contacts/alternatives', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + hospital_name: contact.hospital_name, + role: contact.role, + }), + }); + const candidates: Candidate[] = await res.json(); + setAlternatives((prev) => ({ ...prev, [contact.id]: candidates })); + } finally { + setSearching(null); + } + } + + async function approveCandidate(contactId: string, candidate: Candidate) { + setActing(contactId); + try { + await fetch(`/api/contacts/${contactId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'approve', + overrides: { + full_name: candidate.full_name, + linkedin_url: candidate.linkedin_url, + prior_employer: candidate.prior_employer, + }, + }), + }); + setContacts((prev) => prev.filter((c) => c.id !== contactId)); + setAlternatives((prev) => { + const next = { ...prev }; + delete next[contactId]; + return next; + }); + } finally { + setActing(null); + } + } + + if (loading) return

Loading pending contacts...

; + if (contacts.length === 0) + return

No contacts pending review.

; + + return ( +
+ {contacts.map((c) => ( +
+ {/* Header */} +
+ {c.full_name} + {c.role && ( + + {c.role} + + )} + {c.hospital_name} +
+ + {/* Details */} + {c.prior_employer &&

prev. {c.prior_employer}

} + {c.linkedin_url && ( + + {c.linkedin_url} + + )} + + {/* Agent reasoning */} + {c.review_note && ( +
+ {c.review_note} +
+ )} + + {/* Alternatives */} + {alternatives[c.id] != null && ( +
+

+ Alternative candidates +

+ {alternatives[c.id]!.length === 0 ? ( +

No other candidates found.

+ ) : ( + alternatives[c.id]!.map((candidate, i) => ( +
+
+

{candidate.full_name}

+ {candidate.prior_employer && ( +

prev. {candidate.prior_employer}

+ )} + {candidate.linkedin_url && ( + + {candidate.linkedin_url} + + )} +

{candidate.evidence}

+
+
+ + {(candidate.confidence * 100).toFixed(0)}% + + +
+
+ )) + )} +
+ )} + + {/* Manual LinkedIn input */} + {!c.linkedin_url && ( +
+ setLinkedinInputs((prev) => ({ ...prev, [c.id]: e.target.value }))} + className="flex-1 text-xs border border-line rounded-lg px-3 py-1.5 text-ink placeholder:text-slate-400 focus:outline-none focus:border-slate-400" + /> +
+ )} + + {/* Actions */} +
+ + + +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/DigestRoster.tsx b/apps/web/src/components/DigestRoster.tsx new file mode 100644 index 0000000..534814e --- /dev/null +++ b/apps/web/src/components/DigestRoster.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface DigestView { + ae_id: string; + viewed_at: string; + digest_id: string | null; +} + +interface AeUser { + id: string; + name: string; +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +export default function DigestRoster({ aes }: { aes: AeUser[] }) { + const [views, setViews] = useState([]); + + useEffect(() => { + fetch('/api/digest-view') + .then((r) => r.json()) + .then((data) => setViews(Array.isArray(data) ? data : [])) + .catch(() => {}); + }, []); + + const viewMap = Object.fromEntries(views.map((v) => [v.ae_id, v])); + + return ( +
+
+ + Digest · Last Opened + + {aes.length} AEs +
+
+ {aes.map((ae) => { + const view = viewMap[ae.id]; + return ( +
+ {ae.name} + {view ? ( +
+ + + {timeAgo(view.viewed_at)} + +
+ ) : ( +
+ + not opened +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/DigestTracker.tsx b/apps/web/src/components/DigestTracker.tsx index 6ae67ab..3284366 100644 --- a/apps/web/src/components/DigestTracker.tsx +++ b/apps/web/src/components/DigestTracker.tsx @@ -3,28 +3,22 @@ import { useEffect } from 'react'; import { useSearchParams } from 'next/navigation'; -const BASE_URL = 'https://adonisagents-production.up.railway.app/api/v1'; -const DEFAULT_USER_ID = 'df7c14fd-cde3-4025-be00-ca42f4d31741'; - -// Fires POST /digest-views when the user arrives from a digest email link. -// Expects UTM params: ?utm_source=digest&digest_id= -// Joel's Task 15 — scaffolded; endpoint may not be live yet, fails silently. +// Fires POST /api/digest-view when an AE arrives from a digest link. +// Expected URL params: ?utm_source=digest&digest_id=&ae_id= export default function DigestTracker() { const searchParams = useSearchParams(); useEffect(() => { const utmSource = searchParams.get('utm_source'); const digestId = searchParams.get('digest_id'); + const aeId = searchParams.get('ae_id'); - if (utmSource !== 'digest' || !digestId) return; + if (utmSource !== 'digest' || !aeId) return; - fetch(`${BASE_URL}/digest-views`, { + fetch('/api/digest-view', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': DEFAULT_USER_ID, - }, - body: JSON.stringify({ digest_id: digestId }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ae_id: aeId, digest_id: digestId }), }).catch(() => {}); }, [searchParams]); diff --git a/apps/web/src/components/MobileNav.tsx b/apps/web/src/components/MobileNav.tsx index c39cfd0..bfa1478 100644 --- a/apps/web/src/components/MobileNav.tsx +++ b/apps/web/src/components/MobileNav.tsx @@ -71,8 +71,7 @@ export default function MobileNav() {