diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 3c82f54..ec31aac 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -45,7 +45,7 @@ employee is an AI-powered entity that: |------|-----------------------------------------|-------------| | 0 | Define project & write CLAUDE.md | ✅ Done | | 1 | Scaffold monorepo structure | ✅ Done | -| 2 | Domain model (JPA entities) | ⬜ Pending | +| 2 | Domain model (JPA entities) | ✅ Done | | 3 | Repositories | ⬜ Pending | | 4 | Email Reader Service (IMAP) | ⬜ 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." **Done when:** -- [ ] `EntityMappingTest.kt` exists with all 5 tests. -- [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`. -- [ ] `./gradlew test` is green. -- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev). +- [x] `EntityMappingTest.kt` exists with all 5 tests. +- [x] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`. +- [x] `./gradlew test` is green. +- [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`. --- diff --git a/backend/gradle.properties b/backend/gradle.properties index 1ddc2a9..e30b8c4 100644 --- a/backend/gradle.properties +++ b/backend/gradle.properties @@ -1,2 +1,3 @@ org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError --enable-native-access=ALL-UNNAMED +org.gradle.java.home=C:/Program Files/Java/jdk-21.0.10 kotlin.code.style=official diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/DispatchLog.kt b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchLog.kt new file mode 100644 index 0000000..db2b0aa --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchLog.kt @@ -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 +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/DispatchStatus.kt b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchStatus.kt new file mode 100644 index 0000000..07d9c38 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchStatus.kt @@ -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 +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/VirtualEntity.kt b/backend/src/main/kotlin/com/condado/newsletter/model/VirtualEntity.kt new file mode 100644 index 0000000..c9c3067 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/VirtualEntity.kt @@ -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 +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt b/backend/src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt new file mode 100644 index 0000000..154a4e3 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt @@ -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 { + 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() + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 0000000..1709e72 --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -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