feat(backend): persist tasks and generated message history

- add EntityTask domain and CRUD API backed by PostgreSQL

- relate generated messages directly to tasks and delete on task removal

- move preview generation to backend Llama endpoint

- migrate frontend task APIs from localStorage to backend endpoints

- update tests and CLAUDE rules for backend-owned LLM/persistence
This commit is contained in:
2026-03-27 02:46:56 -03:00
parent f2a16b5cf6
commit ebcea643c4
20 changed files with 1181 additions and 244 deletions

View File

@@ -1,11 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import apiClient from '@/api/apiClient'
import {
activateTask,
buildTaskPreviewPrompt,
createTask,
deleteTaskGeneratedMessage,
deleteTask,
generateTaskPreview,
getAllTasks,
getTaskGeneratedMessages,
getTask,
inactivateTask,
getTasksByEntity,
@@ -13,6 +16,22 @@ import {
type EntityTaskResponse,
} from '@/api/tasksApi'
vi.mock('@/api/apiClient', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
const mockedApiClient = apiClient as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
const taskOne: EntityTaskResponse = {
id: 'task-1',
entityId: 'entity-1',
@@ -57,47 +76,48 @@ const previewTask = {
describe('tasksApi', () => {
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
vi.clearAllMocks()
})
it('should_returnTask_when_getTaskCalledWithExistingId', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.get.mockResolvedValue({ data: taskOne })
await expect(getTask('task-1')).resolves.toEqual(taskOne)
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/task-1')
})
it('should_returnInactiveTask_when_getTaskCalledWithInactiveId', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.get.mockResolvedValue({ data: taskTwo })
await expect(getTask('task-2')).resolves.toEqual(taskTwo)
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/task-2')
})
it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.get.mockResolvedValue({ data: [taskOne] })
await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne])
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/entity/entity-1')
})
it('should_hideInactiveTasks_when_getAllTasksCalled', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.get.mockResolvedValue({ data: [taskOne] })
await expect(getAllTasks()).resolves.toEqual([taskOne])
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks')
})
it('should_returnInactiveTasksForEntity_when_getTasksByEntityCalled', async () => {
localStorage.setItem(
'condado:entity-tasks',
JSON.stringify([
mockedApiClient.get.mockResolvedValue({
data: [
taskOne,
taskTwo,
{
...taskOne,
id: 'task-3',
active: false,
},
])
)
],
})
await expect(getTasksByEntity('entity-1')).resolves.toEqual([
taskOne,
@@ -110,7 +130,12 @@ describe('tasksApi', () => {
})
it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => {
vi.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000003')
mockedApiClient.post.mockResolvedValue({
data: {
...taskOne,
id: '00000000-0000-0000-0000-000000000003',
},
})
const createdTask = await createTask({
entityId: 'entity-1',
@@ -126,16 +151,25 @@ describe('tasksApi', () => {
active: true,
})
)
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
expect.objectContaining({
id: '00000000-0000-0000-0000-000000000003',
active: true,
}),
])
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks', {
entityId: 'entity-1',
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
})
it('should_updateStoredTask_when_updateTaskCalled', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.put.mockResolvedValue({
data: {
...taskOne,
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
},
})
const updatedTask = await updateTask('task-1', {
entityId: 'entity-1',
@@ -152,20 +186,17 @@ describe('tasksApi', () => {
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
{
...taskOne,
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
},
taskTwo,
])
expect(mockedApiClient.put).toHaveBeenCalledWith('/v1/tasks/task-1', {
entityId: 'entity-1',
name: 'Daily Check-in',
prompt: 'Ask about ceremonial coffee',
scheduleCron: '0 8 * * 1-5',
emailLookback: 'last_day',
})
})
it('should_markTaskInactive_when_inactivateTaskCalled', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.post.mockResolvedValue({ data: { ...taskOne, active: false } })
const updatedTask = await inactivateTask('task-1')
@@ -173,17 +204,11 @@ describe('tasksApi', () => {
...taskOne,
active: false,
})
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
{
...taskOne,
active: false,
},
taskTwo,
])
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks/task-1/inactivate')
})
it('should_markTaskActive_when_activateTaskCalled', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.post.mockResolvedValue({ data: { ...taskTwo, active: true } })
const updatedTask = await activateTask('task-2')
@@ -191,21 +216,51 @@ describe('tasksApi', () => {
...taskTwo,
active: true,
})
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
taskOne,
{
...taskTwo,
active: true,
},
])
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks/task-2/activate')
})
it('should_removeTask_when_deleteTaskCalled', async () => {
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
mockedApiClient.delete.mockResolvedValue({ data: {} })
await deleteTask('task-1')
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo])
expect(mockedApiClient.delete).toHaveBeenCalledWith('/v1/tasks/task-1')
})
it('should_callBackendHistoryEndpoint_when_getTaskGeneratedMessagesCalled', async () => {
mockedApiClient.get.mockResolvedValue({
data: [
{
id: 'message-1',
taskId: 'task-1',
label: 'Message #1',
content: 'SUBJECT: One\nBODY:\nFirst message',
createdAt: '2026-03-27T10:00:00Z',
},
],
})
await expect(getTaskGeneratedMessages('task-1')).resolves.toEqual([
{
id: 'message-1',
taskId: 'task-1',
label: 'Message #1',
content: 'SUBJECT: One\nBODY:\nFirst message',
createdAt: '2026-03-27T10:00:00Z',
},
])
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/tasks/task-1/generated-messages')
})
it('should_deleteGeneratedMessageFromHistory_when_deleteTaskGeneratedMessageCalled', async () => {
mockedApiClient.delete.mockResolvedValue({ data: {} })
await deleteTaskGeneratedMessage('task-1', 'message-1')
expect(mockedApiClient.delete).toHaveBeenCalledWith(
'/v1/tasks/task-1/generated-messages/message-1'
)
})
it('should_buildDeterministicPrompt_when_buildTaskPreviewPromptCalled', () => {
@@ -236,40 +291,32 @@ INSTRUCTIONS
- Output plain text only with no markdown fences.`)
})
it('should_callOllamaGenerateEndpoint_when_generateTaskPreviewCalled', async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ response: 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.' }),
it('should_callBackendPreviewEndpoint_when_generateTaskPreviewCalled', async () => {
mockedApiClient.post.mockResolvedValue({
data: {
id: 'message-1',
taskId: 'task-1',
label: 'Message #1',
content: 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.',
createdAt: '2026-03-27T10:00:00Z',
},
})
vi.stubGlobal('fetch', fetchSpy)
await expect(generateTaskPreview({ entity, task: previewTask })).resolves.toBe(
await expect(generateTaskPreview('task-1', { entity, task: previewTask })).resolves.toBe(
'SUBJECT: Memo\nBODY:\nPlease secure the crackers.'
)
expect(fetchSpy).toHaveBeenCalledWith('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gemma3:4b',
prompt: buildTaskPreviewPrompt(entity, previewTask),
stream: false,
}),
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/tasks/task-1/generated-messages/generate', {
entity,
task: previewTask,
})
})
it('should_throwReadableError_when_ollamaRequestFails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({ error: 'model temporarily unavailable' }),
})
)
it('should_throwErrorFromBackend_when_generateTaskPreviewFails', async () => {
mockedApiClient.post.mockRejectedValue(new Error('backend unavailable'))
await expect(generateTaskPreview({ entity, task: previewTask })).rejects.toThrow(
'Unable to generate a test message from the local model. model temporarily unavailable'
await expect(generateTaskPreview('task-1', { entity, task: previewTask })).rejects.toThrow(
'backend unavailable'
)
})
})

View File

@@ -61,9 +61,18 @@ const mockTask = {
createdAt: '2026-03-26T10:00:00Z',
}
let persistedHistory: Array<{
id: string
taskId: string
label: string
content: string
createdAt: string
}> = []
describe('EditTaskPage', () => {
beforeEach(() => {
vi.clearAllMocks()
persistedHistory = []
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask)
vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation(
@@ -73,6 +82,10 @@ describe('EditTaskPage', () => {
vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true })
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false })
vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined)
vi.mocked(tasksApi.getTaskGeneratedMessages).mockImplementation(async () => persistedHistory)
vi.mocked(tasksApi.deleteTaskGeneratedMessage).mockImplementation(async (_taskId, messageId) => {
persistedHistory = persistedHistory.filter((message) => message.id !== messageId)
})
mockNavigate.mockClear()
})
@@ -93,7 +106,18 @@ describe('EditTaskPage', () => {
})
it('should_generatePreviewAndUpdateTask_when_formSubmitted', async () => {
vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
vi.mocked(tasksApi.generateTaskPreview).mockImplementation(async () => {
const content = 'SUBJECT: Preview\nBODY:\nFormal nonsense'
const nextMessage = {
id: `message-${persistedHistory.length + 1}`,
taskId: 'task-1',
label: `Message #${persistedHistory.length + 1}`,
content,
createdAt: '2026-03-27T12:00:00Z',
}
persistedHistory = [nextMessage, ...persistedHistory]
return content
})
vi.mocked(tasksApi.updateTask).mockResolvedValue({
...mockTask,
name: 'Daily Check-in',
@@ -137,7 +161,8 @@ describe('EditTaskPage', () => {
emailLookback: 'last_day',
})
)
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual('task-1')
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][1]).toEqual(
expect.objectContaining({
entity: mockEntity,
task: expect.objectContaining({
@@ -201,8 +226,34 @@ describe('EditTaskPage', () => {
it('should_showAndManageGeneratedMessageHistory_when_multipleMessagesGenerated', async () => {
vi.mocked(tasksApi.generateTaskPreview)
.mockResolvedValueOnce('SUBJECT: First\nBODY:\nFirst output')
.mockResolvedValueOnce('SUBJECT: Second\nBODY:\nSecond output')
.mockImplementationOnce(async () => {
const content = 'SUBJECT: First\nBODY:\nFirst output'
persistedHistory = [
{
id: 'message-1',
taskId: 'task-1',
label: 'Message #1',
content,
createdAt: '2026-03-27T12:00:00Z',
},
...persistedHistory,
]
return content
})
.mockImplementationOnce(async () => {
const content = 'SUBJECT: Second\nBODY:\nSecond output'
persistedHistory = [
{
id: 'message-2',
taskId: 'task-1',
label: 'Message #2',
content,
createdAt: '2026-03-27T12:10:00Z',
},
...persistedHistory,
]
return content
})
renderPage()
await screen.findByRole('link', { name: /back to entity a/i })
@@ -215,7 +266,7 @@ describe('EditTaskPage', () => {
fireEvent.click(generateButton)
await waitFor(() => {
expect(screen.getByText(/Second output/i)).toBeInTheDocument()
expect(tasksApi.generateTaskPreview).toHaveBeenCalledTimes(2)
})
const history = screen.getByRole('list', { name: /generated message history/i })
@@ -224,6 +275,9 @@ describe('EditTaskPage', () => {
const firstMessageHistoryItem = screen.getByRole('button', { name: /^message #1$/i })
const secondMessageHistoryItem = screen.getByRole('button', { name: /^message #2$/i })
fireEvent.click(secondMessageHistoryItem)
expect(screen.getByText(/Second output/i)).toBeInTheDocument()
fireEvent.click(firstMessageHistoryItem)
expect(screen.getByText(/First output/i)).toBeInTheDocument()
@@ -234,11 +288,29 @@ describe('EditTaskPage', () => {
)
await waitFor(() => {
expect(tasksApi.deleteTaskGeneratedMessage).toHaveBeenCalledWith('task-1', 'message-1')
expect(firstMessageHistoryItem).not.toBeInTheDocument()
expect(secondMessageHistoryItem).toBeInTheDocument()
})
})
it('should_loadPersistedGeneratedMessageHistory_when_pageLoads', async () => {
persistedHistory = [
{
id: 'message-1',
taskId: 'task-1',
label: 'Message #1',
content: 'SUBJECT: Persisted\nBODY:\nFrom storage',
createdAt: '2026-03-27T12:00:00Z',
},
]
renderPage()
await screen.findByRole('button', { name: /^message #1$/i })
expect(screen.getByText(/From storage/i)).toBeInTheDocument()
})
it('should_renderActivateButton_when_taskIsInactive', async () => {
renderPage({ task: { ...mockTask, active: false } })