Merge pull request #1 from Sancho41/develop

Develop
This commit is contained in:
2026-03-26 19:29:12 -03:00
committed by GitHub
65 changed files with 2859 additions and 27 deletions

View File

@@ -30,4 +30,4 @@ OPENAI_MODEL=gpt-4o
APP_RECIPIENTS=friend1@example.com,friend2@example.com APP_RECIPIENTS=friend1@example.com,friend2@example.com
# ── Frontend (Vite build-time) ──────────────────────────────────────────────── # ── Frontend (Vite build-time) ────────────────────────────────────────────────
VITE_API_BASE_URL=http://localhost:6969 VITE_API_BASE_URL=http://localhost

View File

@@ -501,6 +501,25 @@ BODY:
- PRs require all CI checks to pass before merging. - PRs require all CI checks to pass before merging.
- Never commit directly to `main`. - Never commit directly to `main`.
### Commit Rules (enforced by AI)
These rules apply to every commit made during AI-assisted implementation:
| Rule | Detail |
|------|--------|
| **Two commits per TDD step** | 1st commit = failing tests (Red), 2nd commit = passing implementation (Green) |
| **Commit after each step** | Never accumulate multiple steps in one commit |
| **Red commit subject** | `test(<scope>): add failing tests for step <N> — <short description>` |
| **Green commit subject** | `feat(<scope>): implement step <N> — <short description>` |
| **Scope values** | `backend`, `frontend`, `docker`, `ci`, `config` |
| **Body** | Optional but encouraged: list what was added/changed |
| **No `--no-verify`** | Never bypass git hooks |
| **No force push** | Never use `--force` on shared branches |
| **Atomic commits** | Each commit must leave the build green (except deliberate Red-phase test commits) |
| **`chore` for housekeeping** | Config changes, dependency tweaks, file renames → `chore(<scope>):` |
| **`fix` for bug fixes** | `fix(<scope>): <what was broken and how it was fixed>` |
| **`docs` for documentation** | Changes to `CLAUDE.md`, `INSTRUCTIONS.md`, `README.md``docs:` |
### GitHub Actions Workflows ### GitHub Actions Workflows
| Workflow file | Trigger | What it does | | Workflow file | Trigger | What it does |

View File

