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,12 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { getEntity } from '../api/entitiesApi'
import {
activateTask,
buildTaskPreviewPrompt,
deleteTaskGeneratedMessage,
deleteTask,
generateTaskPreview,
getTaskGeneratedMessages,
getTask,
inactivateTask,
updateTask,
@@ -35,12 +37,6 @@ interface RegularitySuggestion {
cronParts: CronParts
}
interface GeneratedMessageItem {
id: string
label: string
content: string
}
const DEFAULT_CRON_PARTS: CronParts = {
minute: '0',
hour: '9',
@@ -121,10 +117,8 @@ export default function EditTaskPage() {
const queryClient = useQueryClient()
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
const [generatedMessages, setGeneratedMessages] = useState<GeneratedMessageItem[]>([])
const [selectedMessageId, setSelectedMessageId] = useState('')
const [previewError, setPreviewError] = useState('')
const generatedMessageCounter = useRef(0)
const { data: entity, isLoading: isLoadingEntity } = useQuery({
queryKey: ['entity', entityId],
@@ -138,6 +132,26 @@ export default function EditTaskPage() {
enabled: Boolean(taskId),
})
const { data: generatedMessages = [] } = useQuery({
queryKey: ['task-generated-messages', taskId],
queryFn: () => getTaskGeneratedMessages(taskId),
enabled: Boolean(taskId),
})
useEffect(() => {
if (generatedMessages.length === 0) {
if (selectedMessageId) {
setSelectedMessageId('')
}
return
}
const hasCurrentSelection = generatedMessages.some((message) => message.id === selectedMessageId)
if (!hasCurrentSelection) {
setSelectedMessageId(generatedMessages[0].id)
}
}, [generatedMessages, selectedMessageId])
useEffect(() => {
if (!task) {
return
@@ -193,20 +207,13 @@ export default function EditTaskPage() {
})
const previewMutation = useMutation({
mutationFn: generateTaskPreview,
mutationFn: (payload: Parameters<typeof generateTaskPreview>[1]) =>
generateTaskPreview(taskId, payload),
onMutate: () => {
setPreviewError('')
},
onSuccess: (value) => {
generatedMessageCounter.current += 1
const nextMessage: GeneratedMessageItem = {
id: `message-${generatedMessageCounter.current}`,
label: `Message #${generatedMessageCounter.current}`,
content: value,
}
setGeneratedMessages((prev) => [nextMessage, ...prev])
setSelectedMessageId(nextMessage.id)
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['task-generated-messages', taskId] })
},
onError: (error) => {
setPreviewError(
@@ -246,6 +253,13 @@ export default function EditTaskPage() {
[generatedMessages, selectedMessageId]
)
const deleteGeneratedMessageMutation = useMutation({
mutationFn: (messageId: string) => deleteTaskGeneratedMessage(taskId, messageId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['task-generated-messages', taskId] })
},
})
const applyCronParts = (nextCronParts: CronParts) => {
setCronParts(nextCronParts)
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
@@ -514,13 +528,7 @@ export default function EditTaskPage() {
<button
type="button"
onClick={() => {
setGeneratedMessages((prev) => {
const nextMessages = prev.filter((item) => item.id !== message.id)
if (selectedMessageId === message.id) {
setSelectedMessageId(nextMessages[0]?.id ?? '')
}
return nextMessages
})
deleteGeneratedMessageMutation.mutate(message.id)
}}
className="rounded border border-red-500/40 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-red-300 hover:bg-red-500/10"
aria-label={`Delete ${message.label.toLowerCase()}`}