Files
condado-newsletter/INSTRUCTIONS.md

1021 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Build Instructions — Condado Abaixo da Média SA Email Bot
This file documents every step to build the project from scratch using AI.
After each session, both this file and `CLAUDE.md` will be updated to reflect progress.
---
## What Are We Building?
A group of friends created a fictional company called **"Condado Abaixo da Média SA"**.
Their dynamic works entirely over email — they write to each other in an **extremely formal,
corporate tone**, but the **content is completely casual and nonsensical** (inside jokes,
mundane topics, etc.). The contrast is the joke.
This service allows registering **virtual employees** of that fictional company. Each virtual
employee is an AI-powered entity that:
1. Has a name, email address, job title, and a personality description.
2. Has a **scheduled time** to send emails (e.g., every Monday at 9am).
3. Has a configurable **context window** (e.g., "read emails from the last 3 days") so it
can react to what was already said in the thread.
4. At the scheduled time: reads recent emails from the shared inbox via IMAP → builds a prompt
→ sends the prompt to the OpenAI API → sends the generated email via SMTP.
---
## How to Use This File
- Follow steps **in order** — each step builds on the previous one.
- At the start of each AI session, share the contents of `CLAUDE.md` for context.
- **Each step follows TDD: write the tests first, then implement until all tests pass.**
- After completing each step, mark it ✅ **Done** and note any decisions made.
- If anything changes (new library, schema change, etc.), update `CLAUDE.md` too.
### TDD Workflow Per Step
1. **Red** — AI writes the test file(s) for the step. `./gradlew test` or `npm run test` must fail (or show the new tests failing).
2. **Green** — AI writes the implementation until all tests pass.
3. **Refactor** — Clean up while keeping tests green.
4. Mark the step ✅ Done only when `./gradlew build` (backend) or `npm run build && npm run test` (frontend) is fully green.
---
## Progress Tracker
| Step | Description | Status |
|------|-----------------------------------------|-------------|
| 0 | Define project & write CLAUDE.md | ✅ Done |
| 1 | Scaffold monorepo structure | ✅ Done |
| 2 | Domain model (JPA entities) | ✅ Done |
| 3 | Repositories | ⬜ Pending |
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
| 5 | Prompt Builder Service | ⬜ Pending |
| 6 | AI Service (OpenAI integration) | ⬜ Pending |
| 7 | Email Sender Service (SMTP) | ⬜ Pending |
| 8 | Scheduler (trigger per entity) | ⬜ Pending |
| 9 | REST Controllers & DTOs | ⬜ Pending |
| 10 | Authentication (JWT login) | ⬜ Pending |
| 11 | React Frontend | ⬜ Pending |
| 12 | Docker Compose (dev + prod) | ⬜ Pending |
| 13 | All-in-one Docker image | ⬜ Pending |
| 14 | CI/CD — GitHub Actions + Docker Hub | ⬜ Pending |
> ⚠️ **Steps 211 each follow TDD.** The AI writes failing tests first, then implements until green. See "TDD Workflow Per Step" above.
---
## Step 0 — Define the Project & Write CLAUDE.md
**Goal:** Establish the project scope and create the persistent AI instructions file.
**What was done:**
- Understood the project concept: AI-driven fictional company employees that send formal-toned
but casually-contented emails, reacting to each other via IMAP context reading.
- Decided on Kotlin + Spring Boot 3.x as the core stack.
- Chose PostgreSQL for persistence, Jakarta Mail for IMAP, Spring Mail for SMTP, and Gradle
(Kotlin DSL) as the build tool.
- Defined the core domain concepts: `VirtualEntity`, `EmailContext`, `Prompt`, `DispatchLog`.
- Created `CLAUDE.md` with the prompt template, project structure, coding standards, and env vars.
**Key decisions:**
- Use Gradle Kotlin DSL (`build.gradle.kts`) instead of Groovy DSL.
- Use MockK for tests, not Mockito (more idiomatic for Kotlin).
- Use OpenAI `gpt-4o` as the AI model (configurable via env var).
- No Thymeleaf — emails are plain text or simple HTML generated entirely by the AI.
- The prompt template is defined in `CLAUDE.md` and must be respected exactly.
**Output files:**
- `CLAUDE.md`
---
## Step 1 — Scaffold the Monorepo Structure
**Goal:** Create the full project skeleton for both backend and frontend, with all
dependencies configured and the root-level Docker/CI files in place.
**What the AI should create:**
```
condado-news-letter/
├── .env.example
├── .gitignore
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile.allinone
├── nginx/
│ └── nginx.conf
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── publish.yml
├── backend/
│ ├── Dockerfile
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ ├── gradlew / gradlew.bat
│ ├── gradle/wrapper/
│ └── src/main/kotlin/com/condado/newsletter/
│ └── CondadoApplication.kt
│ └── src/main/resources/
│ ├── application.yml
│ └── application-dev.yml
│ └── src/test/kotlin/com/condado/newsletter/
│ └── CondadoApplicationTests.kt
└── frontend/
├── Dockerfile
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
└── src/
├── main.tsx
└── App.tsx
```
**Backend dependencies (`backend/build.gradle.kts`):**
| Dependency | Purpose |
|-----------------------------------------|----------------------------------------------|
| `spring-boot-starter-web` | REST API |
| `spring-boot-starter-data-jpa` | Database access (JPA/Hibernate) |
| `spring-boot-starter-mail` | Email sending via SMTP |
| `spring-boot-starter-validation` | DTO validation |
| `spring-boot-starter-security` | JWT authentication |
| `jjwt-api`, `jjwt-impl`, `jjwt-jackson` | JWT creation and validation (JJWT library) |
| `postgresql` | PostgreSQL JDBC driver |
| `angus-mail` | IMAP email reading (Jakarta Mail impl) |
| `springdoc-openapi-starter-webmvc-ui` | Swagger UI / OpenAPI docs |
| `kotlin-reflect` | Required by Spring for Kotlin |
| `jackson-module-kotlin` | JSON serialization for Kotlin |
| `h2` (testRuntimeOnly) | In-memory DB for tests |
| `spring-boot-starter-test` | JUnit 5 test support |
| `mockk` | Kotlin mocking library |
| `springmockk` | MockK integration for Spring |
**Frontend dependencies (`frontend/package.json`):**
| Package | Purpose |
|-------------------------------|------------------------------------------|
| `react`, `react-dom` | Core React |
| `typescript` | TypeScript |
| `vite` | Build tool and dev server |
| `@vitejs/plugin-react` | Vite React plugin |
| `react-router-dom` | Client-side routing |
| `@tanstack/react-query` | Server state management |
| `axios` | HTTP client |
| `tailwindcss`, `postcss`, `autoprefixer` | Styling |
| `@radix-ui/*`, `shadcn/ui` | UI component library |
| `lucide-react` | Icon library (used by shadcn) |
| `vitest` | Test runner |
| `@testing-library/react` | Component testing |
| `@testing-library/jest-dom` | DOM matchers |
| `jsdom` | Browser environment for tests |
**`.env.example` must contain all variables from the Environment Variables table in `CLAUDE.md`.**
**Prompt to use with AI:**
> "Using the CLAUDE.md context, scaffold the full monorepo. Create the backend Gradle project
> with all dependencies, the frontend Vite+React project with all packages, the root `.env.example`,
> `.gitignore`, placeholder `docker-compose.yml`, `docker-compose.prod.yml`, `Dockerfile.allinone`,
> `nginx/nginx.conf`, and GitHub Actions workflow stubs at `.github/workflows/ci.yml` and
> `.github/workflows/publish.yml`. Do not implement business logic yet — just the skeleton."
**Done when:**
- [x] `cd backend && ./gradlew build` compiles with no errors.
- [x] `cd frontend && npm install && npm run build` succeeds.
- [x] Application starts with `./gradlew bootRun` (backend) without errors.
- [x] `npm run dev` starts the Vite dev server.
- [ ] `docker compose up --build` starts all containers.
---
---
## Step 2 — Domain Model (JPA Entities)
**Goal:** Create all database entities and their relationships.
**Entities to create:**
### `VirtualEntity`
Represents a fictional employee of "Condado Abaixo da Média SA".
| Column | Type | Notes |
|-------------------------|---------------|----------------------------------------------------|
| `id` | UUID | Primary key, auto-generated |
| `name` | String | The character's full name. Not null. |
| `email` | String | Sender email address. Unique, not null. |
| `job_title` | String | Job title in the fictional company. Not null. |
| `personality` | Text | Free-text personality description for the prompt. |
| `schedule_cron` | String | Cron expression for when to send emails. |
| `context_window_days` | Int | How many days back to read emails for context. |
| `active` | Boolean | Whether this entity is active. Default true. |
| `created_at` | LocalDateTime | Auto-set on creation. |
### `DispatchLog`
A record of every AI generation + email send attempt.
| Column | Type | Notes |
|-------------------|---------------|----------------------------------------------------|
| `id` | UUID | Primary key, auto-generated |
| `entity_id` | UUID (FK) | References `VirtualEntity` |
| `prompt_sent` | Text | The full prompt that was sent to the AI |
| `ai_response` | Text | The raw text returned by the AI |
| `email_subject` | String | The subject line parsed from the AI response |
| `email_body` | Text | The email body parsed from the AI response |
| `status` | Enum | PENDING / SENT / FAILED |
| `error_message` | String | Nullable — stores failure reason if FAILED |
| `dispatched_at` | LocalDateTime | When the dispatch was triggered |
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_persistVirtualEntity_when_allFieldsProvided
// should_enforceUniqueEmail_when_duplicateEmailInserted
// should_persistDispatchLog_when_linkedToVirtualEntity
// should_setCreatedAtAutomatically_when_virtualEntitySaved
// should_defaultActiveToTrue_when_virtualEntityCreated
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write an integration test class `EntityMappingTest` in
> `src/test/kotlin/com/condado/newsletter/model/`. Use `@DataJpaTest` with H2. Write tests
> that verify: VirtualEntity persists all fields, email is unique, DispatchLog links to
> VirtualEntity, createdAt is auto-set, active defaults to true. Do NOT create the entities
> yet — tests should fail to compile."
**Prompt — Phase 2 (implementation):**
> "Now create the two JPA entities `VirtualEntity` and `DispatchLog` in `model/`. Use UUIDs
> as primary keys and proper JPA annotations. `DispatchLog` has a `@ManyToOne` to
> `VirtualEntity`. Make the tests in `EntityMappingTest` pass."
**Done when:**
- [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`.
---
## Step 3 — Repositories
**Goal:** Create Spring Data JPA repositories for each entity.
| Repository | Entity | Custom queries needed |
|--------------------------|-----------------|----------------------------------------------------------|
| `VirtualEntityRepository`| `VirtualEntity` | `findAllByActiveTrue()`, `findByEmail()` |
| `DispatchLogRepository` | `DispatchLog` | `findAllByVirtualEntity()`, `findTopByVirtualEntityOrderByDispatchedAtDesc()` |
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_returnOnlyActiveEntities_when_findAllByActiveTrueCalled
// should_findEntityByEmail_when_emailExists
// should_returnEmptyOptional_when_emailNotFound
// should_returnAllLogsForEntity_when_findAllByVirtualEntityCalled
// should_returnMostRecentLog_when_findTopByVirtualEntityOrderByDispatchedAtDescCalled
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a `@DataJpaTest` class `RepositoryTest` in
> `src/test/kotlin/com/condado/newsletter/repository/`. Test all custom query methods for
> both repositories using H2. Do NOT create the repositories yet."
**Prompt — Phase 2 (implementation):**
> "Create `VirtualEntityRepository` and `DispatchLogRepository` in `repository/`, each
> extending `JpaRepository`, with the custom query methods needed to make `RepositoryTest` pass."
**Done when:**
- [ ] `RepositoryTest.kt` exists with all 5 tests.
- [ ] Both repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`.
- [ ] `./gradlew test` is green.
---
## Step 4 — Email Reader Service (IMAP)
**Goal:** Read recent emails from the shared company inbox via IMAP to use as AI context.
**Class:** `EmailReaderService` in `service/` package.
**Responsibilities:**
- Connect to the IMAP server using credentials from environment variables.
- Fetch all emails from the configured folder received within the last `N` days.
- Return a list of `EmailContext` data objects, each containing:
- `from: String` — sender name/address
- `subject: String` — email subject
- `body: String` — plain text body (strip HTML if needed)
- `receivedAt: LocalDateTime`
- Sort results from oldest to newest (chronological order, for natural prompt reading).
- Handle IMAP errors gracefully — log the error and return an empty list rather than crashing.
**Data class to create (in `dto/` or `model/`):**
```kotlin
data class EmailContext(
val from: String,
val subject: String,
val body: String,
val receivedAt: LocalDateTime
)
```
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/service/EmailReaderServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_returnEmailsSortedChronologically_when_multipleEmailsFetched
// should_returnEmptyList_when_imapConnectionFails
// should_filterEmailsOlderThanContextWindow_when_windowIs3Days
// should_stripHtml_when_emailBodyContainsHtmlTags
```
The IMAP `Session`/`Store` must be injected or overridable so tests can mock it with MockK.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `EmailReaderServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. Mock the Jakarta Mail `Store` and
> `Folder`. Write the 4 tests listed. Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `EmailContext` data class and `EmailReaderService` in `service/`. Use Jakarta Mail
> for IMAP. The Store must be injectable/mockable. Make all `EmailReaderServiceTest` tests pass."
**Done when:**
- [ ] `EmailReaderServiceTest.kt` exists with all 4 tests.
- [ ] `EmailReaderService.kt` and `EmailContext.kt` exist in their packages.
- [ ] `./gradlew test` is green.
- [ ] Returns empty list (not exception) on connection failure.
---
## Step 5 — Prompt Builder Service
**Goal:** Transform a `VirtualEntity` + a list of `EmailContext` into a final AI prompt string.
**Class:** `PromptBuilderService` in `service/` package.
**Rule:** This is the ONLY place in the codebase where prompt strings are built. No other class
may construct or modify prompts.
**The prompt template is defined in `CLAUDE.md` under "The Prompt Template (Core Logic)".**
It must be followed exactly, with the entity fields and email context filled in dynamically.
**Method signature:**
```kotlin
fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String
```
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/service/PromptBuilderServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_containEntityName_when_buildPromptCalled
// should_containEntityJobTitle_when_buildPromptCalled
// should_containEntityPersonality_when_buildPromptCalled
// should_containContextWindowDays_when_buildPromptCalled
// should_containEachEmailSenderAndSubject_when_emailContextProvided
// should_containFormatInstruction_when_buildPromptCalled // verifies SUBJECT:/BODY: instruction
// should_returnPromptWithNoEmails_when_emailContextIsEmpty
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a pure unit test class `PromptBuilderServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. No mocks needed — just build a
> `VirtualEntity` and `List<EmailContext>` and assert the output string. Write all 7 tests.
> Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `PromptBuilderService` in `service/`. It must implement `buildPrompt(entity,
> emailContext)` following the prompt template in CLAUDE.md exactly. Make all
> `PromptBuilderServiceTest` tests pass."
**Done when:**
- [ ] `PromptBuilderServiceTest.kt` exists with all 7 tests.
- [ ] `PromptBuilderService.kt` exists in `service/`.
- [ ] `./gradlew test` is green.
- [ ] Output prompt matches the template from `CLAUDE.md` with all fields correctly substituted.
---
## Step 6 — AI Service (OpenAI Integration)
**Goal:** Send the prompt to OpenAI and get back the generated email text.
**Class:** `AiService` in `service/` package.
**Responsibilities:**
- Call the OpenAI Chat Completions API (`POST https://api.openai.com/v1/chat/completions`).
- Use the model configured in `OPENAI_MODEL` env var (default: `gpt-4o`).
- Send the prompt as a `user` message.
- Return the AI's response as a plain `String`.
- Parse the response to extract `subject` and `body` — the AI is instructed to return:
```
SUBJECT: <generated subject>
BODY:
<generated email body>
```
- Handle API errors gracefully — throw a descriptive exception that `DispatchLog` can record.
**HTTP Client:** Use Spring's `RestClient` (Spring Boot 3.2+) — do NOT use WebClient or Feign.
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_returnAiResponseText_when_apiCallSucceeds
// should_throwAiServiceException_when_apiReturnsError
// should_extractSubjectAndBody_when_responseIsWellFormatted
// should_throwParseException_when_responseIsMissingSubjectLine
// should_throwParseException_when_responseIsMissingBodySection
```
Mock `RestClient` with MockK. `parseResponse()` can be tested without mocking.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `AiServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. Mock Spring's `RestClient` chain.
> Write all 5 tests listed. Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `AiService` in `service/` using Spring `RestClient` for the OpenAI API. Implement
> `parseResponse(raw: String)` that extracts SUBJECT and BODY. Make all `AiServiceTest` tests pass."
**Done when:**
- [ ] `AiServiceTest.kt` exists with all 5 tests.
- [ ] `AiService.kt` exists in `service/`.
- [ ] `./gradlew test` is green.
- [ ] `parseResponse()` correctly extracts subject and body.
---
## Step 7 — Email Sender Service (SMTP)
**Goal:** Send the AI-generated email via SMTP using Spring Mail.
**Class:** `EmailSenderService` in `service/` package.
**Method signature:**
```kotlin
fun send(from: String, to: List<String>, subject: String, body: String)
```
- `from` is the `VirtualEntity.email` (the fictional employee's address).
- `to` is the list of all real participants' emails (from `app.recipients` config).
- `body` may be plain text or simple HTML — send as both `text/plain` and `text/html` (multipart).
- Log every send attempt.
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/service/EmailSenderServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_callJavaMailSenderWithCorrectFromAddress_when_sendCalled
// should_sendToAllRecipients_when_multipleRecipientsConfigured
// should_sendMultipartMessage_when_sendCalled // verifies both text/plain and text/html parts
// should_logSendAttempt_when_sendCalled
```
Mock `JavaMailSender` and `MimeMessage` with MockK.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `EmailSenderServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. Mock `JavaMailSender`. Write all 4 tests.
> Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `EmailSenderService` in `service/` using `JavaMailSender`. Send emails as multipart
> (text/plain + text/html). Make all `EmailSenderServiceTest` tests pass."
**Done when:**
- [ ] `EmailSenderServiceTest.kt` exists with all 4 tests.
- [ ] `EmailSenderService.kt` exists in `service/`.
- [ ] `./gradlew test` is green.
---
## Step 8 — Scheduler (Trigger Per Entity)
**Goal:** Automatically trigger each active `VirtualEntity` at its configured schedule.
**Class:** `EntityScheduler` in `scheduler/` package.
**Approach:**
- Use `SchedulingConfigurer` to register a cron task per active entity on startup.
- A `@Scheduled(fixedRate = 60_000)` refresh method re-reads entities and re-registers tasks
when entities are added/updated.
- When triggered, orchestrate the full pipeline:
1. Read emails via `EmailReaderService` (using `entity.contextWindowDays`).
2. Build prompt via `PromptBuilderService`.
3. Call AI via `AiService`.
4. Parse AI response (subject + body).
5. Send email via `EmailSenderService`.
6. Save a `DispatchLog` with status SENT.
- If the pipeline fails at any step, save a `DispatchLog` with status FAILED and the error message.
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/scheduler/EntitySchedulerTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_runFullPipeline_when_entityIsTriggered
// should_saveDispatchLogWithStatusSent_when_pipelineSucceeds
// should_saveDispatchLogWithStatusFailed_when_aiServiceThrows
// should_saveDispatchLogWithStatusFailed_when_emailSenderThrows
// should_notTrigger_when_entityIsInactive
```
Mock all five services with MockK.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `EntitySchedulerTest` in
> `src/test/kotlin/com/condado/newsletter/scheduler/`. Mock all five service dependencies.
> Write all 5 tests listed. Do NOT create the scheduler yet."
**Prompt — Phase 2 (implementation):**
> "Create `EntityScheduler` in `scheduler/` using `SchedulingConfigurer`. Orchestrate the full
> pipeline and always persist a `DispatchLog`. Make all `EntitySchedulerTest` tests pass."
**Done when:**
- [ ] `EntitySchedulerTest.kt` exists with all 5 tests.
- [ ] `EntityScheduler.kt` exists in `scheduler/`.
- [ ] `./gradlew test` is green.
- [ ] `@EnableScheduling` is on `CondadoApplication`.
---
## Step 9 — REST Controllers & DTOs
**Goal:** Expose CRUD operations for `VirtualEntity` and read-only access to `DispatchLog`.
**Controllers:**
### `VirtualEntityController` — `/api/v1/virtual-entities`
| Method | Path | Description |
|--------|-------------------|--------------------------------------|
| POST | `/` | Create a new virtual entity |
| GET | `/` | List all virtual entities |
| GET | `/{id}` | Get one entity by ID |
| PUT | `/{id}` | Update an entity |
| DELETE | `/{id}` | Deactivate an entity (soft delete) |
| POST | `/{id}/trigger` | Manually trigger the entity pipeline |
### `DispatchLogController` — `/api/v1/dispatch-logs`
| Method | Path | Description |
|--------|-------------------|------------------------------------|
| GET | `/` | List all dispatch logs |
| GET | `/entity/{id}` | List logs for a specific entity |
**DTOs (in `dto/` package):**
- `VirtualEntityCreateDto` — name, email, jobTitle, personality, scheduleCron, contextWindowDays
- `VirtualEntityUpdateDto` — same fields, all optional
- `VirtualEntityResponseDto` — full entity fields + id + createdAt
- `DispatchLogResponseDto` — all DispatchLog fields
### TDD — Write tests first
**Test files:**
- `src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt`
- `src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt`
Tests to write — `VirtualEntityControllerTest` (all should **fail** before implementation):
```kotlin
// should_return201AndBody_when_postWithValidPayload
// should_return400_when_postWithMissingRequiredField
// should_return200AndList_when_getAllEntities
// should_return200AndEntity_when_getById
// should_return404_when_getByIdNotFound
// should_return200_when_putWithValidPayload
// should_return200AndDeactivated_when_delete
// should_return200_when_triggerEndpointCalled
```
Tests to write — `DispatchLogControllerTest`:
```kotlin
// should_return200AndAllLogs_when_getAllLogs
// should_return200AndFilteredLogs_when_getByEntityId
```
Use `@SpringBootTest` + `MockMvc` + H2. Mock `EntityScheduler` so trigger tests don't run the pipeline.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write `@SpringBootTest` integration test classes
> `VirtualEntityControllerTest` and `DispatchLogControllerTest` using MockMvc and H2. Write
> all tests listed. Do NOT create the controllers or DTOs yet."
**Prompt — Phase 2 (implementation):**
> "Create `VirtualEntityController`, `DispatchLogController`, and all DTOs in their packages.
> Controllers return `ResponseEntity<T>`. Make all controller tests pass."
**Done when:**
- [ ] Both test files exist with all tests listed above.
- [ ] All controller and DTO files exist.
- [ ] `./gradlew test` is green.
- [ ] Swagger UI shows all endpoints.
---
## Step 10 — Authentication (JWT Login)
**Goal:** Implement the single-admin JWT login that protects all API endpoints.
**Approach:**
- `POST /api/auth/login` accepts `{ "password": "..." }`, validates against `APP_PASSWORD` env var.
- On success, generates a JWT signed with `JWT_SECRET` and sets it as an `httpOnly` cookie.
- `JwtAuthFilter` (`OncePerRequestFilter`) validates the cookie on every protected request.
- Public paths: `POST /api/auth/login`, `GET /api/auth/me`, `/swagger-ui/**`, `/v3/api-docs/**`.
- There is **no user table** — the password lives only in the environment variable.
**Classes to create:**
- `AuthController` — `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`
- `AuthService` — validates password, generates JWT
- `JwtService` — signs and validates JWT tokens
- `JwtAuthFilter` — reads cookie, validates JWT, sets `SecurityContext`
- `SecurityConfig` — Spring Security HTTP config
**DTOs:**
- `LoginRequest` — `{ "password": String }`
- `AuthResponse` — `{ "message": String }`
### TDD — Write tests first
**Test files:**
- `src/test/kotlin/com/condado/newsletter/service/AuthServiceTest.kt`
- `src/test/kotlin/com/condado/newsletter/controller/AuthControllerTest.kt`
Tests to write — `AuthServiceTest` (all should **fail** before implementation):
```kotlin
// should_returnJwtToken_when_correctPasswordProvided
// should_throwUnauthorizedException_when_wrongPasswordProvided
// should_returnValidClaims_when_jwtTokenParsed
// should_returnFalse_when_expiredTokenValidated
```
Tests to write — `AuthControllerTest`:
```kotlin
// should_return200AndSetCookie_when_correctPasswordPosted
// should_return401_when_wrongPasswordPosted
// should_return200_when_getMeWithValidCookie
// should_return401_when_getMeWithNoCookie
// should_return401_when_protectedEndpointAccessedWithoutCookie
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write MockK unit tests for `AuthService` and a `@SpringBootTest`
> integration test `AuthControllerTest` using MockMvc and H2. Write all tests listed.
> Do NOT create the auth classes yet."
**Prompt — Phase 2 (implementation):**
> "Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`.
> `POST /api/auth/login` validates against `APP_PASSWORD`, returns JWT in an httpOnly cookie.
> Make all auth tests pass."
**Done when:**
- [ ] Both test files exist with all tests listed above.
- [ ] All auth class files exist.
- [ ] `./gradlew test` is green.
- [ ] Swagger UI remains accessible without auth.
- [ ] Password and JWT secret are never hardcoded.
---
## Step 11 — React Frontend
**Goal:** Build the admin SPA that communicates with the backend over the JWT cookie session.
**Pages to create:**
| Page | Path | Description |
|--------------------|-------------------|----------------------------------------------------------|
| `LoginPage` | `/login` | Password input form → calls `POST /api/auth/login` |
| `DashboardPage` | `/` | Overview: entity count, recent dispatch log summary |
| `EntitiesPage` | `/entities` | List, create, edit, delete, toggle active virtual entities|
| `LogsPage` | `/logs` | Paginated dispatch logs with status badges and full details|
**Structure under `frontend/src/`:**
```
api/
authApi.ts — login, logout calls
entitiesApi.ts — CRUD for VirtualEntity
logsApi.ts — fetch DispatchLog records
components/
EntityCard.tsx — card for a single entity
LogRow.tsx — row for a dispatch log entry
ProtectedRoute.tsx — redirects to /login if no valid session
NavBar.tsx — top navigation bar
pages/
LoginPage.tsx
DashboardPage.tsx
EntitiesPage.tsx
LogsPage.tsx
router/
index.tsx — React Router config with lazy-loaded routes
```
**Key rules:**
- All server state via **React Query** — no `useState` for API data.
- All API calls go through `src/api/` — never call `axios` directly in a component.
- Use **shadcn/ui** for all UI components (Button, Input, Table, Badge, Dialog, etc.).
- `ProtectedRoute` checks for a live backend session by calling `GET /api/auth/me`
(add this endpoint to `AuthController`).
- Login form submits to `POST /api/auth/login` — on success React Query invalidates and
React Router navigates to `/`.
### TDD — Write tests first
**Test files:**
- `src/__tests__/api/authApi.test.ts`
- `src/__tests__/api/entitiesApi.test.ts`
- `src/__tests__/api/logsApi.test.ts`
- `src/__tests__/pages/LoginPage.test.tsx`
- `src/__tests__/pages/EntitiesPage.test.tsx`
- `src/__tests__/pages/DashboardPage.test.tsx`
- `src/__tests__/pages/LogsPage.test.tsx`
- `src/__tests__/components/ProtectedRoute.test.tsx`
Tests to write — **API layer** (all should **fail** before implementation):
```typescript
// authApi.test.ts
// should_callLoginEndpoint_when_loginCalled
// should_callLogoutEndpoint_when_logoutCalled
// should_callMeEndpoint_when_getMeCalled
// entitiesApi.test.ts
// should_callGetEndpoint_when_getAllEntitiesCalled
// should_callPostEndpoint_when_createEntityCalled
// should_callPutEndpoint_when_updateEntityCalled
// should_callDeleteEndpoint_when_deleteEntityCalled
// should_callTriggerEndpoint_when_triggerEntityCalled
// logsApi.test.ts
// should_callGetAllLogsEndpoint_when_getAllLogsCalled
// should_callGetByEntityEndpoint_when_getLogsByEntityCalled
```
Tests to write — **Pages & Components** (all should **fail** before implementation):
```typescript
// LoginPage.test.tsx
// should_renderLoginForm_when_pageLoads
// should_callLoginApi_when_formSubmitted
// should_showErrorMessage_when_loginFails
// should_redirectToDashboard_when_loginSucceeds
// EntitiesPage.test.tsx
// should_renderEntityList_when_entitiesLoaded
// should_openCreateDialog_when_addButtonClicked
// should_callDeleteApi_when_deleteConfirmed
// DashboardPage.test.tsx
// should_renderEntityCount_when_pageLoads
// should_renderRecentLogs_when_pageLoads
// LogsPage.test.tsx
// should_renderLogTable_when_logsLoaded
// should_filterLogsByEntity_when_filterSelected
// ProtectedRoute.test.tsx
// should_renderChildren_when_sessionIsValid
// should_redirectToLogin_when_sessionIsInvalid
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write Vitest + React Testing Library test files for the
> frontend. Create tests for all three API modules (`authApi`, `entitiesApi`, `logsApi`) —
> mock Axios and assert that each function calls the correct endpoint. Create smoke tests for
> all four pages and for `ProtectedRoute`. Mock React Query and the API layer. Write all tests
> listed above. Do NOT create any implementation files yet — tests should fail."
**Prompt — Phase 2 (implementation):**
> "Using the CLAUDE.md context, implement the full React frontend. Create all API modules in
> `src/api/`, all components in `src/components/`, all pages in `src/pages/`, and the router
> in `src/router/index.tsx`. Use React Query for all server state, shadcn/ui for UI components,
> and React Router for navigation. Make all frontend tests pass."
**Done when:**
- [ ] All test files exist with all tests listed above.
- [ ] `npm run test` is green (all tests pass).
- [ ] `npm run build` succeeds with no TypeScript errors.
- [ ] `npm run dev` serves the app and login flow works end-to-end.
- [ ] Unauthenticated users are redirected to `/login`.
- [ ] Entities can be created, edited, toggled active, and deleted via the UI.
- [ ] Dispatch logs are visible and filterable by entity.
---
## Step 12 — Docker Compose (Dev + Prod)
**Goal:** Containerize both services and wire them together for local dev and production.
**Files to create / update:**
```
condado-news-letter/
├── backend/Dockerfile # Multi-stage: Gradle build → slim JRE runtime
├── frontend/Dockerfile # Multi-stage: Node build → Nginx static file server
├── nginx/nginx.conf # Serve SPA + proxy /api to backend
├── docker-compose.yml # Dev: Nginx + Backend + PostgreSQL + Mailhog
└── docker-compose.prod.yml # Prod: Nginx + Backend + PostgreSQL (no Mailhog)
```
**Notes:**
- Use [Mailhog](https://github.com/mailhog/MailHog) in dev (SMTP port 1025, web UI port 8025).
- The `nginx` service serves the built React SPA and proxies `/api/**` to `backend:8080`.
- Backend and Postgres communicate over an internal Docker network.
- Env vars come from `.env` at the repo root (copied from `.env.example`).
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create multi-stage Dockerfiles for the backend and frontend,
> an `nginx/nginx.conf` that serves the React SPA and proxies `/api` to the backend, a
> `docker-compose.yml` for dev (includes Mailhog), and a `docker-compose.prod.yml` for
> production. Use `.env` at the repo root for all env vars."
**Done when:**
- [ ] `docker compose up --build` starts all services without errors.
- [ ] `http://localhost` serves the React SPA.
- [ ] `http://localhost/api/v1/virtual-entities` is proxied to the backend.
- [ ] Outgoing emails appear in Mailhog at `http://localhost:8025`.
- [ ] `docker compose -f docker-compose.prod.yml up --build` works (no Mailhog).
---
## Step 13 — All-in-one Docker Image
**Goal:** Build a single Docker image that runs the entire stack (Nginx + Spring Boot + PostgreSQL)
under Supervisor, deployable with a single `docker run` command.
**File to create:** `Dockerfile.allinone` at the repo root.
**What the image bundles:**
- **Nginx** — serves the React SPA and proxies `/api` to Spring Boot
- **Spring Boot** — the backend (from the multi-stage backend build)
- **PostgreSQL** — embedded database
- **Supervisor** — starts and supervises all three processes
**Base approach:**
1. Stage 1: Build frontend (`node:20-alpine` → `npm run build`)
2. Stage 2: Build backend (`gradle:8-jdk21-alpine` → `./gradlew bootJar`)
3. Stage 3: Final image (`ubuntu:24.04` or `debian:bookworm-slim`)
- Install: `nginx`, `postgresql`, `supervisor`, `openjdk-21-jre-headless`
- Copy frontend build → `/usr/share/nginx/html/`
- Copy backend JAR → `/app/app.jar`
- Copy `nginx/nginx.conf` → `/etc/nginx/nginx.conf`
- Add a `supervisord.conf` that starts all three processes
- Add an `entrypoint.sh` that initialises the PostgreSQL data directory on first run
and sets `SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado`
**Supervisor config (`supervisord.conf`):**
```ini
[supervisord]
nodaemon=true
[program:postgres]
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
user=postgres
autostart=true
autorestart=true
[program:backend]
command=java -jar /app/app.jar
autostart=true
autorestart=true
startsecs=10
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
```
**Minimal run command (from `CLAUDE.md`):**
```bash
docker run -d \
-p 80:80 \
-e APP_PASSWORD=yourpassword \
-e JWT_SECRET=yoursecret \
-e OPENAI_API_KEY=sk-... \
-e MAIL_HOST=smtp.example.com \
-e MAIL_PORT=587 \
-e MAIL_USERNAME=company@example.com \
-e MAIL_PASSWORD=secret \
-e IMAP_HOST=imap.example.com \
-e IMAP_PORT=993 \
-e APP_RECIPIENTS=friend1@example.com,friend2@example.com \
-v condado-data:/var/lib/postgresql/data \
<dockerhub-user>/condado-newsletter:latest
```
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create `Dockerfile.allinone` at the repo root. It must be a
> multi-stage build: stage 1 builds the frontend, stage 2 builds the backend, stage 3 assembles
> everything into a single Ubuntu/Debian image with Nginx, PostgreSQL, Spring Boot, and Supervisor.
> Include an `entrypoint.sh` that initialises the PostgreSQL data dir on first run."
**Done when:**
- [ ] `docker build -f Dockerfile.allinone -t condado-newsletter .` succeeds.
- [ ] `docker run -p 80:80 -e APP_PASSWORD=test -e JWT_SECRET=testsecret ... condado-newsletter`
serves the app at `http://localhost`.
- [ ] Data persists across container restarts when a volume is mounted.
- [ ] All three processes (nginx, java, postgres) are visible in `docker exec ... supervisorctl status`.
---
## Step 14 — CI/CD (GitHub Actions + Docker Hub)
**Goal:** Automate testing on every PR and publish the all-in-one image to Docker Hub on every
merge to `main`.
**Files to create:**
```
.github/
└── workflows/
├── ci.yml — run backend + frontend tests on every push / PR
└── publish.yml — build Dockerfile.allinone and push to Docker Hub on push to main
```
### `ci.yml` — Continuous Integration
**Triggers:** `push` and `pull_request` on any branch.
**Jobs:**
1. **`backend-test`**
- `actions/checkout`
- `actions/setup-java` (JDK 21)
- `./gradlew test` in `backend/`
- Upload test results as artifact
2. **`frontend-test`**
- `actions/checkout`
- `actions/setup-node` (Node 20)
- `npm ci` then `npm run test` in `frontend/`
### `publish.yml` — Docker Hub Publish
**Triggers:** `push` to `main` only.
**Steps:**
1. `actions/checkout`
2. `docker/setup-buildx-action`
3. `docker/login-action` — uses `DOCKERHUB_USERNAME` + `DOCKERHUB_TOKEN` secrets
4. `docker/build-push-action`
- File: `Dockerfile.allinone`
- Tags:
- `${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:latest`
- `${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:${{ github.sha }}`
- `push: true`
**Required GitHub repository secrets:**
| Secret | Where to set | Value |
|----------------------|-------------------------------|----------------------------------|
| `DOCKERHUB_USERNAME` | Repo → Settings → Secrets | Your Docker Hub username |
| `DOCKERHUB_TOKEN` | Repo → Settings → Secrets | Docker Hub access token (not password) |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create `.github/workflows/ci.yml` that runs backend Gradle tests
> and frontend Vitest tests on every push/PR. Also create `.github/workflows/publish.yml` that
> builds `Dockerfile.allinone` and pushes two tags (`latest` + git SHA) to Docker Hub using
> `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets, triggered only on push to `main`."
**Done when:**
- [ ] Every PR shows green CI checks for both backend and frontend tests.
- [ ] Merging to `main` triggers an image build and push to Docker Hub.
- [ ] Both `latest` and `<git-sha>` tags are visible on Docker Hub after a push.
- [ ] Workflow files pass YAML linting (`actionlint` or similar).
---
## Notes & Decisions Log
| Date | Decision | Reason |
|------------|--------------------------------------------------------|-----------------------------------------------------|
| 2026-03-26 | Chose Kotlin + Spring Boot 3.x | Modern, type-safe, great Spring support |
| 2026-03-26 | MockK over Mockito | More idiomatic for Kotlin |
| 2026-03-26 | UUID as primary keys | Better for distributed systems |
| 2026-03-26 | No Thymeleaf — AI generates email content directly | Email body is AI-produced, no template needed |
| 2026-03-26 | JWT auth (single admin, password via env var) | No user table needed; simple and secure for a private tool |
| 2026-03-26 | Use Spring `RestClient` for OpenAI (not WebClient) | Spring Boot 3.2+ preferred HTTP client |
| 2026-03-26 | Recipients stored in `app.recipients` config | Simple starting point, can be made dynamic later |
| 2026-03-26 | `PromptBuilderService` is the only prompt builder | Keeps prompt logic centralized and testable |
| 2026-03-26 | AI must format response as `SUBJECT: ...\nBODY:\n...` | Allows reliable parsing of subject vs body |
| 2026-03-26 | React + Vite + shadcn/ui for frontend | Modern, fast DX; Tailwind + Radix keeps UI consistent |
| 2026-03-26 | All-in-one Docker image (Supervisor + Nginx + PG + JVM)| Simplest possible single-command deployment for friends |
| 2026-03-26 | GitHub Actions CI/CD → Docker Hub publish on `main` | Automated image publishing; pinnable via git SHA tags |