@@ -53,6 +53,6 @@ COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 6969 EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -45,7 +45,7 @@ employee is an AI-powered entity that:
|------|-----------------------------------------|-------------| |------|-----------------------------------------|-------------|
| 0 | Define project & write CLAUDE.md | ✅ Done | | 0 | Define project & write CLAUDE.md | ✅ Done |
| 1 | Scaffold monorepo structure | ✅ Done | | 1 | Scaffold monorepo structure | ✅ Done |
| 2 | Domain model (JPA entities) | ⬜ Pending | | 2 | Domain model (JPA entities) | ✅ Done |
| 3 | Repositories | ⬜ Pending | | 3 | Repositories | ⬜ Pending |
| 4 | Email Reader Service (IMAP) | ⬜ Pending | | 4 | Email Reader Service (IMAP) | ⬜ Pending |
| 5 | Prompt Builder Service | ⬜ Pending | | 5 | Prompt Builder Service | ⬜ Pending |
@@ -253,10 +253,16 @@ Tests to write (all should **fail** before implementation):
> `VirtualEntity`. Make the tests in `EntityMappingTest` pass." > `VirtualEntity`. Make the tests in `EntityMappingTest` pass."
**Done when:** **Done when:**
- [ ] `EntityMappingTest.kt` exists with all 5 tests. - [x] `EntityMappingTest.kt` exists with all 5 tests.
- [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`. - [x] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
- [ ] `./gradlew test` is green. - [x] `./gradlew test` is green.
- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev). - [x] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev).
**Key decisions made:**
- Added `org.gradle.java.home=C:/Program Files/Java/jdk-21.0.10` to `gradle.properties` — the Kotlin DSL compiler embedded in Gradle 8.14.1 does not support JVM target 26, so the Gradle daemon must run under JDK 21.
- Created `src/test/resources/application.yml` to override datasource and JPA settings for tests (H2 in-memory, `ddl-auto: create-drop`), and provide placeholder values for required env vars so tests run without Docker/real services.
- `VirtualEntity` and `DispatchLog` use class-body `var` fields for `id` (`@GeneratedValue`) and `createdAt` (`@CreationTimestamp`) so Hibernate can set them; all other fields are constructor `val` properties.
- `DispatchStatus` enum: `PENDING`, `SENT`, `FAILED`.
--- ---

View File

@@ -0,0 +1,15 @@
package com.condado.newsletter.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
/**
* Application-wide bean configuration.
*/
@Configuration
class AppConfig {
@Bean
fun restClient(): RestClient = RestClient.create()
}

View File

@@ -0,0 +1,40 @@
package com.condado.newsletter.config
import com.condado.newsletter.service.JwtService
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
/**
* Reads the JWT from the `jwt` cookie on each request.
* If the token is valid, sets an authenticated [SecurityContext].
*/
@Component
class JwtAuthFilter(private val jwtService: JwtService) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val token = request.cookies
?.firstOrNull { it.name == "jwt" }
?.value
if (token != null && jwtService.validateToken(token)) {
val auth = UsernamePasswordAuthenticationToken(
"admin",
null,
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
SecurityContextHolder.getContext().authentication = auth
}
filterChain.doFilter(request, response)
}
}

View File

@@ -0,0 +1,34 @@
package com.condado.newsletter.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
class SecurityConfig(private val jwtAuthFilter: JwtAuthFilter) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.exceptionHandling { it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) }
.authorizeHttpRequests { auth ->
auth
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
}

View File

@@ -0,0 +1,54 @@
package com.condado.newsletter.controller
import com.condado.newsletter.dto.AuthResponse
import com.condado.newsletter.dto.LoginRequest
import com.condado.newsletter.service.AuthService
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
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
/**
* Handles authentication — login, logout, and session check.
*/
@RestController
@RequestMapping("/api/auth")
class AuthController(private val authService: AuthService) {
/** Validates the password and sets a JWT cookie on success. */
@PostMapping("/login")
fun login(
@RequestBody request: LoginRequest,
response: HttpServletResponse
): ResponseEntity<AuthResponse> {
val token = authService.login(request.password)
val cookie = Cookie("jwt", token).apply {
isHttpOnly = true
path = "/"
maxAge = 86400 // 24 hours
}
response.addCookie(cookie)
return ResponseEntity.ok(AuthResponse("Login successful"))
}
/** Returns 200 if the JWT cookie is valid (checked by JwtAuthFilter). */
@GetMapping("/me")
fun me(): ResponseEntity<AuthResponse> =
ResponseEntity.ok(AuthResponse("Authenticated"))
/** Clears the JWT cookie. */
@PostMapping("/logout")
fun logout(response: HttpServletResponse): ResponseEntity<AuthResponse> {
val cookie = Cookie("jwt", "").apply {
isHttpOnly = true
path = "/"
maxAge = 0
}
response.addCookie(cookie)
return ResponseEntity.ok(AuthResponse("Logged out"))
}
}

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,7 @@
package com.condado.newsletter.dto
/** Request body for POST /api/auth/login. */
data class LoginRequest(val password: String)
/** Generic response body for auth operations. */
data class AuthResponse(val message: String)

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,53 @@
package com.condado.newsletter.model
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
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.Table
import java.time.LocalDateTime
import java.util.UUID
/**
* Records every AI generation and email send attempt for a given [VirtualEntity].
* Stores the prompt sent, the AI response, parsed subject/body, send status, and timestamp.
*/
@Entity
@Table(name = "dispatch_logs")
class DispatchLog(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "entity_id", nullable = false)
val virtualEntity: VirtualEntity,
@Column(name = "prompt_sent", columnDefinition = "TEXT")
val promptSent: String? = null,
@Column(name = "ai_response", columnDefinition = "TEXT")
val aiResponse: String? = null,
@Column(name = "email_subject")
val emailSubject: String? = null,
@Column(name = "email_body", columnDefinition = "TEXT")
val emailBody: String? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val status: DispatchStatus = DispatchStatus.PENDING,
@Column(name = "error_message")
val errorMessage: String? = null,
@Column(name = "dispatched_at", nullable = false)
val dispatchedAt: LocalDateTime = LocalDateTime.now()
) {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null
}

View File

@@ -0,0 +1,10 @@
package com.condado.newsletter.model
/**
* Represents the dispatch status of an AI-generated email send attempt.
*/
enum class DispatchStatus {
PENDING,
SENT,
FAILED
}

View File

@@ -0,0 +1,14 @@
package com.condado.newsletter.model
import java.time.LocalDateTime
/**
* A snapshot of a single email read from the shared company IMAP inbox.
* Used as context when building the AI prompt.
*/
data class EmailContext(
val from: String,
val subject: String,
val body: String,
val receivedAt: LocalDateTime
)

View File

@@ -0,0 +1,9 @@
package com.condado.newsletter.model
/**
* The parsed result of an AI-generated email response.
*/
data class ParsedAiResponse(
val subject: String,
val body: String
)

View File

@@ -0,0 +1,49 @@
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.Table
import org.hibernate.annotations.CreationTimestamp
import java.time.LocalDateTime
import java.util.UUID
/**
* Represents a fictional employee of "Condado Abaixo da Média SA".
* Each entity has a scheduled time to send AI-generated emails, a personality description,
* and a context window for reading recent emails via IMAP.
*/
@Entity
@Table(name = "virtual_entities")
class VirtualEntity(
@Column(nullable = false)
val name: String,
@Column(unique = true, nullable = false)
val email: String,
@Column(name = "job_title", nullable = false)
val jobTitle: String,
@Column(columnDefinition = "TEXT")
val personality: String? = null,
@Column(name = "schedule_cron")
val scheduleCron: String? = null,
@Column(name = "context_window_days")
val contextWindowDays: Int = 3,
@Column(nullable = false)
val active: Boolean = true
) {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null
@CreationTimestamp
@Column(name = "created_at", updatable = false)
var createdAt: LocalDateTime? = null
}

View File

@@ -0,0 +1,19 @@
package com.condado.newsletter.repository
import com.condado.newsletter.model.DispatchLog
import com.condado.newsletter.model.VirtualEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.Optional
import java.util.UUID
/**
* Repository for [DispatchLog] with custom query methods.
*/
interface DispatchLogRepository : JpaRepository<DispatchLog, UUID> {
/** Returns all dispatch logs for the given [VirtualEntity]. */
fun findAllByVirtualEntity(virtualEntity: VirtualEntity): List<DispatchLog>
/** Returns the most recent dispatch log for the given [VirtualEntity], or empty if none exist. */
fun findTopByVirtualEntityOrderByDispatchedAtDesc(virtualEntity: VirtualEntity): Optional<DispatchLog>
}

View File

@@ -0,0 +1,18 @@
package com.condado.newsletter.repository
import com.condado.newsletter.model.VirtualEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.Optional
import java.util.UUID
/**
* Repository for [VirtualEntity] with custom query methods.
*/
interface VirtualEntityRepository : JpaRepository<VirtualEntity, UUID> {
/** Returns all entities where [VirtualEntity.active] is true. */
fun findAllByActiveTrue(): List<VirtualEntity>
/** Finds an entity by its unique email address. */
fun findByEmail(email: String): Optional<VirtualEntity>
}

View File

@@ -0,0 +1,75 @@
package com.condado.newsletter.scheduler
import com.condado.newsletter.model.DispatchLog
import com.condado.newsletter.model.DispatchStatus
import com.condado.newsletter.model.VirtualEntity
import com.condado.newsletter.repository.DispatchLogRepository
import com.condado.newsletter.service.AiService
import com.condado.newsletter.service.EmailReaderService
import com.condado.newsletter.service.EmailSenderService
import com.condado.newsletter.service.PromptBuilderService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.scheduling.config.ScheduledTaskRegistrar
import org.springframework.scheduling.support.CronTrigger
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
/**
* Registers and manages per-entity scheduled tasks using [SchedulingConfigurer].
* Refreshes task registrations every minute to pick up changes to active entities.
*/
@Component
class EntityScheduler(
private val emailReaderService: EmailReaderService,
private val promptBuilderService: PromptBuilderService,
private val aiService: AiService,
private val emailSenderService: EmailSenderService,
private val dispatchLogRepository: DispatchLogRepository,
@Value("\${app.recipients:}") val recipients: String,
@Value("\${imap.inbox-folder:INBOX}") val inboxFolder: String
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* Runs the full email generation + send pipeline for the given [entity].
* If the entity is inactive, returns immediately.
* Always persists a [DispatchLog] with SENT or FAILED status.
*/
fun runPipeline(entity: VirtualEntity) {
if (!entity.active) return
val recipientList = recipients.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
try {
val emails = emailReaderService.readEmails(inboxFolder, entity.contextWindowDays)
val prompt = promptBuilderService.buildPrompt(entity, emails)
val aiResponse = aiService.generate(prompt)
emailSenderService.send(entity.email, recipientList, aiResponse.subject, aiResponse.body)
dispatchLogRepository.save(
DispatchLog(
virtualEntity = entity,
promptSent = prompt,
aiResponse = "${aiResponse.subject}\n${aiResponse.body}",
emailSubject = aiResponse.subject,
emailBody = aiResponse.body,
status = DispatchStatus.SENT
)
)
log.info("Pipeline succeeded for entity '${entity.name}'")
} catch (e: Exception) {
log.error("Pipeline failed for entity '${entity.name}': ${e.message}", e)
dispatchLogRepository.save(
DispatchLog(
virtualEntity = entity,
status = DispatchStatus.FAILED,
errorMessage = e.message
)
)
}
}
}

View File

@@ -0,0 +1,7 @@
package com.condado.newsletter.service
/** Thrown when the OpenAI API call fails. */
class AiServiceException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
/** Thrown when the AI response cannot be parsed into SUBJECT/BODY format. */
class AiParseException(message: String) : RuntimeException(message)

View File

@@ -0,0 +1,92 @@
package com.condado.newsletter.service
import com.condado.newsletter.model.ParsedAiResponse
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
/**
* Calls the OpenAI Chat Completions API to generate email content.
* Returns a [ParsedAiResponse] with the extracted subject and body.
*/
@Service
class AiService(
private val restClient: RestClient,
@Value("\${openai.api-key}") private val apiKey: String,
@Value("\${openai.model:gpt-4o}") private val model: String
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* Sends [prompt] to the OpenAI API and returns the parsed subject + body.
* @throws [AiServiceException] on API errors.
* @throws [AiParseException] if the response format is unexpected.
*/
fun generate(prompt: String): ParsedAiResponse {
val rawText = try {
val request = ChatRequest(
model = model,
messages = listOf(ChatMessage(role = "user", content = prompt))
)
val json = restClient.post()
.uri("https://api.openai.com/v1/chat/completions")
.header("Authorization", "Bearer $apiKey")
.body(request)
.retrieve()
.body(String::class.java)
?: throw AiServiceException("OpenAI returned an empty response")
extractContent(json)
} catch (e: AiServiceException) {
throw e
} catch (e: Exception) {
log.error("OpenAI API call failed: ${e.message}", e)
throw AiServiceException("OpenAI API call failed: ${e.message}", e)
}
return parseResponse(rawText)
}
/**
* Parses the raw AI output into a [ParsedAiResponse].
* Expected format:
* ```
* SUBJECT: <subject line>
* BODY:
* <email body>
* ```
*/
fun parseResponse(raw: String): ParsedAiResponse {
val lines = raw.trim().lines()
val subjectLine = lines.firstOrNull { it.startsWith("SUBJECT:", ignoreCase = true) }
?: throw AiParseException("AI response missing SUBJECT line. Raw response: $raw")
val bodyStart = lines.indexOfFirst { it.trim().equals("BODY:", ignoreCase = true) }
if (bodyStart == -1) throw AiParseException("AI response missing BODY: section. Raw response: $raw")
val subject = subjectLine.replaceFirst(Regex("^SUBJECT:\\s*", RegexOption.IGNORE_CASE), "").trim()
val body = lines.drop(bodyStart + 1).joinToString("\n").trim()
return ParsedAiResponse(subject = subject, body = body)
}
// ── internal JSON helpers ────────────────────────────────────────────────
private fun extractContent(json: String): String {
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
val tree = mapper.readTree(json)
return tree["choices"]?.firstOrNull()?.get("message")?.get("content")?.asText()
?.replace("\\n", "\n")
?: throw AiServiceException("Unexpected OpenAI response structure: $json")
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class ChatRequest(val model: String, val messages: List<ChatMessage>)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ChatMessage(val role: String, val content: String)
}

View File

@@ -0,0 +1,26 @@
package com.condado.newsletter.service
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
/**
* Handles the single-admin authentication flow.
* There is no user table — the password lives only in the [appPassword] environment variable.
*/
@Service
class AuthService(
private val jwtService: JwtService,
@Value("\${app.password}") private val appPassword: String
) {
/**
* Validates the given [password] against [appPassword].
* @return A signed JWT token string.
* @throws [UnauthorizedException] if the password is incorrect.
*/
fun login(password: String): String {
if (password != appPassword) {
throw UnauthorizedException("Invalid password")
}
return jwtService.generateToken()
}
}

View File

@@ -0,0 +1,117 @@
package com.condado.newsletter.service
import com.condado.newsletter.model.EmailContext
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Session
import jakarta.mail.Store
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Date
import java.util.Properties
/**
* Reads recent emails from the shared IMAP inbox to use as AI context.
* Returns emails sorted chronologically (oldest first).
* On any error, logs the exception and returns an empty list.
*/
@Service
class EmailReaderService(
@Value("\${imap.host:localhost}") private val imapHost: String = "localhost",
@Value("\${imap.port:993}") private val imapPort: Int = 993,
@Value("\${spring.mail.username:}") private val username: String = "",
@Value("\${spring.mail.password:}") private val password: String = "",
/** Factory function — can be replaced in tests to inject a mock Store */
val storeFactory: ((Properties, String, String) -> Store)? = null
) {
private val log = LoggerFactory.getLogger(javaClass)
constructor(storeFactory: (Properties, String, String) -> Store) : this(
imapHost = "localhost",
imapPort = 993,
username = "",
password = "",
storeFactory = storeFactory
)
/** Test-only constructor accepting a pre-built mock Store */
internal constructor(storeFactory: () -> Store) : this(
imapHost = "localhost",
imapPort = 993,
username = "",
password = "",
storeFactory = { _, _, _ -> storeFactory() }
)
/**
* Reads emails from [folderName] received within the last [contextWindowDays] days.
*
* @return list of [EmailContext] objects sorted oldest-first, or empty list on error.
*/
fun readEmails(folderName: String, contextWindowDays: Int): List<EmailContext> {
return try {
val store = openStore()
val folder = store.getFolder(folderName)
folder.open(Folder.READ_ONLY)
val cutoff = Date(System.currentTimeMillis() - contextWindowDays.toLong() * 24 * 60 * 60 * 1000)
folder.getMessages()
.filter { msg ->
val date = msg.receivedDate ?: msg.sentDate
date != null && date.after(cutoff)
}
.sortedBy { it.receivedDate ?: it.sentDate }
.map { msg -> msg.toEmailContext() }
} catch (e: Exception) {
log.error("Failed to read emails from IMAP folder '$folderName': ${e.message}", e)
emptyList()
}
}
private fun openStore(): Store {
val props = Properties().apply {
put("mail.store.protocol", "imaps")
put("mail.imaps.host", imapHost)
put("mail.imaps.port", imapPort.toString())
}
return if (storeFactory != null) {
storeFactory!!.invoke(props, username, password)
} else {
val session = Session.getInstance(props)
session.getStore("imaps").also { it.connect(imapHost, imapPort, username, password) }
}
}
private fun Message.toEmailContext(): EmailContext {
val from = from?.firstOrNull()?.toString() ?: "unknown"
val date = (receivedDate ?: sentDate ?: Date())
.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
val bodyText = extractText(content, contentType)
return EmailContext(
from = from,
subject = subject ?: "(no subject)",
body = bodyText,
receivedAt = date
)
}
private fun extractText(content: Any?, contentType: String?): String {
val raw = when (content) {
is String -> content
else -> content?.toString() ?: ""
}
val isHtml = contentType?.contains("html", ignoreCase = true) == true ||
raw.contains(Regex("<[a-zA-Z][^>]*>"))
return if (isHtml) stripHtml(raw) else raw
}
private fun stripHtml(html: String): String =
html.replace(Regex("<[^>]+>"), " ")
.replace(Regex("\\s{2,}"), " ")
.trim()
}

View File

@@ -0,0 +1,41 @@
package com.condado.newsletter.service
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeMessage
import org.slf4j.LoggerFactory
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Service
/**
* Sends AI-generated emails via SMTP using Spring's [JavaMailSender].
* Each email is sent as multipart (text/plain + text/html).
*/
@Service
class EmailSenderService(private val mailSender: JavaMailSender) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* Sends an email from a virtual entity to all configured recipients.
*
* @param from Sender email address (the virtual entity's email).
* @param to List of recipient email addresses.
* @param subject Email subject line.
* @param body Email body (may be plain text or simple HTML).
*/
fun send(from: String, to: List<String>, subject: String, body: String) {
log.info("Sending email from='$from' to=$to subject='$subject'")
val message: MimeMessage = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")
helper.setFrom(InternetAddress(from))
helper.setTo(to.toTypedArray())
helper.setSubject(subject)
// Send as both plain text and HTML for maximum compatibility
val plainText = body.replace(Regex("<[^>]+>"), "").trim()
val htmlBody = if (body.contains(Regex("<[a-zA-Z]"))) body else "<pre>$body</pre>"
helper.setText(plainText, htmlBody)
mailSender.send(message)
log.info("Email sent successfully from='$from' subject='$subject'")
}
}

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

View File

@@ -0,0 +1,46 @@
package com.condado.newsletter.service
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.Date
/**
* Handles JWT token creation and validation using JJWT 0.12.x.
* The secret and expiration are read from environment variables.
*/
@Service
class JwtService(
@Value("\${app.jwt.secret}") val secret: String,
@Value("\${app.jwt.expiration-ms}") val expirationMs: Long
) {
private val signingKey by lazy {
Keys.hmacShaKeyFor(secret.toByteArray(Charsets.UTF_8))
}
/** Generates a new signed JWT token valid for [expirationMs] milliseconds. */
fun generateToken(): String {
val now = Date()
return Jwts.builder()
.subject("admin")
.issuedAt(now)
.expiration(Date(now.time + expirationMs))
.signWith(signingKey)
.compact()
}
/**
* Validates a JWT token.
* @return `true` if the token is valid and not expired; `false` otherwise.
*/
fun validateToken(token: String): Boolean = try {
Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token)
true
} catch (e: ExpiredJwtException) {
false
} catch (e: Exception) {
false
}
}

View File

@@ -0,0 +1,61 @@
package com.condado.newsletter.service
import com.condado.newsletter.model.EmailContext
import com.condado.newsletter.model.VirtualEntity
import org.springframework.stereotype.Service
/**
* The single authoritative place in the codebase where AI prompts are constructed.
* No other class may build or modify prompt strings.
*/
@Service
class PromptBuilderService {
/**
* Builds the full prompt to be sent to the AI, based on the entity's profile
* and the list of recent emails from the company inbox.
*
* @param entity The virtual employee whose turn it is to send an email.
* @param emailContext Recent emails from the IMAP inbox (oldest-first).
* @return The complete prompt string ready to send to the OpenAI API.
*/
fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String {
val emailSection = if (emailContext.isEmpty()) {
"(No recent emails in the inbox.)"
} else {
emailContext.joinToString("\n\n") { email ->
"""
|From: ${email.from}
|Subject: ${email.subject}
|Received: ${email.receivedAt}
|
|${email.body}
""".trimMargin()
}
}
return """
|You are ${entity.name}, ${entity.jobTitle} at "Condado Abaixo da Média SA".
|
|Your personality: ${entity.personality ?: "Professional and formal."}
|
|IMPORTANT TONE RULE: You must write in an extremely formal, bureaucratic, corporate tone —
|as if writing an official memo. However, the actual content of the email must be completely
|casual, trivial, or nonsensical — as if talking to close friends about mundane things.
|The contrast between the formal tone and the casual content is intentional and essential.
|
|Here are the most recent emails from the company inbox (last ${entity.contextWindowDays} days)
|for context:
|
|$emailSection
|
|Write a new email to be sent to the company group, continuing the conversation naturally.
|Reply or react to the recent emails if relevant. Sign off as ${entity.name}, ${entity.jobTitle}.
|
|Format your response exactly as:
|SUBJECT: <subject line here>
|BODY:
|<full email body here>
""".trimMargin()
}
}

View File

@@ -0,0 +1,8 @@
package com.condado.newsletter.service
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ResponseStatus
/** Thrown when an authentication attempt fails (wrong password or missing token). */
@ResponseStatus(HttpStatus.UNAUTHORIZED)
class UnauthorizedException(message: String) : RuntimeException(message)

View File

@@ -0,0 +1,76 @@
package com.condado.newsletter.controller
import com.condado.newsletter.scheduler.EntityScheduler
import com.condado.newsletter.service.JwtService
import com.ninjasquad.springmockk.MockkBean
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.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import jakarta.servlet.http.Cookie
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var jwtService: JwtService
@MockkBean
lateinit var entityScheduler: EntityScheduler
@Test
fun should_return200AndSetCookie_when_correctPasswordPosted() {
mockMvc.perform(
post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"password":"testpassword"}""")
)
.andExpect(status().isOk)
.andExpect(cookie().exists("jwt"))
.andExpect(cookie().httpOnly("jwt", true))
}
@Test
fun should_return401_when_wrongPasswordPosted() {
mockMvc.perform(
post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"password":"wrongpassword"}""")
)
.andExpect(status().isUnauthorized)
}
@Test
fun should_return200_when_getMeWithValidCookie() {
val token = jwtService.generateToken()
mockMvc.perform(
get("/api/auth/me")
.cookie(Cookie("jwt", token))
)
.andExpect(status().isOk)
}
@Test
fun should_return401_when_getMeWithNoCookie() {
mockMvc.perform(get("/api/auth/me"))
.andExpect(status().isUnauthorized)
}
@Test
fun should_return401_when_protectedEndpointAccessedWithoutCookie() {
mockMvc.perform(get("/api/v1/virtual-entities"))
.andExpect(status().isUnauthorized)
}
}

View File

@@ -0,0 +1,57 @@
package com.condado.newsletter.controller
import com.condado.newsletter.model.DispatchLog
import com.condado.newsletter.model.DispatchStatus
import com.condado.newsletter.model.VirtualEntity
import com.condado.newsletter.repository.DispatchLogRepository
import com.condado.newsletter.repository.VirtualEntityRepository
import com.condado.newsletter.scheduler.EntityScheduler
import com.condado.newsletter.service.JwtService
import com.ninjasquad.springmockk.MockkBean
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.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest
@AutoConfigureMockMvc
class DispatchLogControllerTest {
@Autowired lateinit var mockMvc: MockMvc
@Autowired lateinit var virtualEntityRepository: VirtualEntityRepository
@Autowired lateinit var dispatchLogRepository: DispatchLogRepository
@Autowired lateinit var jwtService: JwtService
@MockkBean lateinit var entityScheduler: EntityScheduler
private fun authCookie() = Cookie("jwt", jwtService.generateToken())
@AfterEach
fun cleanUp() {
dispatchLogRepository.deleteAll()
virtualEntityRepository.deleteAll()
}
@Test
fun should_return200AndAllLogs_when_getAllLogs() {
val entity = virtualEntityRepository.save(VirtualEntity(name = "Log Entity", email = "log@condado.com", jobTitle = "Logger"))
dispatchLogRepository.save(DispatchLog(virtualEntity = entity, emailSubject = "Test Subject", status = DispatchStatus.SENT))
mockMvc.perform(get("/api/v1/dispatch-logs").cookie(authCookie()))
.andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].emailSubject").value("Test Subject"))
}
@Test
fun should_return200AndFilteredLogs_when_getByEntityId() {
val entity1 = virtualEntityRepository.save(VirtualEntity(name = "Entity One", email = "one@condado.com", jobTitle = "Job One"))
val entity2 = virtualEntityRepository.save(VirtualEntity(name = "Entity Two", email = "two@condado.com", jobTitle = "Job Two"))
dispatchLogRepository.save(DispatchLog(virtualEntity = entity1, emailSubject = "Log One", status = DispatchStatus.SENT))
dispatchLogRepository.save(DispatchLog(virtualEntity = entity2, emailSubject = "Log Two", status = DispatchStatus.FAILED))
mockMvc.perform(get("/api/v1/dispatch-logs/entity/${entity1.id}").cookie(authCookie()))
.andExpect(status().isOk).andExpect(jsonPath("$.length()").value(1)).andExpect(jsonPath("$[0].emailSubject").value("Log One"))
}
}

View File

@@ -0,0 +1,100 @@
package com.condado.newsletter.controller
import com.condado.newsletter.model.VirtualEntity
import com.condado.newsletter.repository.VirtualEntityRepository
import com.condado.newsletter.scheduler.EntityScheduler
import com.condado.newsletter.service.JwtService
import com.fasterxml.jackson.databind.ObjectMapper
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import io.mockk.just
import io.mockk.runs
import io.mockk.verify
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.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest
@AutoConfigureMockMvc
class VirtualEntityControllerTest {
@Autowired lateinit var mockMvc: MockMvc
@Autowired lateinit var virtualEntityRepository: VirtualEntityRepository
@Autowired lateinit var jwtService: JwtService
@MockkBean lateinit var entityScheduler: EntityScheduler
private val objectMapper = ObjectMapper()
private fun authCookie() = Cookie("jwt", jwtService.generateToken())
@AfterEach
fun cleanUp() { virtualEntityRepository.deleteAll() }
@Test
fun should_return201AndBody_when_postWithValidPayload() {
val payload = mapOf("name" to "Fulano da Silva", "email" to "fulano@condado.com", "jobTitle" to "Diretor de Nada")
mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isCreated).andExpect(jsonPath("$.name").value("Fulano da Silva")).andExpect(jsonPath("$.id").isNotEmpty)
}
@Test
fun should_return400_when_postWithMissingRequiredField() {
val payload = mapOf("name" to "Fulano")
mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isBadRequest)
}
@Test
fun should_return200AndList_when_getAllEntities() {
virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "test@condado.com", jobTitle = "Tester"))
mockMvc.perform(get("/api/v1/virtual-entities").cookie(authCookie()))
.andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].name").value("Test Entity"))
}
@Test
fun should_return200AndEntity_when_getById() {
val entity = virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "entity@condado.com", jobTitle = "Test Job"))
mockMvc.perform(get("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()))
.andExpect(status().isOk).andExpect(jsonPath("$.name").value("Test Entity"))
}
@Test
fun should_return404_when_getByIdNotFound() {
mockMvc.perform(get("/api/v1/virtual-entities/${java.util.UUID.randomUUID()}").cookie(authCookie()))
.andExpect(status().isNotFound)
}
@Test
fun should_return200_when_putWithValidPayload() {
val entity = virtualEntityRepository.save(VirtualEntity(name = "Old Name", email = "old@condado.com", jobTitle = "Old Job"))
mockMvc.perform(put("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content("""{"name":"New Name"}"""))
.andExpect(status().isOk).andExpect(jsonPath("$.name").value("New Name")).andExpect(jsonPath("$.email").value("old@condado.com"))
}
@Test
fun should_return200AndDeactivated_when_delete() {
val entity = virtualEntityRepository.save(VirtualEntity(name = "Active Entity", email = "active@condado.com", jobTitle = "Active Job"))
mockMvc.perform(delete("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()))
.andExpect(status().isOk).andExpect(jsonPath("$.active").value(false))
}
@Test
fun should_return200_when_triggerEndpointCalled() {
val entity = virtualEntityRepository.save(VirtualEntity(name = "Trigger Entity", email = "trigger@condado.com", jobTitle = "Trigger Job"))
every { entityScheduler.runPipeline(any()) } just runs
mockMvc.perform(post("/api/v1/virtual-entities/${entity.id}/trigger").cookie(authCookie()))
.andExpect(status().isOk)
verify(exactly = 1) { entityScheduler.runPipeline(any()) }
}
}

View File

@@ -0,0 +1,96 @@
package com.condado.newsletter.model
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
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.time.LocalDateTime
@DataJpaTest
class EntityMappingTest {
@Autowired
lateinit var entityManager: TestEntityManager
@Test
fun should_persistVirtualEntity_when_allFieldsProvided() {
val entity = VirtualEntity(
name = "João Silva",
email = "joao@condado.com",
jobTitle = "Chief Nonsense Officer",
personality = "Extremely formal but talks about cats",
scheduleCron = "0 9 * * 1",
contextWindowDays = 3
)
val saved = entityManager.persistAndFlush(entity)
assertThat(saved.id).isNotNull()
assertThat(saved.name).isEqualTo("João Silva")
assertThat(saved.email).isEqualTo("joao@condado.com")
assertThat(saved.jobTitle).isEqualTo("Chief Nonsense Officer")
assertThat(saved.personality).isEqualTo("Extremely formal but talks about cats")
assertThat(saved.scheduleCron).isEqualTo("0 9 * * 1")
assertThat(saved.contextWindowDays).isEqualTo(3)
}
@Test
fun should_enforceUniqueEmail_when_duplicateEmailInserted() {
val entity1 = VirtualEntity(name = "First", email = "dup@condado.com", jobTitle = "Dev")
val entity2 = VirtualEntity(name = "Second", email = "dup@condado.com", jobTitle = "Dev")
entityManager.persistAndFlush(entity1)
assertThrows<Exception> {
entityManager.persistAndFlush(entity2)
}
}
@Test
fun should_persistDispatchLog_when_linkedToVirtualEntity() {
val entity = VirtualEntity(name = "Maria Santos", email = "maria@condado.com", jobTitle = "COO")
val savedEntity = entityManager.persistAndFlush(entity)
entityManager.clear()
val log = DispatchLog(
virtualEntity = entityManager.find(VirtualEntity::class.java, savedEntity.id),
promptSent = "Test prompt content",
aiResponse = "SUBJECT: Test\nBODY:\nTest body",
emailSubject = "Test Subject",
emailBody = "Test body content",
status = DispatchStatus.SENT
)
val savedLog = entityManager.persistAndFlush(log)
assertThat(savedLog.id).isNotNull()
assertThat(savedLog.virtualEntity.id).isEqualTo(savedEntity.id)
assertThat(savedLog.status).isEqualTo(DispatchStatus.SENT)
assertThat(savedLog.promptSent).isEqualTo("Test prompt content")
assertThat(savedLog.emailSubject).isEqualTo("Test Subject")
}
@Test
fun should_setCreatedAtAutomatically_when_virtualEntitySaved() {
val before = LocalDateTime.now().minusSeconds(1)
val entity = VirtualEntity(name = "Auto Time", email = "time@condado.com", jobTitle = "Tester")
val saved = entityManager.persistAndFlush(entity)
val after = LocalDateTime.now().plusSeconds(1)
assertThat(saved.createdAt).isNotNull()
assertThat(saved.createdAt).isAfterOrEqualTo(before)
assertThat(saved.createdAt).isBeforeOrEqualTo(after)
}
@Test
fun should_defaultActiveToTrue_when_virtualEntityCreated() {
val entity = VirtualEntity(name = "Default Active", email = "active@condado.com", jobTitle = "CEO")
val saved = entityManager.persistAndFlush(entity)
assertThat(saved.active).isTrue()
}
}

View File

@@ -0,0 +1,91 @@
package com.condado.newsletter.repository
import com.condado.newsletter.model.DispatchLog
import com.condado.newsletter.model.DispatchStatus
import com.condado.newsletter.model.VirtualEntity
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
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
@DataJpaTest
class RepositoryTest {
@Autowired
lateinit var entityManager: TestEntityManager
@Autowired
lateinit var virtualEntityRepository: VirtualEntityRepository
@Autowired
lateinit var dispatchLogRepository: DispatchLogRepository
private lateinit var activeEntity: VirtualEntity
private lateinit var inactiveEntity: VirtualEntity
@BeforeEach
fun setUp() {
activeEntity = entityManager.persistAndFlush(
VirtualEntity(name = "Active One", email = "active@condado.com", jobTitle = "CEO", active = true)
)
inactiveEntity = entityManager.persistAndFlush(
VirtualEntity(name = "Inactive One", email = "inactive@condado.com", jobTitle = "CTO", active = false)
)
entityManager.clear()
}
@Test
fun should_returnOnlyActiveEntities_when_findAllByActiveTrueCalled() {
val result = virtualEntityRepository.findAllByActiveTrue()
assertThat(result).hasSize(1)
assertThat(result[0].email).isEqualTo("active@condado.com")
}
@Test
fun should_findEntityByEmail_when_emailExists() {
val result = virtualEntityRepository.findByEmail("active@condado.com")
assertThat(result).isPresent
assertThat(result.get().name).isEqualTo("Active One")
}
@Test
fun should_returnEmptyOptional_when_emailNotFound() {
val result = virtualEntityRepository.findByEmail("nobody@condado.com")
assertThat(result).isEmpty
}
@Test
fun should_returnAllLogsForEntity_when_findAllByVirtualEntityCalled() {
val entity = entityManager.find(VirtualEntity::class.java, activeEntity.id)
entityManager.persistAndFlush(DispatchLog(virtualEntity = entity, status = DispatchStatus.SENT))
entityManager.persistAndFlush(DispatchLog(virtualEntity = entity, status = DispatchStatus.FAILED))
entityManager.clear()
val result = dispatchLogRepository.findAllByVirtualEntity(entity)
assertThat(result).hasSize(2)
}
@Test
fun should_returnMostRecentLog_when_findTopByVirtualEntityOrderByDispatchedAtDescCalled() {
val entity = entityManager.find(VirtualEntity::class.java, activeEntity.id)
val firstLog = entityManager.persistAndFlush(
DispatchLog(virtualEntity = entity, status = DispatchStatus.SENT)
)
Thread.sleep(10)
val latestLog = entityManager.persistAndFlush(
DispatchLog(virtualEntity = entity, status = DispatchStatus.FAILED)
)
entityManager.clear()
val result = dispatchLogRepository.findTopByVirtualEntityOrderByDispatchedAtDesc(entity)
assertThat(result).isPresent
assertThat(result.get().id).isEqualTo(latestLog.id)
}
}

View File

@@ -0,0 +1,150 @@
package com.condado.newsletter.scheduler
import com.condado.newsletter.model.DispatchLog
import com.condado.newsletter.model.DispatchStatus
import com.condado.newsletter.model.EmailContext
import com.condado.newsletter.model.ParsedAiResponse
import com.condado.newsletter.model.VirtualEntity
import com.condado.newsletter.repository.DispatchLogRepository
import com.condado.newsletter.service.AiService
import com.condado.newsletter.service.AiServiceException
import com.condado.newsletter.service.EmailReaderService
import com.condado.newsletter.service.EmailSenderService
import com.condado.newsletter.service.PromptBuilderService
import io.mockk.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.util.UUID
class EntitySchedulerTest {
private val emailReaderService: EmailReaderService = mockk()
private val promptBuilderService: PromptBuilderService = mockk()
private val aiService: AiService = mockk()
private val emailSenderService: EmailSenderService = mockk()
private val dispatchLogRepository: DispatchLogRepository = mockk()
private lateinit var scheduler: EntityScheduler
private val entity = VirtualEntity(
name = "Joao Gerente",
email = "joao@condado.com",
jobTitle = "Gerente de Nada",
personality = "Muito formal",
scheduleCron = "0 9 * * *",
contextWindowDays = 3,
active = true
).apply { id = UUID.randomUUID() }
private val inactiveEntity = VirtualEntity(
name = "Maria Inativa",
email = "maria@condado.com",
jobTitle = "Consultora de Vibe",
active = false
).apply { id = UUID.randomUUID() }
@BeforeEach
fun setUp() {
scheduler = EntityScheduler(
emailReaderService = emailReaderService,
promptBuilderService = promptBuilderService,
aiService = aiService,
emailSenderService = emailSenderService,
dispatchLogRepository = dispatchLogRepository,
recipients = "recipient@example.com",
inboxFolder = "INBOX"
)
}
@Test
fun should_runFullPipeline_when_entityIsTriggered() {
val emails = listOf(EmailContext("sender@example.com", "Subject", "Body", LocalDateTime.now()))
val prompt = "Generated prompt"
val aiResponse = ParsedAiResponse(subject = "Weekly memo", body = "Dear colleagues...")
every { emailReaderService.readEmails("INBOX", 3) } returns emails
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
every { aiService.generate(prompt) } returns aiResponse
every { emailSenderService.send(entity.email, listOf("recipient@example.com"), aiResponse.subject, aiResponse.body) } just runs
every { dispatchLogRepository.save(any()) } answers { firstArg() }
scheduler.runPipeline(entity)
verify(exactly = 1) { emailReaderService.readEmails("INBOX", 3) }
verify(exactly = 1) { promptBuilderService.buildPrompt(entity, emails) }
verify(exactly = 1) { aiService.generate(prompt) }
verify(exactly = 1) { emailSenderService.send(entity.email, listOf("recipient@example.com"), aiResponse.subject, aiResponse.body) }
}
@Test
fun should_saveDispatchLogWithStatusSent_when_pipelineSucceeds() {
val emails = emptyList<EmailContext>()
val prompt = "prompt"
val aiResponse = ParsedAiResponse(subject = "Subject", body = "Body")
val savedLog = slot<DispatchLog>()
every { emailReaderService.readEmails("INBOX", 3) } returns emails
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
every { aiService.generate(prompt) } returns aiResponse
every { emailSenderService.send(any(), any(), any(), any()) } just runs
every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() }
scheduler.runPipeline(entity)
assertThat(savedLog.isCaptured).isTrue()
assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.SENT)
assertThat(savedLog.captured.emailSubject).isEqualTo("Subject")
assertThat(savedLog.captured.emailBody).isEqualTo("Body")
}
@Test
fun should_saveDispatchLogWithStatusFailed_when_aiServiceThrows() {
val emails = emptyList<EmailContext>()
val prompt = "prompt"
val savedLog = slot<DispatchLog>()
every { emailReaderService.readEmails("INBOX", 3) } returns emails
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
every { aiService.generate(prompt) } throws AiServiceException("API error")
every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() }
scheduler.runPipeline(entity)
assertThat(savedLog.isCaptured).isTrue()
assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.FAILED)
assertThat(savedLog.captured.errorMessage).contains("API error")
}
@Test
fun should_saveDispatchLogWithStatusFailed_when_emailSenderThrows() {
val emails = emptyList<EmailContext>()
val prompt = "prompt"
val aiResponse = ParsedAiResponse(subject = "Subject", body = "Body")
val savedLog = slot<DispatchLog>()
every { emailReaderService.readEmails("INBOX", 3) } returns emails
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
every { aiService.generate(prompt) } returns aiResponse
every { emailSenderService.send(any(), any(), any(), any()) } throws RuntimeException("SMTP error")
every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() }
scheduler.runPipeline(entity)
assertThat(savedLog.isCaptured).isTrue()
assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.FAILED)
assertThat(savedLog.captured.errorMessage).contains("SMTP error")
}
@Test
fun should_notTrigger_when_entityIsInactive() {
scheduler.runPipeline(inactiveEntity)
verify { emailReaderService wasNot called }
verify { promptBuilderService wasNot called }
verify { aiService wasNot called }
verify { emailSenderService wasNot called }
verify { dispatchLogRepository wasNot called }
}
}

View File

@@ -0,0 +1,88 @@
package com.condado.newsletter.service
import com.condado.newsletter.model.ParsedAiResponse
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.web.client.RestClient
class AiServiceTest {
private val mockRestClient: RestClient = mockk()
private val mockRequestBodyUriSpec: RestClient.RequestBodyUriSpec = mockk()
private val mockRequestBodySpec: RestClient.RequestBodySpec = mockk()
private val mockResponseSpec: RestClient.ResponseSpec = mockk()
private val service = AiService(
restClient = mockRestClient,
apiKey = "test-key",
model = "gpt-4o"
)
@Test
fun should_returnAiResponseText_when_apiCallSucceeds() {
val rawResponse = "SUBJECT: Test Subject\nBODY:\nTest body content"
stubRestClient(rawResponse.replace("\n", "\\n"))
val result = service.generate("My test prompt")
assertThat(result.subject).isEqualTo("Test Subject")
assertThat(result.body).isEqualTo("Test body content")
}
@Test
fun should_throwAiServiceException_when_apiReturnsError() {
every { mockRestClient.post() } throws RuntimeException("API unavailable")
assertThrows<AiServiceException> {
service.generate("My prompt")
}
}
@Test
fun should_extractSubjectAndBody_when_responseIsWellFormatted() {
val raw = "SUBJECT: Weekly Update\nBODY:\nDear colleagues,\n\nPlease note the snacks."
val result = service.parseResponse(raw)
assertThat(result.subject).isEqualTo("Weekly Update")
assertThat(result.body).isEqualTo("Dear colleagues,\n\nPlease note the snacks.")
}
@Test
fun should_throwParseException_when_responseIsMissingSubjectLine() {
val raw = "BODY:\nSome body without a subject"
assertThrows<AiParseException> {
service.parseResponse(raw)
}
}
@Test
fun should_throwParseException_when_responseIsMissingBodySection() {
val raw = "SUBJECT: Some Subject\nNo body section here"
assertThrows<AiParseException> {
service.parseResponse(raw)
}
}
// ── helper ──────────────────────────────────────────────────────────────
private fun stubRestClient(responseText: String) {
every { mockRestClient.post() } returns mockRequestBodyUriSpec
every { mockRequestBodyUriSpec.uri(any<String>()) } returns mockRequestBodySpec
every { mockRequestBodySpec.header(any(), any()) } returns mockRequestBodySpec
every { mockRequestBodySpec.body(any<Any>()) } returns mockRequestBodySpec
every { mockRequestBodySpec.retrieve() } returns mockResponseSpec
every { mockResponseSpec.body(String::class.java) } returns """
{
"choices": [
{ "message": { "content": "$responseText" } }
]
}
""".trimIndent()
}
}

View File

@@ -0,0 +1,62 @@
package com.condado.newsletter.service
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class AuthServiceTest {
private val jwtService: JwtService = mockk()
private lateinit var authService: AuthService
@BeforeEach
fun setUp() {
authService = AuthService(
jwtService = jwtService,
appPassword = "testpassword"
)
}
@Test
fun should_returnJwtToken_when_correctPasswordProvided() {
every { jwtService.generateToken() } returns "jwt-token"
val token = authService.login("testpassword")
assertThat(token).isEqualTo("jwt-token")
}
@Test
fun should_throwUnauthorizedException_when_wrongPasswordProvided() {
assertThrows<UnauthorizedException> {
authService.login("wrongpassword")
}
}
@Test
fun should_returnValidClaims_when_jwtTokenParsed() {
val realJwtService = JwtService(
secret = "test-secret-key-for-testing-only-must-be-at-least-32-characters",
expirationMs = 86400000L
)
val token = realJwtService.generateToken()
assertThat(realJwtService.validateToken(token)).isTrue()
}
@Test
fun should_returnFalse_when_expiredTokenValidated() {
val realJwtService = JwtService(
secret = "test-secret-key-for-testing-only-must-be-at-least-32-characters",
expirationMs = 1L
)
val token = realJwtService.generateToken()
Thread.sleep(10) // wait for expiration
assertThat(realJwtService.validateToken(token)).isFalse()
}
}

View File

@@ -0,0 +1,113 @@
package com.condado.newsletter.service
import io.mockk.every
import io.mockk.mockk
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Session
import jakarta.mail.Store
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeMessage
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.util.Date
class EmailReaderServiceTest {
private val mockStore: Store = mockk(relaxed = true)
private val service = EmailReaderService(storeFactory = { mockStore })
@Test
fun should_returnEmailsSortedChronologically_when_multipleEmailsFetched() {
val folder: Folder = mockk(relaxed = true)
val session: Session = mockk()
val newer = buildMimeMessage(session, "Newer email", "bob@x.com", Date())
val older = buildMimeMessage(session, "Older email", "alice@x.com", Date(System.currentTimeMillis() - 100_000))
every { mockStore.getFolder(any<String>()) } returns folder
every { folder.messageCount } returns 2
every { folder.getMessages() } returns arrayOf(newer, older)
val result = service.readEmails("INBOX", contextWindowDays = 10)
assertThat(result).hasSize(2)
assertThat(result[0].subject).isEqualTo("Older email")
assertThat(result[1].subject).isEqualTo("Newer email")
}
@Test
fun should_returnEmptyList_when_imapConnectionFails() {
every { mockStore.getFolder(any<String>()) } throws RuntimeException("Connection refused")
val result = service.readEmails("INBOX", contextWindowDays = 3)
assertThat(result).isEmpty()
}
@Test
fun should_filterEmailsOlderThanContextWindow_when_windowIs3Days() {
val folder: Folder = mockk(relaxed = true)
val session: Session = mockk()
val recentDate = Date()
val oldDate = Date(System.currentTimeMillis() - 10L * 24 * 60 * 60 * 1000)
val recent = buildMimeMessage(session, "Recent", "a@x.com", recentDate)
val old = buildMimeMessage(session, "Old", "b@x.com", oldDate)
every { mockStore.getFolder(any<String>()) } returns folder
every { folder.messageCount } returns 2
every { folder.getMessages() } returns arrayOf(recent, old)
val result = service.readEmails("INBOX", contextWindowDays = 3)
assertThat(result).hasSize(1)
assertThat(result[0].subject).isEqualTo("Recent")
}
@Test
fun should_stripHtml_when_emailBodyContainsHtmlTags() {
val folder: Folder = mockk(relaxed = true)
val session: Session = mockk()
val msg = buildMimeMessage(
session,
subject = "HTML email",
from = "dev@x.com",
date = Date(),
body = "<p>Hello <b>world</b></p>"
)
every { mockStore.getFolder(any<String>()) } returns folder
every { folder.messageCount } returns 1
every { folder.getMessages() } returns arrayOf(msg)
val result = service.readEmails("INBOX", contextWindowDays = 10)
assertThat(result).hasSize(1)
assertThat(result[0].body).doesNotContain("<p>", "<b>", "</b>", "</p>")
assertThat(result[0].body).contains("Hello")
assertThat(result[0].body).contains("world")
}
// ── helper ──────────────────────────────────────────────────────────────
private fun buildMimeMessage(
session: Session,
subject: String,
from: String,
date: Date,
body: String = "Plain text body"
): MimeMessage {
val msg: MimeMessage = mockk(relaxed = true)
every { msg.subject } returns subject
every { msg.from } returns arrayOf(InternetAddress(from))
every { msg.sentDate } returns date
every { msg.receivedDate } returns date
every { msg.contentType } returns "text/plain"
every { msg.content } returns body
return msg
}
}

View File

@@ -0,0 +1,79 @@
package com.condado.newsletter.service
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import jakarta.mail.internet.MimeMessage
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mail.javamail.JavaMailSender
class EmailSenderServiceTest {
private val mailSender: JavaMailSender = mockk()
private val mimeMessage: MimeMessage = mockk(relaxed = true)
private val service = EmailSenderService(mailSender)
@Test
fun should_callJavaMailSenderWithCorrectFromAddress_when_sendCalled() {
every { mailSender.createMimeMessage() } returns mimeMessage
every { mailSender.send(mimeMessage) } returns Unit
service.send(
from = "joao@condado.com",
to = listOf("friend@example.com"),
subject = "RE: Meeting on Tuesday",
body = "Dear colleagues, I have prepared the agenda."
)
verify(exactly = 1) { mailSender.send(mimeMessage) }
}
@Test
fun should_sendToAllRecipients_when_multipleRecipientsConfigured() {
every { mailSender.createMimeMessage() } returns mimeMessage
every { mailSender.send(mimeMessage) } returns Unit
service.send(
from = "joao@condado.com",
to = listOf("friend1@example.com", "friend2@example.com", "friend3@example.com"),
subject = "Subject",
body = "Body"
)
verify(exactly = 1) { mailSender.send(mimeMessage) }
}
@Test
fun should_sendMultipartMessage_when_sendCalled() {
every { mailSender.createMimeMessage() } returns mimeMessage
every { mailSender.send(mimeMessage) } returns Unit
// The send method should complete without throwing — multipart assembly is verified indirectly
service.send(
from = "joao@condado.com",
to = listOf("friend@example.com"),
subject = "Formal Memo",
body = "<p>I wish to formally announce that I am out of coffee.</p>"
)
verify(exactly = 1) { mailSender.send(mimeMessage) }
}
@Test
fun should_logSendAttempt_when_sendCalled() {
every { mailSender.createMimeMessage() } returns mimeMessage
every { mailSender.send(mimeMessage) } returns Unit
// No exception should be thrown — logging is captured via SLF4J internally
service.send(
from = "joao@condado.com",
to = listOf("friend@example.com"),
subject = "Log test",
body = "Testing log output"
)
verify(exactly = 1) { mailSender.send(mimeMessage) }
}
}

View File

@@ -0,0 +1,75 @@
package com.condado.newsletter.service
import com.condado.newsletter.model.EmailContext
import com.condado.newsletter.model.VirtualEntity
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class PromptBuilderServiceTest {
private val service = PromptBuilderService()
private val entity = VirtualEntity(
name = "Rodrigo Alves",
email = "rodrigo@condado.com",
jobTitle = "Head of Casual Affairs",
personality = "Extremely pedantic but only talks about cats",
scheduleCron = "0 9 * * 1",
contextWindowDays = 3
)
private val emailContext = listOf(
EmailContext(
from = "Maria Santos <maria@condado.com>",
subject = "Re: The weekly snack situation",
body = "I think we need more chips in the pantry.",
receivedAt = LocalDateTime.now().minusHours(2)
)
)
@Test
fun should_containEntityName_when_buildPromptCalled() {
val prompt = service.buildPrompt(entity, emailContext)
assertThat(prompt).contains("Rodrigo Alves")
}
@Test
fun should_containEntityJobTitle_when_buildPromptCalled() {
val prompt = service.buildPrompt(entity, emailContext)
assertThat(prompt).contains("Head of Casual Affairs")
}
@Test
fun should_containEntityPersonality_when_buildPromptCalled() {
val prompt = service.buildPrompt(entity, emailContext)
assertThat(prompt).contains("Extremely pedantic but only talks about cats")
}
@Test
fun should_containContextWindowDays_when_buildPromptCalled() {
val prompt = service.buildPrompt(entity, emailContext)
assertThat(prompt).contains("3")
}
@Test
fun should_containEachEmailSenderAndSubject_when_emailContextProvided() {
val prompt = service.buildPrompt(entity, emailContext)
assertThat(prompt).contains("Maria Santos <maria@condado.com>")
assertThat(prompt).contains("Re: The weekly snack situation")
}
@Test
fun should_containFormatInstruction_when_buildPromptCalled() {
val prompt = service.buildPrompt(entity, emailContext)
assertThat(prompt).contains("SUBJECT:")
assertThat(prompt).contains("BODY:")
}
@Test
fun should_returnPromptWithNoEmails_when_emailContextIsEmpty() {
val prompt = service.buildPrompt(entity, emptyList())
assertThat(prompt).contains("Rodrigo Alves")
assertThat(prompt).isNotEmpty()
}
}

View File

@@ -0,0 +1,40 @@
spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
mail:
host: localhost
port: 25
username: test
password: test
properties:
mail:
smtp:
auth: false
starttls:
enable: false
app:
password: testpassword
recipients: test@test.com
jwt:
secret: test-secret-key-for-testing-only-must-be-at-least-32-characters
expiration-ms: 86400000
imap:
host: localhost
port: 993
inbox-folder: INBOX
openai:
api-key: test-api-key
model: gpt-4o

View File

@@ -49,7 +49,7 @@ services:
- condado-net - condado-net
# ── Frontend + Nginx ───────────────────────────────────────────────────────── # ── Frontend + Nginx ─────────────────────────────────────────────────────────
frontend: nginx:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -57,7 +57,7 @@ services:
VITE_API_BASE_URL: ${VITE_API_BASE_URL} VITE_API_BASE_URL: ${VITE_API_BASE_URL}
restart: always restart: always
ports: ports:
- "6969:80" - "80:80"
depends_on: depends_on:
- backend - backend
networks: networks:

View File

@@ -49,7 +49,7 @@ services:
- condado-net - condado-net
# ── Frontend + Nginx ───────────────────────────────────────────────────────── # ── Frontend + Nginx ─────────────────────────────────────────────────────────
frontend: nginx:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -57,7 +57,7 @@ services:
VITE_API_BASE_URL: ${VITE_API_BASE_URL} VITE_API_BASE_URL: ${VITE_API_BASE_URL}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "6969:80" - "80:80"
depends_on: depends_on:
- backend - backend
networks: networks:

View File

@@ -19,5 +19,10 @@ fi
# ── Ensure supervisor log directory exists ──────────────────────────────────── # ── Ensure supervisor log directory exists ────────────────────────────────────
mkdir -p /var/log/supervisor mkdir -p /var/log/supervisor
# ── Defaults for all-in-one local PostgreSQL ─────────────────────────────────
export SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL:-jdbc:postgresql://localhost:5432/condado}
export SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME:-condado}
export SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD:-condado}
# ── Start all services via supervisord ─────────────────────────────────────── # ── Start all services via supervisord ───────────────────────────────────────
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

View File

@@ -13,7 +13,6 @@ stderr_logfile=/var/log/supervisor/postgres.err.log
[program:backend] [program:backend]
command=java -jar /app/app.jar command=java -jar /app/app.jar
environment=SPRING_DATASOURCE_URL="jdbc:postgresql://localhost:5432/condado",SPRING_DATASOURCE_USERNAME="condado",SPRING_DATASOURCE_PASSWORD="condado"
autostart=true autostart=true
autorestart=true autorestart=true
startsecs=15 startsecs=15

View File

@@ -1,12 +1,12 @@
{ {
"name": "condado-newsletter-frontend", "name": "condado-newsletter-frontend",
"version": "0.1.1", "version": "0.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "condado-newsletter-frontend", "name": "condado-newsletter-frontend",
"version": "0.1.1", "version": "0.2.1",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",

View File

@@ -1,7 +1,7 @@
{ {
"name": "condado-newsletter-frontend", "name": "condado-newsletter-frontend",
"private": true, "private": true,
"version": "0.1.1", "version": "0.2.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import apiClient from '@/api/apiClient'
import { login, logout, getMe } from '@/api/authApi'
vi.mock('@/api/apiClient', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}))
const mockedApiClient = apiClient as unknown as {
post: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('authApi', () => {
it('should_callLoginEndpoint_when_loginCalled', async () => {
mockedApiClient.post.mockResolvedValue({ data: {} })
await login({ password: 'secret' })
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/login', { password: 'secret' })
})
it('should_callLogoutEndpoint_when_logoutCalled', async () => {
mockedApiClient.post.mockResolvedValue({ data: {} })
await logout()
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/logout')
})
it('should_callMeEndpoint_when_getMeCalled', async () => {
mockedApiClient.get.mockResolvedValue({ data: { message: 'Authenticated' } })
const result = await getMe()
expect(mockedApiClient.get).toHaveBeenCalledWith('/auth/me')
expect(result).toEqual({ message: 'Authenticated' })
})
})

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import apiClient from '@/api/apiClient'
import {
getEntities,
createEntity,
updateEntity,
deleteEntity,
triggerEntity,
} from '@/api/entitiesApi'
vi.mock('@/api/apiClient', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
const mockedApiClient = apiClient as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('entitiesApi', () => {
it('should_callGetEndpoint_when_getAllEntitiesCalled', async () => {
mockedApiClient.get.mockResolvedValue({ data: [] })
await getEntities()
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/virtual-entities')
})
it('should_callPostEndpoint_when_createEntityCalled', async () => {
const dto = { name: 'Test', email: 'test@test.com', jobTitle: 'Tester', personality: '', scheduleCron: '', contextWindowDays: 3 }
mockedApiClient.post.mockResolvedValue({ data: dto })
await createEntity(dto)
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/virtual-entities', dto)
})
it('should_callPutEndpoint_when_updateEntityCalled', async () => {
mockedApiClient.put.mockResolvedValue({ data: {} })
await updateEntity('123', { name: 'Updated' })
expect(mockedApiClient.put).toHaveBeenCalledWith('/v1/virtual-entities/123', { name: 'Updated' })
})
it('should_callDeleteEndpoint_when_deleteEntityCalled', async () => {
mockedApiClient.delete.mockResolvedValue({ data: {} })
await deleteEntity('123')
expect(mockedApiClient.delete).toHaveBeenCalledWith('/v1/virtual-entities/123')
})
it('should_callTriggerEndpoint_when_triggerEntityCalled', async () => {
mockedApiClient.post.mockResolvedValue({ data: {} })
await triggerEntity('123')
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/virtual-entities/123/trigger')
})
})

View File

@@ -0,0 +1,31 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import apiClient from '@/api/apiClient'
import { getLogs, getLogsByEntity } from '@/api/logsApi'
vi.mock('@/api/apiClient', () => ({
default: {
get: vi.fn(),
},
}))
const mockedApiClient = apiClient as unknown as {
get: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('logsApi', () => {
it('should_callGetAllLogsEndpoint_when_getAllLogsCalled', async () => {
mockedApiClient.get.mockResolvedValue({ data: [] })
await getLogs()
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/dispatch-logs')
})
it('should_callGetByEntityEndpoint_when_getLogsByEntityCalled', async () => {
mockedApiClient.get.mockResolvedValue({ data: [] })
await getLogsByEntity('entity-123')
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/dispatch-logs/entity/entity-123')
})
})

View File

@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProtectedRoute from '@/components/ProtectedRoute'
import * as authApi from '@/api/authApi'
vi.mock('@/api/authApi')
const makeWrapper = (initialPath = '/') =>
({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
</QueryClientProvider>
)
describe('ProtectedRoute', () => {
it('should_renderChildren_when_sessionIsValid', async () => {
vi.mocked(authApi.getMe).mockResolvedValue({ message: 'Authenticated' })
const wrapper = makeWrapper()
render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>,
{ wrapper }
)
await screen.findByText('Protected Content')
})
it('should_redirectToLogin_when_sessionIsInvalid', async () => {
vi.mocked(authApi.getMe).mockRejectedValue(new Error('Unauthorized'))
const wrapper = makeWrapper()
render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>,
{ wrapper }
)
// Should navigate away, protected content should not be visible
await new Promise((r) => setTimeout(r, 100))
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
})
})

View File

@@ -1,11 +1,47 @@
import { render, screen } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import DashboardPage from '@/pages/DashboardPage' import DashboardPage from '@/pages/DashboardPage'
import * as entitiesApi from '@/api/entitiesApi'
import * as logsApi from '@/api/logsApi'
vi.mock('@/api/entitiesApi')
vi.mock('@/api/logsApi')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
)
describe('DashboardPage', () => { describe('DashboardPage', () => {
it('should_display_app_version_when_rendered', () => { it('should_display_app_version_when_rendered', () => {
render(<DashboardPage />) vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
vi.mocked(logsApi.getLogs).mockResolvedValue([])
render(<DashboardPage />, { wrapper })
expect(screen.getByText(/version \d+\.\d+\.\d+/i)).toBeInTheDocument()
})
expect(screen.getByText(/version 0.1.0/i)).toBeInTheDocument() it('should_renderEntityCount_when_pageLoads', async () => {
vi.mocked(entitiesApi.getEntities).mockResolvedValue([
{ id: '1', name: 'Entity A', email: 'a@a.com', jobTitle: 'Job A', personality: '', scheduleCron: '', contextWindowDays: 3, active: true, createdAt: '' },
])
vi.mocked(logsApi.getLogs).mockResolvedValue([])
render(<DashboardPage />, { wrapper })
await waitFor(() => {
expect(screen.getByText(/1 active entity|1 entidade/i)).toBeInTheDocument()
})
})
it('should_renderRecentLogs_when_pageLoads', async () => {
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
vi.mocked(logsApi.getLogs).mockResolvedValue([
{ id: '1', entityId: 'e1', entityName: 'Entity A', promptSent: '', aiResponse: '', emailSubject: 'Memo', emailBody: '', status: 'SENT', errorMessage: null, dispatchedAt: '2024-01-01T10:00:00' },
])
render(<DashboardPage />, { wrapper })
await waitFor(() => {
expect(screen.getByText(/Memo/i)).toBeInTheDocument()
})
}) })
}) })

View File

@@ -0,0 +1,60 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import EntitiesPage from '@/pages/EntitiesPage'
import * as entitiesApi from '@/api/entitiesApi'
vi.mock('@/api/entitiesApi')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
)
const mockEntity = {
id: 'entity-1',
name: 'Test Entity',
email: 'test@condado.com',
jobTitle: 'Tester',
personality: 'Formal',
scheduleCron: '0 9 * * *',
contextWindowDays: 3,
active: true,
createdAt: '2024-01-01T00:00:00',
}
describe('EntitiesPage', () => {
it('should_renderEntityList_when_entitiesLoaded', async () => {
vi.mocked(entitiesApi.getEntities).mockResolvedValue([mockEntity])
render(<EntitiesPage />, { wrapper })
await waitFor(() => {
expect(screen.getByText('Test Entity')).toBeInTheDocument()
})
})
it('should_openCreateDialog_when_addButtonClicked', async () => {
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
render(<EntitiesPage />, { wrapper })
const addButton = screen.getByRole('button', { name: /add|create|new/i })
fireEvent.click(addButton)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should_callDeleteApi_when_deleteConfirmed', async () => {
vi.mocked(entitiesApi.getEntities).mockResolvedValue([mockEntity])
vi.mocked(entitiesApi.deleteEntity).mockResolvedValue(undefined)
render(<EntitiesPage />, { wrapper })
await waitFor(() => {
expect(screen.getByText('Test Entity')).toBeInTheDocument()
})
const deleteButton = screen.getByRole('button', { name: /delete|deactivate/i })
fireEvent.click(deleteButton)
await waitFor(() => {
expect(entitiesApi.deleteEntity).toHaveBeenCalledWith('entity-1')
})
})
})

View File

@@ -0,0 +1,52 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import LoginPage from '@/pages/LoginPage'
import * as authApi from '@/api/authApi'
vi.mock('@/api/authApi')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
)
describe('LoginPage', () => {
it('should_renderLoginForm_when_pageLoads', () => {
render(<LoginPage />, { wrapper })
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
})
it('should_callLoginApi_when_formSubmitted', async () => {
vi.mocked(authApi.login).mockResolvedValue(undefined)
render(<LoginPage />, { wrapper })
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } })
fireEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith({ password: 'secret' })
})
})
it('should_showErrorMessage_when_loginFails', async () => {
vi.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'))
render(<LoginPage />, { wrapper })
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } })
fireEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(screen.getByText(/invalid password|unauthorized|error/i)).toBeInTheDocument()
})
})
it('should_redirectToDashboard_when_loginSucceeds', async () => {
vi.mocked(authApi.login).mockResolvedValue(undefined)
render(<LoginPage />, { wrapper })
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'correct' } })
fireEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(authApi.login).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,52 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import LogsPage from '@/pages/LogsPage'
import * as logsApi from '@/api/logsApi'
import * as entitiesApi from '@/api/entitiesApi'
vi.mock('@/api/logsApi')
vi.mock('@/api/entitiesApi')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
)
const mockLog = {
id: 'log-1',
entityId: 'e1',
entityName: 'Entity A',
promptSent: 'prompt',
aiResponse: 'response',
emailSubject: 'Weekly Memo',
emailBody: 'Dear colleagues',
status: 'SENT' as const,
errorMessage: null,
dispatchedAt: '2024-01-01T10:00:00',
}
describe('LogsPage', () => {
it('should_renderLogTable_when_logsLoaded', async () => {
vi.mocked(logsApi.getLogs).mockResolvedValue([mockLog])
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
render(<LogsPage />, { wrapper })
await waitFor(() => {
expect(screen.getByText('Weekly Memo')).toBeInTheDocument()
})
})
it('should_filterLogsByEntity_when_filterSelected', async () => {
vi.mocked(logsApi.getLogs).mockResolvedValue([mockLog])
vi.mocked(entitiesApi.getEntities).mockResolvedValue([
{ id: 'e1', name: 'Entity A', email: 'a@a.com', jobTitle: 'Job', personality: '', scheduleCron: '', contextWindowDays: 3, active: true, createdAt: '' },
])
render(<LogsPage />, { wrapper })
await waitFor(() => {
expect(screen.getByText('Weekly Memo')).toBeInTheDocument()
})
expect(screen.getByRole('combobox', { hidden: true })).toBeInTheDocument()
})
})

View File

@@ -5,6 +5,7 @@ export type DispatchStatus = 'PENDING' | 'SENT' | 'FAILED'
export interface DispatchLogResponse { export interface DispatchLogResponse {
id: string id: string
entityId: string entityId: string
entityName: string
promptSent: string promptSent: string
aiResponse: string aiResponse: string
emailSubject: string emailSubject: string

View File

@@ -1,11 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import { getEntities } from '../api/entitiesApi'
import { getLogs } from '../api/logsApi'
export default function DashboardPage() { export default function DashboardPage() {
const appVersion = __APP_VERSION__ const appVersion = __APP_VERSION__
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
const { data: logs = [] } = useQuery({ queryKey: ['logs'], queryFn: getLogs })
const activeCount = entities.filter((e) => e.active).length
return ( return (
<div className="p-8"> <div className="p-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-sm text-gray-500">Dashboard coming in Step 11.</p>
<p className="mt-2 text-xs text-gray-400">Version {appVersion}</p> <p className="mt-2 text-xs text-gray-400">Version {appVersion}</p>
<div className="mt-6 grid grid-cols-2 gap-4">
<div className="rounded-lg border bg-white p-4 shadow-sm">
<p className="text-sm text-gray-500">Active Entities</p>
<p className="mt-1 text-2xl font-bold">{activeCount} active {activeCount === 1 ? 'entity' : 'entities'}</p>
</div>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-800">Recent Dispatches</h2>
<ul className="mt-2 divide-y divide-gray-100 rounded-lg border bg-white shadow-sm">
{logs.slice(0, 10).map((log) => (
<li key={log.id} className="px-4 py-3 text-sm">
<span className="font-medium">{log.emailSubject}</span>
<span className="ml-2 text-gray-400">{log.entityName}</span>
</li>
))}
{logs.length === 0 && (
<li className="px-4 py-3 text-sm text-gray-400">No dispatches yet.</li>
)}
</ul>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,150 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getEntities,
createEntity,
deleteEntity,
VirtualEntityCreateDto,
} from '../api/entitiesApi'
export default function EntitiesPage() {
const queryClient = useQueryClient()
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
const [dialogOpen, setDialogOpen] = useState(false)
const [form, setForm] = useState<VirtualEntityCreateDto>({
name: '',
email: '',
jobTitle: '',
personality: '',
scheduleCron: '',
contextWindowDays: 3,
})
const createMutation = useMutation({
mutationFn: createEntity,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['entities'] })
setDialogOpen(false)
setForm({ name: '', email: '', jobTitle: '', personality: '', scheduleCron: '', contextWindowDays: 3 })
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteEntity(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['entities'] }),
})
return (
<div className="p-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Virtual Entities</h1>
<button
onClick={() => setDialogOpen(true)}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
New Entity
</button>
</div>
<ul className="mt-6 divide-y divide-gray-100 rounded-lg border bg-white shadow-sm">
{entities.map((entity) => (
<li key={entity.id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="font-medium">{entity.name}</p>
<p className="text-sm text-gray-500">{entity.jobTitle} {entity.email}</p>
</div>
<button
onClick={() => deleteMutation.mutate(entity.id)}
className="ml-4 rounded border border-red-300 px-3 py-1 text-sm text-red-600 hover:bg-red-50"
>
Delete
</button>
</li>
))}
{entities.length === 0 && (
<li className="px-4 py-3 text-sm text-gray-400">No entities yet.</li>
)}
</ul>
{dialogOpen && (
<div
role="dialog"
aria-modal="true"
aria-label="Create Entity"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
>
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-lg">
<h2 className="mb-4 text-lg font-semibold">New Entity</h2>
<form
onSubmit={(e) => {
e.preventDefault()
createMutation.mutate(form)
}}
className="space-y-3"
>
<input
placeholder="Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
required
/>
<input
placeholder="Email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
required
/>
<input
placeholder="Job Title"
value={form.jobTitle}
onChange={(e) => setForm({ ...form, jobTitle: e.target.value })}
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
required
/>
<textarea
placeholder="Personality"
value={form.personality}
onChange={(e) => setForm({ ...form, personality: e.target.value })}
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
/>
<input
placeholder="Schedule Cron (e.g. 0 9 * * 1)"
value={form.scheduleCron}
onChange={(e) => setForm({ ...form, scheduleCron: e.target.value })}
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
required
/>
<input
type="number"
placeholder="Context Window Days"
value={form.contextWindowDays}
onChange={(e) => setForm({ ...form, contextWindowDays: Number(e.target.value) })}
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
min={1}
required
/>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setDialogOpen(false)}
className="rounded border border-gray-300 px-4 py-2 text-sm"
>
Cancel
</button>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,11 +1,52 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { login } from '../api/authApi'
export default function LoginPage() { export default function LoginPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
try {
await login({ password })
navigate('/', { replace: true })
} catch {
setError('Invalid password')
}
}
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50"> <div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-sm rounded-lg bg-white p-8 shadow"> <div className="w-full max-w-sm rounded-lg bg-white p-8 shadow">
<h1 className="mb-6 text-2xl font-bold text-gray-900"> <h1 className="mb-6 text-2xl font-bold text-gray-900">
Condado Abaixo da Média SA Condado Abaixo da Média SA
</h1> </h1>
<p className="text-sm text-gray-500">Login page coming in Step 11.</p> <form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
className="w-full rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Login
</button>
</form>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,78 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getLogs, getLogsByEntity } from '../api/logsApi'
import { getEntities } from '../api/entitiesApi'
export default function LogsPage() {
const [selectedEntityId, setSelectedEntityId] = useState<string>('')
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
const { data: logs = [] } = useQuery({
queryKey: ['logs', selectedEntityId],
queryFn: () => selectedEntityId ? getLogsByEntity(selectedEntityId) : getLogs(),
})
return (
<div className="p-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Dispatch Logs</h1>
<select
value={selectedEntityId}
onChange={(e) => setSelectedEntityId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
<option value="">All Entities</option>
{entities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name}
</option>
))}
</select>
</div>
<div className="mt-6 overflow-hidden rounded-lg border bg-white shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
<tr>
<th className="px-4 py-3">Subject</th>
<th className="px-4 py-3">Entity</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Dispatched At</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map((log) => (
<tr key={log.id}>
<td className="px-4 py-3 font-medium">{log.emailSubject}</td>
<td className="px-4 py-3 text-gray-500">{log.entityName}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
log.status === 'SENT'
? 'bg-green-100 text-green-700'
: log.status === 'FAILED'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}
>
{log.status}
</span>
</td>
<td className="px-4 py-3 text-gray-400">
{new Date(log.dispatchedAt).toLocaleString()}
</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">
No logs found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -1,8 +1,19 @@
import { createBrowserRouter } from 'react-router-dom' import { createBrowserRouter } from 'react-router-dom'
import { lazy, Suspense } from 'react' import { lazy, Suspense, ReactNode } from 'react'
import ProtectedRoute from '../components/ProtectedRoute'
const LoginPage = lazy(() => import('../pages/LoginPage')) const LoginPage = lazy(() => import('../pages/LoginPage'))
const DashboardPage = lazy(() => import('../pages/DashboardPage')) const DashboardPage = lazy(() => import('../pages/DashboardPage'))
const EntitiesPage = lazy(() => import('../pages/EntitiesPage'))
const LogsPage = lazy(() => import('../pages/LogsPage'))
function Protected({ children }: { children: ReactNode }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProtectedRoute>{children}</ProtectedRoute>
</Suspense>
)
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@@ -16,9 +27,25 @@ export const router = createBrowserRouter([
{ {
path: '/', path: '/',
element: ( element: (
<Suspense fallback={<div>Loading...</div>}> <Protected>
<DashboardPage /> <DashboardPage />
</Suspense> </Protected>
),
},
{
path: '/entities',
element: (
<Protected>
<EntitiesPage />
</Protected>
),
},
{
path: '/logs',
element: (
<Protected>
<LogsPage />
</Protected>
), ),
}, },
]) ])

View File

@@ -16,7 +16,7 @@ http {
text/xml application/xml application/xml+rss text/javascript; text/xml application/xml application/xml+rss text/javascript;
server { server {
listen 6969; listen 80;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;