diff --git a/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx b/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx index 93891fbf8..67a597e34 100644 --- a/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx +++ b/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx @@ -1,20 +1,38 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import Link from 'next/link'; -import { Award, Building2, GraduationCap, Info, Mail, Pencil } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { + Award, + Building2, + FileText, + GraduationCap, + Info, + Loader2, + Mail, + Pencil, + Sparkles, +} from 'lucide-react'; +import { toast } from 'react-hot-toast'; import { Alert } from '@/components/ui/Alert'; import { Button } from '@/components/ui/Button'; import { Checkbox } from '@/components/ui/form/Checkbox'; +import { Progress } from '@/components/ui/Progress'; import { Tooltip } from '@/components/ui/Tooltip'; import { cn } from '@/utils/styles'; import { formatTimestamp } from '@/utils/date'; -import type { ExpertResult } from '@/types/expertFinder'; +import type { ExpertResult, ProposalDraft } from '@/types/expertFinder'; import { buildExpertSearchHref, buildOutreachDocumentHref, + isProposalDraftActive, + isProposalDraftComplete, outreachDocumentLabel, + proposalDraftStepProgress, } from '@/types/expertFinder'; +import { useProposalDraft } from '@/hooks/useExpertFinder'; +import { NoteService } from '@/services/note.service'; import { ExpertFormModal } from './ExpertFormModal'; import { ExpertSourceLinkIcon } from './ExpertSourceLinkIcon'; @@ -26,6 +44,8 @@ interface ExpertResultCardProps { onToggleSelect?: (index: number) => void; onGenerateEmail?: (expert: ExpertResult) => void; onSuccess?: () => Promise; + /** Enables AI proposal drafts (grant-linked searches only). */ + proposalDraftsEnabled?: boolean; } function empty(value: string | undefined): string { @@ -35,6 +55,107 @@ function empty(value: string | undefined): string { /** Show Read more when notes are longer than this */ const NOTES_READ_MORE_MIN_LENGTH = 25; +interface ProposalDraftSectionProps { + draft: ProposalDraft | null; + isStarting: boolean; + startError: string | null; + isOpeningNote: boolean; + onStart: () => void; + onOpenNote: () => void; +} + +function ProposalDraftSection({ + draft, + isStarting, + startError, + isOpeningNote, + onStart, + onOpenNote, +}: Readonly) { + if (draft && isProposalDraftActive(draft)) { + const progress = proposalDraftStepProgress(draft); + return ( +
+
+ + Drafting proposal… + + {progress.position}/{progress.total} + +
+ +

{progress.label}

+
+ ); + } + + if (startError || draft?.status === 'FAILED') { + return ( +
+ +

Proposal draft failed

+

+ {startError || draft?.errorMessage || 'An error occurred while drafting the proposal.'} +

+
+ +
+ ); + } + + if (isProposalDraftComplete(draft)) { + return ( + + ); + } + + return ( + + ); +} + export function ExpertResultCard({ expert, index, @@ -43,8 +164,39 @@ export function ExpertResultCard({ onToggleSelect, onGenerateEmail, onSuccess, + proposalDraftsEnabled, }: ExpertResultCardProps) { + const router = useRouter(); const [editOpen, setEditOpen] = useState(false); + const [isOpeningNote, setIsOpeningNote] = useState(false); + + // Refresh the search detail once a draft settles so selection-based flows + // (e.g. proposal invitation emails) see the latest draft state. + const handleDraftSettled = useCallback(() => { + void onSuccess?.(); + }, [onSuccess]); + const [{ draft, isStarting, startError }, startDraft] = useProposalDraft( + expert.proposalDraft, + handleDraftSettled + ); + const showProposalDraft = Boolean(proposalDraftsEnabled) && expert.searchExpertId != null; + + const handleStartDraft = useCallback(() => { + if (expert.searchExpertId != null) void startDraft(expert.searchExpertId); + }, [expert.searchExpertId, startDraft]); + + const handleOpenNote = useCallback(async () => { + if (draft?.noteId == null) return; + setIsOpeningNote(true); + try { + const note = await NoteService.getNote(String(draft.noteId)); + router.push(`/notebook/${note.organization.slug}/${draft.noteId}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to open the proposal note'); + } finally { + setIsOpeningNote(false); + } + }, [draft?.noteId, router]); const name = empty(expert.name); const canEditContact = expert.expertId != null && Boolean(onSuccess); const title = empty(expert.title); @@ -286,6 +438,16 @@ export function ExpertResultCard({
+ {showProposalDraft ? ( + void handleOpenNote()} + /> + ) : null} {onGenerateEmail && (
+ {email.proposalInviteUrl ? ( + +

Proposal invitation

+

+ This email includes an invite link that gives the expert editor access to the proposal + note. +

+
+ + + {email.proposalInviteUrl} + + + + +
+
+ ) : null} +
{isDraftLike ? ( diff --git a/hooks/useExpertFinder.ts b/hooks/useExpertFinder.ts index 144a34cc2..c97201168 100644 --- a/hooks/useExpertFinder.ts +++ b/hooks/useExpertFinder.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { EXPERT_FINDER_LIST_PAGE_SIZE } from '@/app/expert-finder/lib/paginationParams'; import { ExpertFinderService, @@ -19,8 +19,10 @@ import type { ExpertSearchResult, ExpertSearchListItem, GeneratedEmail, + ProposalDraft, SavedTemplate, } from '@/types/expertFinder'; +import { isProposalDraftActive } from '@/types/expertFinder'; import type { Work } from '@/types/work'; // ── useExpertSearchDetail ──────────────────────────────────────────────────── @@ -72,6 +74,94 @@ export function useExpertSearchDetail( return [{ searchDetail, isLoading, error }, fetch]; } +// ── useProposalDraft ───────────────────────────────────────────────────────── + +const PROPOSAL_DRAFT_POLL_INTERVAL_MS = 5000; + +interface UseProposalDraftState { + draft: ProposalDraft | null; + isStarting: boolean; + startError: string | null; +} + +type StartProposalDraftFn = (searchExpertId: number) => Promise; +type UseProposalDraftReturn = [UseProposalDraftState, StartProposalDraftFn]; + +/** + * Manages one expert's proposal draft: start (attaching to an in-flight run on + * 409) and poll until the draft completes or fails. + * + * @param initialDraft – latest draft embedded in the search detail payload + * @param onSettled – called once when polling observes a terminal status + */ +export function useProposalDraft( + initialDraft: ProposalDraft | null, + onSettled?: (draft: ProposalDraft) => void +): UseProposalDraftReturn { + const [draft, setDraft] = useState(initialDraft); + const [isStarting, setIsStarting] = useState(false); + const [startError, setStartError] = useState(null); + + const onSettledRef = useRef(onSettled); + useEffect(() => { + onSettledRef.current = onSettled; + }, [onSettled]); + + // Reconcile with server data after a search refetch: adopt a newer draft, or + // a terminal update of the one we're tracking. Never regress to older data. + useEffect(() => { + if (!initialDraft) return; + setDraft((prev) => { + if (prev == null || initialDraft.id > prev.id) return initialDraft; + if (initialDraft.id === prev.id && isProposalDraftActive(prev)) return initialDraft; + return prev; + }); + }, [initialDraft]); + + const draftId = draft?.id ?? null; + const shouldPoll = isProposalDraftActive(draft); + + useEffect(() => { + if (draftId == null || !shouldPoll) return; + + let cancelled = false; + const poll = async () => { + try { + const next = await ExpertFinderService.getProposalDraft(draftId); + if (cancelled) return; + setDraft(next); + if (!isProposalDraftActive(next)) onSettledRef.current?.(next); + } catch { + // Transient poll failure — keep the interval running and retry. + } + }; + + const interval = setInterval(poll, PROPOSAL_DRAFT_POLL_INTERVAL_MS); + void poll(); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [draftId, shouldPoll]); + + const start = useCallback(async (searchExpertId: number): Promise => { + setIsStarting(true); + setStartError(null); + try { + const started = await ExpertFinderService.startProposalDraft(searchExpertId); + setDraft(started); + return started; + } catch (err: unknown) { + setStartError(extractApiErrorMessage(err, 'Failed to start proposal draft')); + return null; + } finally { + setIsStarting(false); + } + }, []); + + return [{ draft, isStarting, startError }, start]; +} + // ── useExpertSearches ───────────────────────────────────────────────────────── interface Pagination { diff --git a/services/expertFinder.service.ts b/services/expertFinder.service.ts index ed9029db6..ed0333aff 100644 --- a/services/expertFinder.service.ts +++ b/services/expertFinder.service.ts @@ -10,6 +10,8 @@ import { transformGeneratedEmail, transformSavedTemplate, transformInvitedExperts, + transformProposalDraft, + type ProposalDraft, type InvitedExperts, type ExpertResult, type ExpertSearchCreated, @@ -115,8 +117,12 @@ export type EmailTemplateKind = | 'peer-review' | 'publication' | 'rfp-outreach' + | 'proposal-draft-outreach' | 'custom'; +/** Purpose that links a completed proposal draft and embeds a note invite link. */ +export const PROPOSAL_DRAFT_OUTREACH_TEMPLATE: EmailTemplateKind = 'proposal-draft-outreach'; + // ── Generated emails API ─────────────────────────────────────────────────── /** GeneratedEmail.status */ @@ -137,6 +143,8 @@ export interface GenerateEmailPayload { expert_email: string; template?: string | null; template_id?: number | null; + /** Requires template 'proposal-draft-outreach'; the draft must be completed. */ + proposal_draft_id?: number | null; } export interface CreateDraftEmailPayload { @@ -324,6 +332,51 @@ export class ExpertFinderService { return transformInvitedExperts(raw); } + // ── Proposal drafts ────────────────────────────────────────────────────── + + /** + * Kick off an AI proposal draft for one expert in a search. + * POST /api/research_ai/expert-finder/proposal-drafts/ + */ + static async createProposalDraft(searchExpertId: number): Promise { + const raw = await ApiClient.post>( + `${this.BASE_PATH}/proposal-drafts/`, + { search_expert_id: searchExpertId } + ); + return transformProposalDraft(raw); + } + + /** + * Fetch the current state of a proposal draft (used for polling). + * GET /api/research_ai/expert-finder/proposal-drafts/:draftId/ + */ + static async getProposalDraft(draftId: number | string): Promise { + const raw = await ApiClient.get>( + `${this.BASE_PATH}/proposal-drafts/${draftId}/` + ); + return transformProposalDraft(raw); + } + + /** + * Start a proposal draft, attaching to the already-running one if the API + * answers 409 with the active draft's id. + */ + static async startProposalDraft(searchExpertId: number): Promise { + try { + return await this.createProposalDraft(searchExpertId); + } catch (err) { + if (err instanceof ApiError && err.status === 409) { + const existingId = Number( + (err.errors as Record | undefined)?.proposal_draft_id + ); + if (Number.isInteger(existingId) && existingId >= 1) { + return this.getProposalDraft(existingId); + } + } + throw err; + } + } + // ── Generated emails ───────────────────────────────────────────────────── static async generateEmail(payload: GenerateEmailPayload): Promise { diff --git a/services/lib/serviceUtils.ts b/services/lib/serviceUtils.ts index 67c5966b4..00c78b23a 100644 --- a/services/lib/serviceUtils.ts +++ b/services/lib/serviceUtils.ts @@ -18,14 +18,19 @@ export const roundRscAmount = (amount: number): number => { return Math.round(amount * 1000) / 1000; }; +/** Message from a parsed API error body: `error` (string or array) or DRF `detail`. */ +function messageFromApiErrorBody(errors: any): string | null { + const errorMsg = errors?.error; + if (Array.isArray(errorMsg) && errorMsg.length > 0) return errorMsg[0]; + if (typeof errorMsg === 'string' && errorMsg) return errorMsg; + if (typeof errors?.detail === 'string' && errors.detail.trim()) return errors.detail; + return null; +} + export function extractApiErrorMessage(error: unknown, defaultMessage: string): string { if (error instanceof ApiError) { - const errors = error.errors as any; - if (errors?.error) { - const errorMsg = errors.error; - if (Array.isArray(errorMsg) && errorMsg.length > 0) return errorMsg[0]; - if (typeof errorMsg === 'string') return errorMsg; - } + const bodyMessage = messageFromApiErrorBody(error.errors); + if (bodyMessage) return bodyMessage; if ((error as any).error && typeof (error as any).error === 'string') { return (error as any).error; } diff --git a/types/expertFinder.ts b/types/expertFinder.ts index 399fddc82..57c1d4b1b 100644 --- a/types/expertFinder.ts +++ b/types/expertFinder.ts @@ -40,6 +40,73 @@ export interface ExpertEmailedOnOtherDocument { searchId: number; } +// ── Proposal drafts ────────────────────────────────── + +/** ProposalDraft.status (wire values are uppercase). */ +export type ProposalDraftStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + +/** ProposalDraft.step — ordered pipeline steps of a draft run. */ +export const PROPOSAL_DRAFT_STEPS = [ + { value: 'QUEUED', label: 'Queued' }, + { value: 'BUILDING_PROFILE', label: 'Building expert profile' }, + { value: 'DRAFTING', label: 'Drafting proposal' }, + { value: 'JUDGING', label: 'Evaluating draft' }, + { value: 'REVISING', label: 'Revising draft' }, + { value: 'VERIFYING', label: 'Verifying' }, + { value: 'WRITING_NOTE', label: 'Writing note' }, + { value: 'DONE', label: 'Done' }, +] as const; + +export type ProposalDraftStep = (typeof PROPOSAL_DRAFT_STEPS)[number]['value']; + +/** AI-generated proposal draft job for one expert in a search. */ +export interface ProposalDraft { + id: number; + searchExpertId: number; + /** Resulting notebook note id; null until the run writes the note. */ + noteId: number | null; + status: ProposalDraftStatus; + step: ProposalDraftStep; + roundsUsed: number; + errorMessage: string; + createdDate: string; + completedAt: string | null; +} + +export function isProposalDraftActive(draft: ProposalDraft | null | undefined): boolean { + return draft != null && (draft.status === 'PENDING' || draft.status === 'PROCESSING'); +} + +export function isProposalDraftComplete(draft: ProposalDraft | null | undefined): boolean { + return draft?.status === 'COMPLETED' && draft.noteId != null; +} + +/** 1-based position of the draft's current step, for progress display. */ +export function proposalDraftStepProgress(draft: ProposalDraft): { + label: string; + position: number; + total: number; +} { + const index = PROPOSAL_DRAFT_STEPS.findIndex((s) => s.value === draft.step); + return { + label: index >= 0 ? PROPOSAL_DRAFT_STEPS[index].label : 'Processing', + position: index >= 0 ? index + 1 : 1, + total: PROPOSAL_DRAFT_STEPS.length, + }; +} + +export const transformProposalDraft = createTransformer((raw) => ({ + id: raw.id ?? 0, + searchExpertId: raw.search_expert ?? 0, + noteId: raw.note ?? null, + status: raw.status ?? 'PENDING', + step: raw.step ?? 'QUEUED', + roundsUsed: raw.rounds_used ?? 0, + errorMessage: raw.error_message ?? '', + createdDate: raw.created_date ?? '', + completedAt: raw.completed_at ?? null, +})); + /** Single expert as displayed in the app (detail/list rows). */ export interface ExpertResult { expertId: number | null; @@ -59,6 +126,10 @@ export interface ExpertResult { emailedOnOtherDocuments: ExpertEmailedOnOtherDocument[]; notes?: string; sources?: ExpertSourceLink[] | null; + /** SearchExpert row id — used to start proposal drafts. Null if missing from the payload. */ + searchExpertId: number | null; + /** Latest proposal draft for this expert in this search, if any. */ + proposalDraft: ProposalDraft | null; } export interface ReportUrls { @@ -147,6 +218,12 @@ function transformExpertSource(raw: string | Record): ExpertSou return { url, text }; } +function parseSearchExpertId(raw: unknown): number | null { + if (raw == null || raw === '') return null; + const n = Number(raw); + return Number.isInteger(n) && n >= 1 ? n : null; +} + function parseExpertId(raw: any): number | null { const idRaw = raw?.id ?? raw?.expert_id; if (idRaw == null || idRaw === '') return null; @@ -258,6 +335,8 @@ export function transformExpertResult(raw: any): ExpertResult { emailedOnOtherDocuments: transformEmailedOnOtherDocuments(raw.emailed_on_other_documents), notes: raw.notes ?? raw.recommendation_notes, sources: sources?.length ? sources : null, + searchExpertId: parseSearchExpertId(raw.search_expert_id), + proposalDraft: raw.proposal_draft ? transformProposalDraft(raw.proposal_draft) : null, }; } @@ -372,6 +451,10 @@ export interface GeneratedEmail { template: string | null; status: string; notes: string; + /** ProposalDraft id this email links to, when generated as a proposal invitation. */ + proposalDraftId: number | null; + /** Invite URL granting the expert editor access to the proposal note, when present. */ + proposalInviteUrl: string | null; bouncedAt: string | null; openedAt: string | null; openCount: number; @@ -401,6 +484,11 @@ export const transformGeneratedEmail = createTransformer((r template: raw.template ?? '', status: raw.status ?? 'draft', notes: raw.notes ?? '', + proposalDraftId: raw.proposal_draft ?? null, + proposalInviteUrl: + raw.proposal_invite_url != null && String(raw.proposal_invite_url).trim() !== '' + ? String(raw.proposal_invite_url).trim() + : null, bouncedAt: raw.bounced_at ?? null, openedAt: raw.opened_at ?? null, openCount: raw.open_count ?? 0,