feat(backend): implement step 9 — REST controllers, DTOs, EntityService, SecurityConfig (permit-all)

This commit is contained in:
2026-03-26 19:01:37 -03:00
parent 47704c2ef2
commit 731c80a2bc
8 changed files with 305 additions and 0 deletions

View File

@@ -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()
}
}

View File

@@ -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<List<DispatchLogResponseDto>> =
ResponseEntity.ok(dispatchLogRepository.findAll().map { DispatchLogResponseDto.from(it) })
/** Lists dispatch logs for a specific entity. */
@GetMapping("/entity/{entityId}")
fun getByEntityId(@PathVariable entityId: UUID): ResponseEntity<List<DispatchLogResponseDto>> {
val entity = virtualEntityRepository.findById(entityId).orElse(null)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(
dispatchLogRepository.findAllByVirtualEntity(entity).map { DispatchLogResponseDto.from(it) }
)
}
}

View File

@@ -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<VirtualEntityResponseDto> =
ResponseEntity.status(HttpStatus.CREATED).body(entityService.create(dto))
/** Lists all virtual entities. */
@GetMapping
fun getAll(): ResponseEntity<List<VirtualEntityResponseDto>> =
ResponseEntity.ok(entityService.findAll())
/** Returns one entity by ID. */
@GetMapping("/{id}")
fun getById(@PathVariable id: UUID): ResponseEntity<VirtualEntityResponseDto> {
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<VirtualEntityResponseDto> {
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<VirtualEntityResponseDto> {
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<Void> {
val entity = entityService.findRawById(id) ?: return ResponseEntity.notFound().build()
entityScheduler.runPipeline(entity)
return ResponseEntity.ok().build()
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)

View File

@@ -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
)
}
}

View File

@@ -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
)

View File

@@ -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<VirtualEntityResponseDto> =
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)
}