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