From 731c80a2bc4bd80e05117eddd2ab0be3c97a489e Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Thu, 26 Mar 2026 19:01:37 -0300 Subject: [PATCH] =?UTF-8?q?feat(backend):=20implement=20step=209=20?= =?UTF-8?q?=E2=80=94=20REST=20controllers,=20DTOs,=20EntityService,=20Secu?= =?UTF-8?q?rityConfig=20(permit-all)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newsletter/config/SecurityConfig.kt | 24 ++++++ .../controller/DispatchLogController.kt | 37 +++++++++ .../controller/VirtualEntityController.kt | 72 +++++++++++++++++ .../newsletter/dto/DispatchLogResponseDto.kt | 35 ++++++++ .../newsletter/dto/VirtualEntityCreateDto.kt | 14 ++++ .../dto/VirtualEntityResponseDto.kt | 32 ++++++++ .../newsletter/dto/VirtualEntityUpdateDto.kt | 12 +++ .../newsletter/service/EntityService.kt | 79 +++++++++++++++++++ 8 files changed, 305 insertions(+) create mode 100644 backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/controller/DispatchLogController.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/controller/VirtualEntityController.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/dto/DispatchLogResponseDto.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityCreateDto.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityResponseDto.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityUpdateDto.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/service/EntityService.kt diff --git a/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt b/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt new file mode 100644 index 0000000..9b46412 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt @@ -0,0 +1,24 @@ +package com.condado.newsletter.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +/** + * Security configuration — Step 9: permits all requests. + * Will be updated in Step 10 with JWT authentication. + */ +@Configuration +@EnableWebSecurity +class SecurityConfig { + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .authorizeHttpRequests { it.anyRequest().permitAll() } + return http.build() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/DispatchLogController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/DispatchLogController.kt new file mode 100644 index 0000000..ab48e63 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/DispatchLogController.kt @@ -0,0 +1,37 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.DispatchLogResponseDto +import com.condado.newsletter.repository.DispatchLogRepository +import com.condado.newsletter.repository.VirtualEntityRepository +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * REST controller for read-only access to [com.condado.newsletter.model.DispatchLog] resources. + */ +@RestController +@RequestMapping("/api/v1/dispatch-logs") +class DispatchLogController( + private val dispatchLogRepository: DispatchLogRepository, + private val virtualEntityRepository: VirtualEntityRepository +) { + + /** Lists all dispatch logs. */ + @GetMapping + fun getAll(): ResponseEntity> = + ResponseEntity.ok(dispatchLogRepository.findAll().map { DispatchLogResponseDto.from(it) }) + + /** Lists dispatch logs for a specific entity. */ + @GetMapping("/entity/{entityId}") + fun getByEntityId(@PathVariable entityId: UUID): ResponseEntity> { + val entity = virtualEntityRepository.findById(entityId).orElse(null) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok( + dispatchLogRepository.findAllByVirtualEntity(entity).map { DispatchLogResponseDto.from(it) } + ) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/VirtualEntityController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/VirtualEntityController.kt new file mode 100644 index 0000000..58479d4 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/VirtualEntityController.kt @@ -0,0 +1,72 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.VirtualEntityCreateDto +import com.condado.newsletter.dto.VirtualEntityResponseDto +import com.condado.newsletter.dto.VirtualEntityUpdateDto +import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.EntityService +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 managing [com.condado.newsletter.model.VirtualEntity] resources. + */ +@RestController +@RequestMapping("/api/v1/virtual-entities") +class VirtualEntityController( + private val entityService: EntityService, + private val entityScheduler: EntityScheduler +) { + + /** Creates a new virtual entity. */ + @PostMapping + fun create(@Valid @RequestBody dto: VirtualEntityCreateDto): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED).body(entityService.create(dto)) + + /** Lists all virtual entities. */ + @GetMapping + fun getAll(): ResponseEntity> = + ResponseEntity.ok(entityService.findAll()) + + /** Returns one entity by ID. */ + @GetMapping("/{id}") + fun getById(@PathVariable id: UUID): ResponseEntity { + val entity = entityService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(entity) + } + + /** Updates an entity. */ + @PutMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestBody dto: VirtualEntityUpdateDto + ): ResponseEntity { + val entity = entityService.update(id, dto) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(entity) + } + + /** Soft-deletes an entity (sets active = false). */ + @DeleteMapping("/{id}") + fun deactivate(@PathVariable id: UUID): ResponseEntity { + val entity = entityService.deactivate(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(entity) + } + + /** Manually triggers the email pipeline for a specific entity. */ + @PostMapping("/{id}/trigger") + fun trigger(@PathVariable id: UUID): ResponseEntity { + val entity = entityService.findRawById(id) ?: return ResponseEntity.notFound().build() + entityScheduler.runPipeline(entity) + return ResponseEntity.ok().build() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/DispatchLogResponseDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/DispatchLogResponseDto.kt new file mode 100644 index 0000000..bb5d612 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/DispatchLogResponseDto.kt @@ -0,0 +1,35 @@ +package com.condado.newsletter.dto + +import com.condado.newsletter.model.DispatchLog +import com.condado.newsletter.model.DispatchStatus +import java.time.LocalDateTime +import java.util.UUID + +/** DTO returned by the API for a [com.condado.newsletter.model.DispatchLog]. */ +data class DispatchLogResponseDto( + val id: UUID?, + val entityId: UUID?, + val entityName: String, + val promptSent: String?, + val aiResponse: String?, + val emailSubject: String?, + val emailBody: String?, + val status: DispatchStatus, + val errorMessage: String?, + val dispatchedAt: LocalDateTime +) { + companion object { + fun from(log: DispatchLog) = DispatchLogResponseDto( + id = log.id, + entityId = log.virtualEntity.id, + entityName = log.virtualEntity.name, + promptSent = log.promptSent, + aiResponse = log.aiResponse, + emailSubject = log.emailSubject, + emailBody = log.emailBody, + status = log.status, + errorMessage = log.errorMessage, + dispatchedAt = log.dispatchedAt + ) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityCreateDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityCreateDto.kt new file mode 100644 index 0000000..10a0738 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityCreateDto.kt @@ -0,0 +1,14 @@ +package com.condado.newsletter.dto + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +/** DTO for creating a new [com.condado.newsletter.model.VirtualEntity]. */ +data class VirtualEntityCreateDto( + @field:NotBlank val name: String, + @field:NotBlank @field:Email val email: String, + @field:NotBlank val jobTitle: String, + val personality: String? = null, + val scheduleCron: String? = null, + val contextWindowDays: Int = 3 +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityResponseDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityResponseDto.kt new file mode 100644 index 0000000..c379b8a --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityResponseDto.kt @@ -0,0 +1,32 @@ +package com.condado.newsletter.dto + +import com.condado.newsletter.model.VirtualEntity +import java.time.LocalDateTime +import java.util.UUID + +/** DTO returned by the API for a [com.condado.newsletter.model.VirtualEntity]. */ +data class VirtualEntityResponseDto( + val id: UUID?, + val name: String, + val email: String, + val jobTitle: String, + val personality: String?, + val scheduleCron: String?, + val contextWindowDays: Int, + val active: Boolean, + val createdAt: LocalDateTime? +) { + companion object { + fun from(entity: VirtualEntity) = VirtualEntityResponseDto( + id = entity.id, + name = entity.name, + email = entity.email, + jobTitle = entity.jobTitle, + personality = entity.personality, + scheduleCron = entity.scheduleCron, + contextWindowDays = entity.contextWindowDays, + active = entity.active, + createdAt = entity.createdAt + ) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityUpdateDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityUpdateDto.kt new file mode 100644 index 0000000..28b9a41 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityUpdateDto.kt @@ -0,0 +1,12 @@ +package com.condado.newsletter.dto + +/** DTO for updating an existing [com.condado.newsletter.model.VirtualEntity]. All fields are optional. */ +data class VirtualEntityUpdateDto( + val name: String? = null, + val email: String? = null, + val jobTitle: String? = null, + val personality: String? = null, + val scheduleCron: String? = null, + val contextWindowDays: Int? = null, + val active: Boolean? = null +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/EntityService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/EntityService.kt new file mode 100644 index 0000000..225938e --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/EntityService.kt @@ -0,0 +1,79 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.dto.VirtualEntityCreateDto +import com.condado.newsletter.dto.VirtualEntityResponseDto +import com.condado.newsletter.dto.VirtualEntityUpdateDto +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.DispatchLogRepository +import com.condado.newsletter.repository.VirtualEntityRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Service for managing [VirtualEntity] lifecycle: create, read, update, and soft-delete. + */ +@Service +class EntityService( + private val virtualEntityRepository: VirtualEntityRepository, + private val dispatchLogRepository: DispatchLogRepository +) { + + /** Returns all virtual entities. */ + fun findAll(): List = + virtualEntityRepository.findAll().map { VirtualEntityResponseDto.from(it) } + + /** Returns one entity by ID, or null if not found. */ + fun findById(id: UUID): VirtualEntityResponseDto? = + virtualEntityRepository.findById(id).map { VirtualEntityResponseDto.from(it) }.orElse(null) + + /** Creates a new virtual entity. */ + @Transactional + fun create(dto: VirtualEntityCreateDto): VirtualEntityResponseDto { + val entity = VirtualEntity( + name = dto.name, + email = dto.email, + jobTitle = dto.jobTitle, + personality = dto.personality, + scheduleCron = dto.scheduleCron, + contextWindowDays = dto.contextWindowDays + ) + return VirtualEntityResponseDto.from(virtualEntityRepository.save(entity)) + } + + /** Applies partial updates to an existing entity. Returns null if not found. */ + @Transactional + fun update(id: UUID, dto: VirtualEntityUpdateDto): VirtualEntityResponseDto? { + val existing = virtualEntityRepository.findById(id).orElse(null) ?: return null + val updated = VirtualEntity( + name = dto.name ?: existing.name, + email = dto.email ?: existing.email, + jobTitle = dto.jobTitle ?: existing.jobTitle, + personality = dto.personality ?: existing.personality, + scheduleCron = dto.scheduleCron ?: existing.scheduleCron, + contextWindowDays = dto.contextWindowDays ?: existing.contextWindowDays, + active = dto.active ?: existing.active + ).apply { this.id = existing.id } + return VirtualEntityResponseDto.from(virtualEntityRepository.save(updated)) + } + + /** Soft-deletes an entity by setting active = false. Returns null if not found. */ + @Transactional + fun deactivate(id: UUID): VirtualEntityResponseDto? { + val existing = virtualEntityRepository.findById(id).orElse(null) ?: return null + val deactivated = VirtualEntity( + name = existing.name, + email = existing.email, + jobTitle = existing.jobTitle, + personality = existing.personality, + scheduleCron = existing.scheduleCron, + contextWindowDays = existing.contextWindowDays, + active = false + ).apply { this.id = existing.id } + return VirtualEntityResponseDto.from(virtualEntityRepository.save(deactivated)) + } + + /** Finds the raw entity for use in the scheduler pipeline. Returns null if not found. */ + fun findRawById(id: UUID): VirtualEntity? = + virtualEntityRepository.findById(id).orElse(null) +}