feat: add prep store with research polling
Adds usePrepStore (Pinia) for interview prep data: parallel fetch of research brief, contacts, task status, and full job detail; setInterval- based polling that stops on completion and re-fetches; clear() cancels the interval and resets all state. Also adds useApiFetch composable wrapper (returns T|null directly) used by the store.
This commit is contained in:
parent
0ef8547c99
commit
44adfd6691
3 changed files with 309 additions and 0 deletions
10
web/src/composables/useApiFetch.ts
Normal file
10
web/src/composables/useApiFetch.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Convenience wrapper around useApiFetch from useApi.ts that returns data directly
|
||||||
|
* (null on error), simplifying store code that doesn't need detailed error handling.
|
||||||
|
*/
|
||||||
|
import { useApiFetch as _useApiFetch } from './useApi'
|
||||||
|
|
||||||
|
export async function useApiFetch<T>(url: string, opts?: RequestInit): Promise<T | null> {
|
||||||
|
const { data } = await _useApiFetch<T>(url, opts)
|
||||||
|
return data
|
||||||
|
}
|
||||||
157
web/src/stores/prep.test.ts
Normal file
157
web/src/stores/prep.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { usePrepStore } from './prep'
|
||||||
|
|
||||||
|
// Mock useApiFetch
|
||||||
|
vi.mock('../composables/useApiFetch', () => ({
|
||||||
|
useApiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useApiFetch } from '../composables/useApiFetch'
|
||||||
|
|
||||||
|
describe('usePrepStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchFor loads research, contacts, task, and full job in parallel', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ company_brief: 'Acme', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: '2026-03-20T12:00:00' }) // research
|
||||||
|
.mockResolvedValueOnce([]) // contacts
|
||||||
|
.mockResolvedValueOnce({ status: 'none', stage: null, message: null }) // task
|
||||||
|
.mockResolvedValueOnce({ id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||||
|
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||||
|
keyword_gaps: null }) // fullJob
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
expect(store.research?.company_brief).toBe('Acme')
|
||||||
|
expect(store.contacts).toEqual([])
|
||||||
|
expect(store.taskStatus.status).toBe('none')
|
||||||
|
expect(store.fullJob?.description).toBe('Build things.')
|
||||||
|
expect(store.currentJobId).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchFor clears state when called for a different job', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// First call for job 1
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ company_brief: 'OldCo', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: null })
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce({ status: 'none', stage: null, message: null })
|
||||||
|
.mockResolvedValueOnce({ id: 1, title: 'Old Job', company: 'OldCo', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
expect(store.research?.company_brief).toBe('OldCo')
|
||||||
|
|
||||||
|
// Second call for job 2 - clears first
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce(null) // 404 → null
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce({ status: 'none', stage: null, message: null })
|
||||||
|
.mockResolvedValueOnce({ id: 2, title: 'New Job', company: 'NewCo', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null })
|
||||||
|
|
||||||
|
await store.fetchFor(2)
|
||||||
|
expect(store.research).toBeNull()
|
||||||
|
expect(store.currentJobId).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generateResearch calls POST then starts polling', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch.mockResolvedValueOnce({ task_id: 7, is_new: true })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
store.currentJobId = 1
|
||||||
|
|
||||||
|
// Spy on pollTask via the interval
|
||||||
|
const pollSpy = mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ status: 'running', stage: 'Analyzing', message: null })
|
||||||
|
|
||||||
|
await store.generateResearch(1)
|
||||||
|
|
||||||
|
// Advance timer one tick — should poll
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
|
||||||
|
// Should have called POST generate + poll task
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/research/generate'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pollTask stops when status is completed and re-fetches research', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// Set up store with a job loaded
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ company_brief: 'Acme', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: null })
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce({ status: 'none', stage: null, message: null })
|
||||||
|
.mockResolvedValueOnce({ id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
|
||||||
|
// Mock first poll → completed
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ status: 'completed', stage: null, message: null })
|
||||||
|
// re-fetch on completed: research, contacts, task, fullJob
|
||||||
|
.mockResolvedValueOnce({ company_brief: 'Updated!', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: '2026-03-20T13:00:00' })
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce({ status: 'completed', stage: null, message: null })
|
||||||
|
.mockResolvedValueOnce({ id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
|
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null })
|
||||||
|
|
||||||
|
store.pollTask(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(store.research?.company_brief).toBe('Updated!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clear cancels polling interval and resets state', async () => {
|
||||||
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
mockApiFetch
|
||||||
|
.mockResolvedValueOnce({ company_brief: 'Acme', ceo_brief: null, talking_points: null,
|
||||||
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
|
generated_at: null })
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce({ status: 'none', stage: null, message: null })
|
||||||
|
.mockResolvedValueOnce({ id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null })
|
||||||
|
|
||||||
|
const store = usePrepStore()
|
||||||
|
await store.fetchFor(1)
|
||||||
|
store.pollTask(1)
|
||||||
|
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
// Advance timers — if polling wasn't cancelled, fetchFor would be called again
|
||||||
|
const callCountBeforeClear = mockApiFetch.mock.calls.length
|
||||||
|
await vi.advanceTimersByTimeAsync(9000)
|
||||||
|
expect(mockApiFetch.mock.calls.length).toBe(callCountBeforeClear)
|
||||||
|
|
||||||
|
expect(store.research).toBeNull()
|
||||||
|
expect(store.contacts).toEqual([])
|
||||||
|
expect(store.currentJobId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
142
web/src/stores/prep.ts
Normal file
142
web/src/stores/prep.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../composables/useApiFetch'
|
||||||
|
|
||||||
|
export interface ResearchBrief {
|
||||||
|
company_brief: string | null
|
||||||
|
ceo_brief: string | null
|
||||||
|
talking_points: string | null
|
||||||
|
tech_brief: string | null
|
||||||
|
funding_brief: string | null
|
||||||
|
red_flags: string | null
|
||||||
|
accessibility_brief: string | null
|
||||||
|
generated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id: number
|
||||||
|
direction: 'inbound' | 'outbound'
|
||||||
|
subject: string | null
|
||||||
|
from_addr: string | null
|
||||||
|
body: string | null
|
||||||
|
received_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStatus {
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
|
||||||
|
stage: string | null
|
||||||
|
message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullJobDetail {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
url: string | null
|
||||||
|
description: string | null
|
||||||
|
cover_letter: string | null
|
||||||
|
match_score: number | null
|
||||||
|
keyword_gaps: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePrepStore = defineStore('prep', () => {
|
||||||
|
const research = ref<ResearchBrief | null>(null)
|
||||||
|
const contacts = ref<Contact[]>([])
|
||||||
|
const taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
|
||||||
|
const fullJob = ref<FullJobDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const currentJobId = ref<number | null>(null)
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function _clearInterval() {
|
||||||
|
if (pollInterval !== null) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFor(jobId: number) {
|
||||||
|
if (jobId !== currentJobId.value) {
|
||||||
|
_clearInterval()
|
||||||
|
research.value = null
|
||||||
|
contacts.value = []
|
||||||
|
taskStatus.value = { status: null, stage: null, message: null }
|
||||||
|
fullJob.value = null
|
||||||
|
error.value = null
|
||||||
|
currentJobId.value = jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [researchData, contactsData, taskData, jobData] = await Promise.all([
|
||||||
|
useApiFetch<ResearchBrief>(`/api/jobs/${jobId}/research`),
|
||||||
|
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
|
||||||
|
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
|
||||||
|
useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`),
|
||||||
|
])
|
||||||
|
|
||||||
|
research.value = researchData ?? null
|
||||||
|
contacts.value = (contactsData as Contact[]) ?? []
|
||||||
|
taskStatus.value = (taskData as TaskStatus) ?? { status: null, stage: null, message: null }
|
||||||
|
fullJob.value = (jobData as FullJobDetail) ?? null
|
||||||
|
|
||||||
|
// If a task is already running/queued, start polling
|
||||||
|
const ts = taskStatus.value.status
|
||||||
|
if (ts === 'queued' || ts === 'running') {
|
||||||
|
pollTask(jobId)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load prep data'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateResearch(jobId: number) {
|
||||||
|
await useApiFetch(`/api/jobs/${jobId}/research/generate`, { method: 'POST' })
|
||||||
|
pollTask(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollTask(jobId: number) {
|
||||||
|
_clearInterval()
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
const data = await useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`)
|
||||||
|
if (data) {
|
||||||
|
taskStatus.value = data as TaskStatus
|
||||||
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
|
_clearInterval()
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
await fetchFor(jobId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
_clearInterval()
|
||||||
|
research.value = null
|
||||||
|
contacts.value = []
|
||||||
|
taskStatus.value = { status: null, stage: null, message: null }
|
||||||
|
fullJob.value = null
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
currentJobId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
research,
|
||||||
|
contacts,
|
||||||
|
taskStatus,
|
||||||
|
fullJob,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentJobId,
|
||||||
|
fetchFor,
|
||||||
|
generateResearch,
|
||||||
|
pollTask,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue