From ebcea643c459288653e114c84afd230ffab0d94a Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Fri, 27 Mar 2026 02:46:56 -0300 Subject: [PATCH] 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 --- CLAUDE.md | 11 + .../controller/EntityTaskController.kt | 81 +++++++ .../TaskGeneratedMessageController.kt | 49 +++++ .../condado/newsletter/dto/EntityTaskDtos.kt | 48 +++++ .../dto/TaskGeneratedMessageDtos.kt | 51 +++++ .../condado/newsletter/model/EntityTask.kt | 53 +++++ .../model/GeneratedMessageHistory.kt | 37 ++++ .../repository/EntityTaskRepository.kt | 11 + .../GeneratedMessageHistoryRepository.kt | 12 ++ .../newsletter/service/EntityTaskService.kt | 116 ++++++++++ .../newsletter/service/LlamaPreviewService.kt | 47 ++++ .../service/TaskGeneratedMessageService.kt | 96 +++++++++ backend/src/main/resources/application.yml | 4 + .../TaskGeneratedMessageControllerTest.kt | 141 ++++++++++++ .../newsletter/repository/RepositoryTest.kt | 43 ++++ .../TaskGeneratedMessageServiceTest.kt | 84 ++++++++ frontend/src/__tests__/api/tasksApi.test.ts | 193 ++++++++++------- .../src/__tests__/pages/EditTaskPage.test.tsx | 82 ++++++- frontend/src/api/tasksApi.ts | 204 ++++++------------ frontend/src/pages/EditTaskPage.tsx | 62 +++--- 20 files changed, 1181 insertions(+), 244 deletions(-) create mode 100644 backend/src/main/kotlin/com/condado/newsletter/controller/EntityTaskController.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageController.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/dto/EntityTaskDtos.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/dto/TaskGeneratedMessageDtos.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/model/EntityTask.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/model/GeneratedMessageHistory.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/repository/EntityTaskRepository.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/repository/GeneratedMessageHistoryRepository.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/service/EntityTaskService.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/service/LlamaPreviewService.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/service/TaskGeneratedMessageService.kt create mode 100644 backend/src/test/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageControllerTest.kt create mode 100644 backend/src/test/kotlin/com/condado/newsletter/service/TaskGeneratedMessageServiceTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 7ac00ea..6a69f4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,6 +494,17 @@ BODY: ``` +## Task Preview Generation Rules + +- The frontend must **never** call LLM providers (OpenAI/Ollama/Llama) directly. +- The frontend requests backend endpoints only. +- The backend is responsible for: + - building the final prompt, + - calling the configured LLM endpoint, + - returning the generated message to the frontend. +- Generated preview message history must be persisted in the backend database (not browser storage), + so history survives reloads and restarts. + --- ## Git Workflow & CI/CD diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/EntityTaskController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/EntityTaskController.kt new file mode 100644 index 0000000..3a98d46 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/EntityTaskController.kt @@ -0,0 +1,81 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.EntityTaskCreateDto +import com.condado.newsletter.dto.EntityTaskResponseDto +import com.condado.newsletter.dto.EntityTaskUpdateDto +import com.condado.newsletter.service.EntityTaskService +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * REST controller for task CRUD operations. + */ +@RestController +@RequestMapping("/api/v1/tasks") +class EntityTaskController( + private val entityTaskService: EntityTaskService +) { + + /** Lists active tasks across all entities. */ + @GetMapping + fun getAllActive(): ResponseEntity> = + ResponseEntity.ok(entityTaskService.findAllActive()) + + /** Lists all tasks for one entity. */ + @GetMapping("/entity/{entityId}") + fun getByEntity(@PathVariable entityId: UUID): ResponseEntity> = + ResponseEntity.ok(entityTaskService.findByEntity(entityId)) + + /** Returns one task by id. */ + @GetMapping("/{taskId}") + fun getById(@PathVariable taskId: UUID): ResponseEntity { + val task = entityTaskService.findById(taskId) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(task) + } + + /** Creates a task. */ + @PostMapping + fun create(@Valid @RequestBody dto: EntityTaskCreateDto): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED).body(entityTaskService.create(dto)) + + /** Updates a task. */ + @PutMapping("/{taskId}") + fun update( + @PathVariable taskId: UUID, + @Valid @RequestBody dto: EntityTaskUpdateDto + ): ResponseEntity { + val task = entityTaskService.update(taskId, dto) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(task) + } + + /** Inactivates a task. */ + @PostMapping("/{taskId}/inactivate") + fun inactivate(@PathVariable taskId: UUID): ResponseEntity { + val task = entityTaskService.inactivate(taskId) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(task) + } + + /** Activates a task. */ + @PostMapping("/{taskId}/activate") + fun activate(@PathVariable taskId: UUID): ResponseEntity { + val task = entityTaskService.activate(taskId) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(task) + } + + /** Deletes a task and all linked generated-message history. */ + @DeleteMapping("/{taskId}") + fun delete(@PathVariable taskId: UUID): ResponseEntity { + entityTaskService.delete(taskId) + return ResponseEntity.noContent().build() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageController.kt new file mode 100644 index 0000000..880b287 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageController.kt @@ -0,0 +1,49 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.GeneratedMessageHistoryResponseDto +import com.condado.newsletter.dto.TaskPreviewGenerateRequestDto +import com.condado.newsletter.service.TaskGeneratedMessageService +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * REST endpoints for task generated-message history and server-side preview generation. + */ +@RestController +@RequestMapping("/api/v1/tasks/{taskId}/generated-messages") +class TaskGeneratedMessageController( + private val taskGeneratedMessageService: TaskGeneratedMessageService +) { + + /** Lists generated-message history for one task. */ + @GetMapping + fun getAllByTask(@PathVariable taskId: UUID): ResponseEntity> = + ResponseEntity.ok(taskGeneratedMessageService.list(taskId)) + + /** Generates a new test message via backend Llama call and persists it. */ + @PostMapping("/generate") + fun generate( + @PathVariable taskId: UUID, + @Valid @RequestBody request: TaskPreviewGenerateRequestDto + ): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED).body(taskGeneratedMessageService.generateAndSave(taskId, request)) + + /** Deletes one persisted generated message for a task. */ + @DeleteMapping("/{messageId}") + fun delete( + @PathVariable taskId: UUID, + @PathVariable messageId: UUID + ): ResponseEntity { + taskGeneratedMessageService.delete(taskId, messageId) + return ResponseEntity.noContent().build() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/EntityTaskDtos.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/EntityTaskDtos.kt new file mode 100644 index 0000000..3829827 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/EntityTaskDtos.kt @@ -0,0 +1,48 @@ +package com.condado.newsletter.dto + +import com.condado.newsletter.model.EntityTask +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import java.time.LocalDateTime +import java.util.UUID + +data class EntityTaskCreateDto( + @field:NotNull val entityId: UUID, + @field:NotBlank val name: String, + @field:NotBlank val prompt: String, + @field:NotBlank val scheduleCron: String, + @field:NotBlank val emailLookback: String +) + +data class EntityTaskUpdateDto( + @field:NotNull val entityId: UUID, + @field:NotBlank val name: String, + @field:NotBlank val prompt: String, + @field:NotBlank val scheduleCron: String, + @field:NotBlank val emailLookback: String +) + +data class EntityTaskResponseDto( + val id: UUID, + val entityId: UUID, + val name: String, + val prompt: String, + val scheduleCron: String, + val emailLookback: String, + val active: Boolean, + val createdAt: LocalDateTime? +) { + companion object { + fun from(task: EntityTask): EntityTaskResponseDto = + EntityTaskResponseDto( + id = task.id!!, + entityId = task.virtualEntity.id!!, + name = task.name, + prompt = task.prompt, + scheduleCron = task.scheduleCron, + emailLookback = task.emailLookback, + active = task.active, + createdAt = task.createdAt + ) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/TaskGeneratedMessageDtos.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/TaskGeneratedMessageDtos.kt new file mode 100644 index 0000000..526684e --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/TaskGeneratedMessageDtos.kt @@ -0,0 +1,51 @@ +package com.condado.newsletter.dto + +import com.condado.newsletter.model.GeneratedMessageHistory +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import java.time.LocalDateTime +import java.util.UUID + +data class GeneratedMessageHistoryResponseDto( + val id: UUID, + val taskId: UUID, + val label: String, + val content: String, + val createdAt: LocalDateTime +) { + companion object { + fun from(model: GeneratedMessageHistory): GeneratedMessageHistoryResponseDto = + GeneratedMessageHistoryResponseDto( + id = model.id!!, + taskId = model.task.id!!, + label = model.label, + content = model.content, + createdAt = model.createdAt + ) + } +} + +data class TaskPreviewEntityDto( + @field:NotBlank val id: String, + @field:NotBlank val name: String, + @field:NotBlank val email: String, + @field:NotBlank val jobTitle: String, + val personality: String? = null, + val scheduleCron: String? = null, + @field:NotNull val contextWindowDays: Int, + @field:NotNull val active: Boolean +) + +data class TaskPreviewTaskDto( + @field:NotBlank val entityId: String, + @field:NotBlank val name: String, + @field:NotBlank val prompt: String, + @field:NotBlank val scheduleCron: String, + @field:NotBlank val emailLookback: String +) + +data class TaskPreviewGenerateRequestDto( + @field:Valid @field:NotNull val entity: TaskPreviewEntityDto, + @field:Valid @field:NotNull val task: TaskPreviewTaskDto +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/EntityTask.kt b/backend/src/main/kotlin/com/condado/newsletter/model/EntityTask.kt new file mode 100644 index 0000000..c743dea --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/EntityTask.kt @@ -0,0 +1,53 @@ +package com.condado.newsletter.model + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import java.time.LocalDateTime +import java.util.UUID + +/** + * Represents a configurable task belonging to a virtual entity. + */ +@Entity +@Table(name = "entity_tasks") +class EntityTask( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "entity_id", nullable = false) + val virtualEntity: VirtualEntity, + + @Column(nullable = false) + val name: String, + + @Column(columnDefinition = "TEXT", nullable = false) + val prompt: String, + + @Column(name = "schedule_cron", nullable = false) + val scheduleCron: String, + + @Column(name = "email_lookback", nullable = false) + val emailLookback: String, + + @Column(nullable = false) + val active: Boolean = true, + + @CreationTimestamp + @Column(name = "created_at", updatable = false, nullable = false) + val createdAt: LocalDateTime? = null +) { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID? = null + + @OneToMany(mappedBy = "task", cascade = [CascadeType.ALL], orphanRemoval = true) + val generatedMessages: MutableList = mutableListOf() +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/GeneratedMessageHistory.kt b/backend/src/main/kotlin/com/condado/newsletter/model/GeneratedMessageHistory.kt new file mode 100644 index 0000000..3d2f727 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/GeneratedMessageHistory.kt @@ -0,0 +1,37 @@ +package com.condado.newsletter.model + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.FetchType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +/** + * Stores generated test messages for a task, so the history survives page reloads and restarts. + */ +@Entity +@Table(name = "generated_message_history") +class GeneratedMessageHistory( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "task_id", nullable = false) + val task: EntityTask, + + @Column(nullable = false) + val label: String, + + @Column(columnDefinition = "TEXT", nullable = false) + val content: String, + + @Column(name = "created_at", nullable = false) + val createdAt: LocalDateTime = LocalDateTime.now() +) { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID? = null +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/repository/EntityTaskRepository.kt b/backend/src/main/kotlin/com/condado/newsletter/repository/EntityTaskRepository.kt new file mode 100644 index 0000000..7af240e --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/repository/EntityTaskRepository.kt @@ -0,0 +1,11 @@ +package com.condado.newsletter.repository + +import com.condado.newsletter.model.EntityTask +import com.condado.newsletter.model.VirtualEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface EntityTaskRepository : JpaRepository { + fun findAllByActiveTrue(): List + fun findAllByVirtualEntity(virtualEntity: VirtualEntity): List +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/repository/GeneratedMessageHistoryRepository.kt b/backend/src/main/kotlin/com/condado/newsletter/repository/GeneratedMessageHistoryRepository.kt new file mode 100644 index 0000000..bfc7c50 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/repository/GeneratedMessageHistoryRepository.kt @@ -0,0 +1,12 @@ +package com.condado.newsletter.repository + +import com.condado.newsletter.model.GeneratedMessageHistory +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface GeneratedMessageHistoryRepository : JpaRepository { + fun findAllByTask_IdOrderByCreatedAtDesc(taskId: UUID): List + fun countByTask_Id(taskId: UUID): Long + fun deleteByIdAndTask_Id(id: UUID, taskId: UUID): Long + fun deleteAllByTask_Id(taskId: UUID) +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/EntityTaskService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/EntityTaskService.kt new file mode 100644 index 0000000..44b2722 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/EntityTaskService.kt @@ -0,0 +1,116 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.dto.EntityTaskCreateDto +import com.condado.newsletter.dto.EntityTaskResponseDto +import com.condado.newsletter.dto.EntityTaskUpdateDto +import com.condado.newsletter.model.EntityTask +import com.condado.newsletter.repository.EntityTaskRepository +import com.condado.newsletter.repository.GeneratedMessageHistoryRepository +import com.condado.newsletter.repository.VirtualEntityRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Service layer for CRUD operations on entity tasks. + */ +@Service +class EntityTaskService( + private val entityTaskRepository: EntityTaskRepository, + private val virtualEntityRepository: VirtualEntityRepository, + private val generatedMessageHistoryRepository: GeneratedMessageHistoryRepository +) { + + /** Returns active tasks only. */ + fun findAllActive(): List = + entityTaskRepository.findAllByActiveTrue().map { EntityTaskResponseDto.from(it) } + + /** Returns all tasks for one virtual entity, including inactive ones. */ + fun findByEntity(entityId: UUID): List { + val entity = virtualEntityRepository.findById(entityId).orElse(null) ?: return emptyList() + return entityTaskRepository.findAllByVirtualEntity(entity).map { EntityTaskResponseDto.from(it) } + } + + /** Returns one task by id. */ + fun findById(taskId: UUID): EntityTaskResponseDto? = + entityTaskRepository.findById(taskId).map { EntityTaskResponseDto.from(it) }.orElse(null) + + /** Creates a new task. */ + @Transactional + fun create(dto: EntityTaskCreateDto): EntityTaskResponseDto { + val entity = virtualEntityRepository.findById(dto.entityId) + .orElseThrow { IllegalArgumentException("Virtual entity not found: ${dto.entityId}") } + + val task = EntityTask( + virtualEntity = entity, + name = dto.name, + prompt = dto.prompt, + scheduleCron = dto.scheduleCron, + emailLookback = dto.emailLookback, + active = true + ) + + return EntityTaskResponseDto.from(entityTaskRepository.save(task)) + } + + /** Updates one existing task. */ + @Transactional + fun update(taskId: UUID, dto: EntityTaskUpdateDto): EntityTaskResponseDto? { + val existing = entityTaskRepository.findById(taskId).orElse(null) ?: return null + val entity = virtualEntityRepository.findById(dto.entityId) + .orElseThrow { IllegalArgumentException("Virtual entity not found: ${dto.entityId}") } + + val updated = EntityTask( + virtualEntity = entity, + name = dto.name, + prompt = dto.prompt, + scheduleCron = dto.scheduleCron, + emailLookback = dto.emailLookback, + active = existing.active, + createdAt = existing.createdAt + ).apply { id = existing.id } + + return EntityTaskResponseDto.from(entityTaskRepository.save(updated)) + } + + /** Marks task as inactive. */ + @Transactional + fun inactivate(taskId: UUID): EntityTaskResponseDto? { + val existing = entityTaskRepository.findById(taskId).orElse(null) ?: return null + val updated = EntityTask( + virtualEntity = existing.virtualEntity, + name = existing.name, + prompt = existing.prompt, + scheduleCron = existing.scheduleCron, + emailLookback = existing.emailLookback, + active = false, + createdAt = existing.createdAt + ).apply { id = existing.id } + + return EntityTaskResponseDto.from(entityTaskRepository.save(updated)) + } + + /** Marks task as active. */ + @Transactional + fun activate(taskId: UUID): EntityTaskResponseDto? { + val existing = entityTaskRepository.findById(taskId).orElse(null) ?: return null + val updated = EntityTask( + virtualEntity = existing.virtualEntity, + name = existing.name, + prompt = existing.prompt, + scheduleCron = existing.scheduleCron, + emailLookback = existing.emailLookback, + active = true, + createdAt = existing.createdAt + ).apply { id = existing.id } + + return EntityTaskResponseDto.from(entityTaskRepository.save(updated)) + } + + /** Deletes a task and all generated messages linked to it. */ + @Transactional + fun delete(taskId: UUID) { + generatedMessageHistoryRepository.deleteAllByTask_Id(taskId) + entityTaskRepository.deleteById(taskId) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/LlamaPreviewService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/LlamaPreviewService.kt new file mode 100644 index 0000000..5147050 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/LlamaPreviewService.kt @@ -0,0 +1,47 @@ +package com.condado.newsletter.service + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient + +/** Calls a local Llama/Ollama endpoint to generate test task messages. */ +@Service +class LlamaPreviewService( + private val restClient: RestClient, + @Value("\${llama.base-url:http://localhost:11434}") private val baseUrl: String, + @Value("\${llama.model:gemma3:4b}") private val model: String +) { + + /** + * Generates one message from the provided prompt. + */ + fun generate(prompt: String): String { + val response = restClient.post() + .uri("${baseUrl.trimEnd('/')}/api/generate") + .body(LlamaGenerateRequest(model = model, prompt = prompt, stream = false)) + .retrieve() + .body(LlamaGenerateResponse::class.java) + ?: throw IllegalStateException("Llama returned an empty response") + + val text = response.response?.trim().orEmpty() + if (text.isBlank()) { + throw IllegalStateException("Llama returned an empty message") + } + + return text + } + + private data class LlamaGenerateRequest( + val model: String, + val prompt: String, + val stream: Boolean + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class LlamaGenerateResponse( + @JsonProperty("response") + val response: String? + ) +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/TaskGeneratedMessageService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/TaskGeneratedMessageService.kt new file mode 100644 index 0000000..6f5d608 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/TaskGeneratedMessageService.kt @@ -0,0 +1,96 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.dto.GeneratedMessageHistoryResponseDto +import com.condado.newsletter.dto.TaskPreviewGenerateRequestDto +import com.condado.newsletter.model.GeneratedMessageHistory +import com.condado.newsletter.repository.EntityTaskRepository +import com.condado.newsletter.repository.GeneratedMessageHistoryRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Handles generation and persistence of task test-message history. + */ +@Service +class TaskGeneratedMessageService( + private val generatedMessageHistoryRepository: GeneratedMessageHistoryRepository, + private val entityTaskRepository: EntityTaskRepository, + private val llamaPreviewService: LlamaPreviewService +) { + + /** Lists persisted generated messages for a task. */ + fun list(taskId: UUID): List = + generatedMessageHistoryRepository + .findAllByTask_IdOrderByCreatedAtDesc(taskId) + .map { GeneratedMessageHistoryResponseDto.from(it) } + + /** + * Generates a new message using local Llama, persists it, and returns it. + */ + @Transactional + fun generateAndSave(taskId: UUID, request: TaskPreviewGenerateRequestDto): GeneratedMessageHistoryResponseDto { + val task = entityTaskRepository.findById(taskId) + .orElseThrow { IllegalArgumentException("Task not found: $taskId") } + val prompt = buildPrompt(request) + val generatedContent = llamaPreviewService.generate(prompt) + val nextLabel = "Message #${generatedMessageHistoryRepository.countByTask_Id(taskId) + 1}" + + val saved = generatedMessageHistoryRepository.save( + GeneratedMessageHistory( + task = task, + label = nextLabel, + content = generatedContent + ) + ) + + return GeneratedMessageHistoryResponseDto.from(saved) + } + + /** Deletes one history item from a task. */ + @Transactional + fun delete(taskId: UUID, messageId: UUID) { + generatedMessageHistoryRepository.deleteByIdAndTask_Id(messageId, taskId) + } + + private fun buildPrompt(request: TaskPreviewGenerateRequestDto): String { + val entity = request.entity + val task = request.task + + val personality = entity.personality?.takeIf { it.isNotBlank() } ?: "Not provided" + val entityCron = entity.scheduleCron?.takeIf { it.isNotBlank() } ?: "Not provided" + val lookbackLabel = when (task.emailLookback) { + "last_day" -> "Last 24 hours" + "last_month" -> "Last month" + else -> "Last week" + } + + return listOf( + "You are preparing a test email message for a scheduled task in Condado Abaixo da Media SA.", + "", + "ENTITY DETAILS", + "- Entity ID: ${entity.id}", + "- Name: ${entity.name}", + "- Email: ${entity.email}", + "- Job Title: ${entity.jobTitle}", + "- Personality: $personality", + "- Entity Schedule Cron: $entityCron", + "- Context Window Days: ${entity.contextWindowDays}", + "- Active: ${entity.active}", + "", + "TASK DETAILS", + "- Task Name: ${task.name}", + "- Task Prompt: ${task.prompt}", + "- Task Schedule Cron: ${task.scheduleCron}", + "- Email Lookback: $lookbackLabel", + "", + "INSTRUCTIONS", + "- Write exactly one email message.", + "- The message must be written by ${entity.name} (${entity.jobTitle}) as the sender persona.", + "- Use an extremely formal corporate tone.", + "- Keep the content casual, mundane, and slightly nonsensical.", + "- Reflect the entity personality and task prompt faithfully.", + "- Output plain text only with no markdown fences." + ).joinToString("\n") + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 981bf28..2d5fec2 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -48,6 +48,10 @@ openai: api-key: ${OPENAI_API_KEY} model: ${OPENAI_MODEL:gpt-4o} +llama: + base-url: ${LLAMA_BASE_URL:http://localhost:11434} + model: ${LLAMA_MODEL:gemma3:4b} + springdoc: swagger-ui: path: /swagger-ui.html diff --git a/backend/src/test/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageControllerTest.kt b/backend/src/test/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageControllerTest.kt new file mode 100644 index 0000000..9511671 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/controller/TaskGeneratedMessageControllerTest.kt @@ -0,0 +1,141 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.model.GeneratedMessageHistory +import com.condado.newsletter.model.EntityTask +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.EntityTaskRepository +import com.condado.newsletter.repository.GeneratedMessageHistoryRepository +import com.condado.newsletter.repository.VirtualEntityRepository +import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.JwtService +import com.condado.newsletter.service.LlamaPreviewService +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import jakarta.servlet.http.Cookie +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc +class TaskGeneratedMessageControllerTest { + + @Autowired lateinit var mockMvc: MockMvc + @Autowired lateinit var jwtService: JwtService + @Autowired lateinit var generatedMessageHistoryRepository: GeneratedMessageHistoryRepository + @Autowired lateinit var virtualEntityRepository: VirtualEntityRepository + @Autowired lateinit var entityTaskRepository: EntityTaskRepository + + @MockkBean lateinit var entityScheduler: EntityScheduler + @MockkBean lateinit var llamaPreviewService: LlamaPreviewService + + private fun authCookie() = Cookie("jwt", jwtService.generateToken()) + + @AfterEach + fun cleanUp() { + generatedMessageHistoryRepository.deleteAll() + entityTaskRepository.deleteAll() + virtualEntityRepository.deleteAll() + } + + private fun createTask(taskName: String = "Task"): EntityTask { + val entity = virtualEntityRepository.save( + VirtualEntity(name = "Entity A", email = "entity-a@condado.com", jobTitle = "Ops") + ) + return entityTaskRepository.save( + EntityTask( + virtualEntity = entity, + name = taskName, + prompt = "Prompt", + scheduleCron = "0 9 * * 1", + emailLookback = "last_week" + ) + ) + } + + @Test + fun should_returnPersistedHistory_when_getByTaskId() { + val task = createTask() + generatedMessageHistoryRepository.save( + GeneratedMessageHistory( + task = task, + label = "Message #1", + content = "SUBJECT: A\\nBODY:\\nHello" + ) + ) + + mockMvc.perform(get("/api/v1/tasks/${task.id}/generated-messages").cookie(authCookie())) + .andExpect(status().isOk) + .andExpect(jsonPath("$[0].taskId").value(task.id.toString())) + .andExpect(jsonPath("$[0].label").value("Message #1")) + } + + @Test + fun should_generateAndPersistMessage_when_generateEndpointCalled() { + val task = createTask("Morning Blast") + every { llamaPreviewService.generate(any()) } returns "SUBJECT: Generated\\nBODY:\\nFrom backend" + + val payload = """ + { + "entity": { + "id": "${UUID.randomUUID()}", + "name": "Entity A", + "email": "entity@example.com", + "jobTitle": "Ops", + "personality": "Formal", + "scheduleCron": "0 9 * * 1", + "contextWindowDays": 3, + "active": true + }, + "task": { + "entityId": "${UUID.randomUUID()}", + "name": "Morning Blast", + "prompt": "Talk about coffee", + "scheduleCron": "0 8 * * 1-5", + "emailLookback": "last_week" + } + } + """.trimIndent() + + mockMvc.perform( + post("/api/v1/tasks/${task.id}/generated-messages/generate") + .cookie(authCookie()) + .contentType(MediaType.APPLICATION_JSON) + .content(payload) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.taskId").value(task.id.toString())) + .andExpect(jsonPath("$.content").value("SUBJECT: Generated\\nBODY:\\nFrom backend")) + .andExpect(jsonPath("$.label").value("Message #1")) + } + + @Test + fun should_deleteHistoryItem_when_deleteEndpointCalled() { + val task = createTask() + val saved = generatedMessageHistoryRepository.save( + GeneratedMessageHistory( + task = task, + label = "Message #1", + content = "SUBJECT: A\\nBODY:\\nHello" + ) + ) + + mockMvc.perform(delete("/api/v1/tasks/${task.id}/generated-messages/${saved.id}").cookie(authCookie())) + .andExpect(status().isNoContent) + + mockMvc.perform(get("/api/v1/tasks/${task.id}/generated-messages").cookie(authCookie())) + .andExpect(status().isOk) + .andExpect(jsonPath("$").isArray) + .andExpect(jsonPath("$.length()").value(0)) + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt b/backend/src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt index f253abd..2bcf077 100644 --- a/backend/src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt +++ b/backend/src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt @@ -2,6 +2,8 @@ package com.condado.newsletter.repository import com.condado.newsletter.model.DispatchLog import com.condado.newsletter.model.DispatchStatus +import com.condado.newsletter.model.EntityTask +import com.condado.newsletter.model.GeneratedMessageHistory import com.condado.newsletter.model.VirtualEntity import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -9,6 +11,7 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager +import java.util.UUID @DataJpaTest class RepositoryTest { @@ -22,6 +25,12 @@ class RepositoryTest { @Autowired lateinit var dispatchLogRepository: DispatchLogRepository + @Autowired + lateinit var generatedMessageHistoryRepository: GeneratedMessageHistoryRepository + + @Autowired + lateinit var entityTaskRepository: EntityTaskRepository + private lateinit var activeEntity: VirtualEntity private lateinit var inactiveEntity: VirtualEntity @@ -88,4 +97,38 @@ class RepositoryTest { assertThat(result).isPresent assertThat(result.get().id).isEqualTo(latestLog.id) } + + @Test + fun should_returnTaskHistoryOrderedByCreatedAtDesc_when_findAllByTaskIdOrderByCreatedAtDescCalled() { + val task = entityTaskRepository.save( + EntityTask( + virtualEntity = activeEntity, + name = "Task One", + prompt = "Prompt", + scheduleCron = "0 9 * * 1", + emailLookback = "last_week" + ) + ) + generatedMessageHistoryRepository.save( + GeneratedMessageHistory( + task = task, + label = "Message #1", + content = "SUBJECT: One\nBODY:\nFirst" + ) + ) + Thread.sleep(10) + generatedMessageHistoryRepository.save( + GeneratedMessageHistory( + task = task, + label = "Message #2", + content = "SUBJECT: Two\nBODY:\nSecond" + ) + ) + + val result = generatedMessageHistoryRepository.findAllByTask_IdOrderByCreatedAtDesc(task.id!!) + + assertThat(result).hasSize(2) + assertThat(result[0].label).isEqualTo("Message #2") + assertThat(result[1].label).isEqualTo("Message #1") + } } diff --git a/backend/src/test/kotlin/com/condado/newsletter/service/TaskGeneratedMessageServiceTest.kt b/backend/src/test/kotlin/com/condado/newsletter/service/TaskGeneratedMessageServiceTest.kt new file mode 100644 index 0000000..a2c8cc5 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/service/TaskGeneratedMessageServiceTest.kt @@ -0,0 +1,84 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.dto.TaskPreviewEntityDto +import com.condado.newsletter.dto.TaskPreviewGenerateRequestDto +import com.condado.newsletter.dto.TaskPreviewTaskDto +import com.condado.newsletter.model.EntityTask +import com.condado.newsletter.model.GeneratedMessageHistory +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.EntityTaskRepository +import com.condado.newsletter.repository.GeneratedMessageHistoryRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class TaskGeneratedMessageServiceTest { + + private val generatedMessageHistoryRepository: GeneratedMessageHistoryRepository = mockk() + private val entityTaskRepository: EntityTaskRepository = mockk() + private val llamaPreviewService: LlamaPreviewService = mockk() + + private val service = TaskGeneratedMessageService( + generatedMessageHistoryRepository = generatedMessageHistoryRepository, + entityTaskRepository = entityTaskRepository, + llamaPreviewService = llamaPreviewService + ) + + @Test + fun should_generateAndPersistMessage_when_generateAndSaveCalled() { + val taskId = UUID.randomUUID() + val entity = VirtualEntity(name = "Entity", email = "e@x.com", jobTitle = "Ops").apply { id = UUID.randomUUID() } + val task = EntityTask( + virtualEntity = entity, + name = "Task", + prompt = "Prompt", + scheduleCron = "0 9 * * 1", + emailLookback = "last_week" + ).apply { id = taskId } + val captured = slot() + + every { llamaPreviewService.generate(any()) } returns "SUBJECT: Test\nBODY:\nGenerated by backend" + every { entityTaskRepository.findById(taskId) } returns java.util.Optional.of(task) + every { generatedMessageHistoryRepository.countByTask_Id(taskId) } returns 0 + every { generatedMessageHistoryRepository.save(capture(captured)) } answers { + captured.captured.apply { + id = UUID.fromString("00000000-0000-0000-0000-000000000001") + } + } + + val response = service.generateAndSave(taskId, sampleRequest()) + + assertThat(response.id).isEqualTo(UUID.fromString("00000000-0000-0000-0000-000000000001")) + assertThat(response.taskId).isEqualTo(taskId) + assertThat(response.label).isEqualTo("Message #1") + assertThat(response.content).contains("Generated by backend") + assertThat(captured.captured.task.id).isEqualTo(taskId) + + verify(exactly = 1) { llamaPreviewService.generate(any()) } + verify(exactly = 1) { generatedMessageHistoryRepository.save(any()) } + } + + private fun sampleRequest() = TaskPreviewGenerateRequestDto( + entity = TaskPreviewEntityDto( + id = UUID.randomUUID().toString(), + name = "Entity A", + email = "entity@example.com", + jobTitle = "Ops", + personality = "Formal", + scheduleCron = "0 9 * * 1", + contextWindowDays = 3, + active = true + ), + task = TaskPreviewTaskDto( + entityId = UUID.randomUUID().toString(), + name = "Morning Blast", + prompt = "Talk about coffee", + scheduleCron = "0 8 * * 1-5", + emailLookback = "last_week" + ) + ) +} diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index d04c3fb..64dc6e9 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -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 + post: ReturnType + put: ReturnType + delete: ReturnType +} + 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' ) }) }) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index f1c36a7..8ca7c00 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -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 } }) diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index df1c3b5..d61f87e 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -1,8 +1,5 @@ import type { VirtualEntityResponse } from './entitiesApi' - -const STORAGE_KEY = 'condado:entity-tasks' -const OLLAMA_GENERATE_URL = 'http://localhost:11434/api/generate' -const OLLAMA_MODEL = 'gemma3:4b' +import apiClient from './apiClient' export type EmailLookback = 'last_day' | 'last_week' | 'last_month' @@ -32,24 +29,12 @@ export interface TaskPreviewRequest { task: EntityTaskCreateDto } -function readTasks(): EntityTaskResponse[] { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return [] - - try { - return (JSON.parse(raw) as Array & { active?: boolean }>).map( - (task) => ({ - ...task, - active: task.active ?? true, - }) - ) - } catch { - return [] - } -} - -function writeTasks(tasks: EntityTaskResponse[]): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)) +export interface GeneratedMessageHistoryItem { + id: string + taskId: string + label: string + content: string + createdAt: string } export function getEmailLookbackLabel(value: EmailLookback): string { @@ -62,27 +47,6 @@ function getEntityValue(value: string | null | undefined): string { return value && value.trim().length > 0 ? value : 'Not provided' } -async function readOllamaError(response: Response): Promise { - try { - const data = (await response.json()) as { error?: string } - if (data.error?.trim()) { - return data.error - } - } catch { - return `Request failed with status ${response.status}` - } - - return `Request failed with status ${response.status}` -} - -function getReadablePreviewError(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message - } - - return 'Unknown error' -} - export function buildTaskPreviewPrompt( entity: VirtualEntityResponse, task: EntityTaskCreateDto @@ -117,132 +81,94 @@ export function buildTaskPreviewPrompt( } /** Generates a task preview via the local Ollama model. */ -export async function generateTaskPreview({ entity, task }: TaskPreviewRequest): Promise { - const prompt = buildTaskPreviewPrompt(entity, task) - - try { - const response = await fetch(OLLAMA_GENERATE_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: OLLAMA_MODEL, - prompt, - stream: false, - }), - }) - - if (!response.ok) { - const message = await readOllamaError(response) - throw new Error(`Unable to generate a test message from the local model. ${message}`) - } - - const data = (await response.json()) as { response?: string } - const generatedMessage = data.response?.trim() - - if (!generatedMessage) { - throw new Error('Unable to generate a test message from the local model. The model returned an empty response.') - } - - return generatedMessage - } catch (error) { - const message = getReadablePreviewError(error) - - if (message.startsWith('Unable to generate a test message from the local model.')) { - throw error - } - - throw new Error(`Unable to generate a test message from the local model. ${message}`) - } +export async function generateTaskPreview( + taskId: string, + payload: TaskPreviewRequest +): Promise { + const response = await apiClient.post( + `/v1/tasks/${taskId}/generated-messages/generate`, + payload + ) + return response.data.content } -/** Returns all scheduled tasks currently configured in local storage. */ +/** Returns generated message history for one task. */ +export async function getTaskGeneratedMessages( + taskId: string +): Promise { + const response = await apiClient.get( + `/v1/tasks/${taskId}/generated-messages` + ) + return response.data +} + +/** Deletes one generated message from a task history. */ +export async function deleteTaskGeneratedMessage(taskId: string, messageId: string): Promise { + await apiClient.delete(`/v1/tasks/${taskId}/generated-messages/${messageId}`) +} + +/** Returns all active scheduled tasks from backend. */ export async function getAllTasks(): Promise { - return readTasks().filter((task) => task.active) + const response = await apiClient.get('/v1/tasks') + return response.data } -/** Returns scheduled tasks for a specific entity. */ +/** Returns all tasks for a specific entity, including inactive ones. */ export async function getTasksByEntity(entityId: string): Promise { - return readTasks().filter((task) => task.entityId === entityId) + const response = await apiClient.get(`/v1/tasks/entity/${entityId}`) + return response.data } /** Returns one scheduled task by identifier. */ export async function getTask(taskId: string): Promise { - return readTasks().find((task) => task.id === taskId) ?? null -} - -/** Creates a scheduled task in local storage. */ -export async function createTask(data: EntityTaskCreateDto): Promise { - const current = readTasks() - const task: EntityTaskResponse = { - ...data, - id: crypto.randomUUID(), - active: true, - createdAt: new Date().toISOString(), + try { + const response = await apiClient.get(`/v1/tasks/${taskId}`) + return response.data + } catch { + return null } - - current.push(task) - writeTasks(current) - return task } -/** Updates one scheduled task in local storage. */ +/** Creates a scheduled task. */ +export async function createTask(data: EntityTaskCreateDto): Promise { + const response = await apiClient.post('/v1/tasks', data) + return response.data +} + +/** Updates one scheduled task. */ export async function updateTask( taskId: string, data: EntityTaskUpdateDto ): Promise { - const current = readTasks() - const existingTask = current.find((task) => task.id === taskId) - - if (!existingTask) { + try { + const response = await apiClient.put(`/v1/tasks/${taskId}`, data) + return response.data + } catch { return null } - - const updatedTask: EntityTaskResponse = { - ...existingTask, - ...data, - } - - writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task))) - return updatedTask } -/** Marks one scheduled task as inactive in local storage. */ +/** Marks one scheduled task as inactive. */ export async function inactivateTask(taskId: string): Promise { - const current = readTasks() - const existingTask = current.find((task) => task.id === taskId) - - if (!existingTask) { + try { + const response = await apiClient.post(`/v1/tasks/${taskId}/inactivate`) + return response.data + } catch { return null } - - const updatedTask: EntityTaskResponse = { - ...existingTask, - active: false, - } - - writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task))) - return updatedTask } -/** Marks one scheduled task as active in local storage. */ +/** Marks one scheduled task as active. */ export async function activateTask(taskId: string): Promise { - const current = readTasks() - const existingTask = current.find((task) => task.id === taskId) - - if (!existingTask) { + try { + const response = await apiClient.post(`/v1/tasks/${taskId}/activate`) + return response.data + } catch { return null } - - const updatedTask: EntityTaskResponse = { - ...existingTask, - active: true, - } - - writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task))) - return updatedTask } -/** Deletes one scheduled task from local storage. */ +/** Deletes one scheduled task. */ export async function deleteTask(taskId: string): Promise { - writeTasks(readTasks().filter((task) => task.id !== taskId)) + await apiClient.delete(`/v1/tasks/${taskId}`) } diff --git a/frontend/src/pages/EditTaskPage.tsx b/frontend/src/pages/EditTaskPage.tsx index 0a37ae9..dcb99ee 100644 --- a/frontend/src/pages/EditTaskPage.tsx +++ b/frontend/src/pages/EditTaskPage.tsx @@ -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(DEFAULT_CRON_PARTS) const [taskForm, setTaskForm] = useState(DEFAULT_TASK_FORM) - const [generatedMessages, setGeneratedMessages] = useState([]) 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[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() {