Add build instructions and project structure for Condado Abaixo da Média SA Email Bot
- Created INSTRUCTIONS.md detailing project goals, usage, and progress tracking. - Defined project scope, technology stack, and core domain concepts. - Outlined step-by-step build process from scaffolding to deployment. - Included detailed descriptions for each step, including entity models, services, and controllers. - Established a decision log to track key choices made during development.
This commit is contained in:
872
INSTRUCTIONS.md
Normal file
872
INSTRUCTIONS.md
Normal file
@@ -0,0 +1,872 @@
|
||||
# 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.
|
||||
- After completing each step, mark it ✅ **Done** and note any decisions made.
|
||||
- If anything changes (new library, schema change, etc.), update `CLAUDE.md` too.
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracker
|
||||
|
||||
| Step | Description | Status |
|
||||
|------|-----------------------------------------|-------------|
|
||||
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
||||
| 1 | Scaffold project structure | ⬜ Pending |
|
||||
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
||||
| 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 | Security (API Key auth) | ⬜ Pending |
|
||||
| 11 | Unit & integration tests | ⬜ Pending |
|
||||
| 12 | Docker & deployment config | ⬜ Pending |
|
||||
|
||||
---
|
||||
|
||||
## 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 Project Structure
|
||||
|
||||
**Goal:** Generate the full Gradle project skeleton with all dependencies configured.
|
||||
|
||||
**What the AI should create:**
|
||||
|
||||
```
|
||||
condado-news-letter/
|
||||
├── build.gradle.kts
|
||||
├── settings.gradle.kts
|
||||
├── gradle/wrapper/
|
||||
│ ├── gradle-wrapper.jar
|
||||
│ └── gradle-wrapper.properties
|
||||
├── gradlew
|
||||
├── gradlew.bat
|
||||
├── .gitignore
|
||||
├── .env.example
|
||||
└── src/
|
||||
├── main/
|
||||
│ ├── kotlin/com/condado/newsletter/
|
||||
│ │ └── CondadoApplication.kt
|
||||
│ └── resources/
|
||||
│ ├── application.yml
|
||||
│ └── application-dev.yml
|
||||
└── test/
|
||||
└── kotlin/com/condado/newsletter/
|
||||
└── CondadoApplicationTests.kt
|
||||
```
|
||||
|
||||
**Dependencies to include in `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` | API key authentication |
|
||||
| `postgresql` | PostgreSQL JDBC driver |
|
||||
| `jakarta.mail` / `angus-mail` | IMAP email reading |
|
||||
| `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 |
|
||||
|
||||
**`.env.example` should contain:**
|
||||
```env
|
||||
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado
|
||||
SPRING_DATASOURCE_USERNAME=postgres
|
||||
SPRING_DATASOURCE_PASSWORD=postgres
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=company@example.com
|
||||
MAIL_PASSWORD=secret
|
||||
IMAP_HOST=imap.example.com
|
||||
IMAP_PORT=993
|
||||
IMAP_INBOX_FOLDER=INBOX
|
||||
OPENAI_API_KEY=sk-...
|
||||
OPENAI_MODEL=gpt-4o
|
||||
API_KEY=change-me
|
||||
```
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, generate the full `build.gradle.kts`, `settings.gradle.kts`,
|
||||
> `application.yml`, `application-dev.yml`, `.gitignore`, `.env.example`, and the main
|
||||
> application entry point `CondadoApplication.kt`."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `./gradlew build` runs successfully (compile only, no logic yet).
|
||||
- [ ] Application starts with `./gradlew bootRun` without errors.
|
||||
- [ ] Swagger UI is accessible at `http://localhost:8080/swagger-ui.html`.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the two JPA entities: `VirtualEntity` and `DispatchLog`.
|
||||
> Place them in the `model/` package. Use UUIDs as primary keys and proper JPA annotations.
|
||||
> `DispatchLog` has a `@ManyToOne` relationship to `VirtualEntity`."
|
||||
|
||||
**Done when:**
|
||||
- [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
||||
- [ ] `./gradlew build` compiles cleanly.
|
||||
- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Repositories
|
||||
|
||||
**Goal:** Create Spring Data JPA repositories for each entity.
|
||||
|
||||
| Repository | Entity | Custom queries needed |
|
||||
|--------------------------|-----------------|----------------------------------------------------------|
|
||||
| `VirtualEntityRepository`| `VirtualEntity` | `findAllByActiveTrue()`, `findByEmail()` |
|
||||
| `DispatchLogRepository` | `DispatchLog` | `findAllByEntityId()`, `findTopByEntityIdOrderByDispatchedAtDesc()` |
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the two JPA repositories in the `repository/` package.
|
||||
> Each must extend `JpaRepository` and include the custom query methods listed in the instructions."
|
||||
|
||||
**Done when:**
|
||||
- [ ] Both repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`.
|
||||
- [ ] `./gradlew build` compiles cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
)
|
||||
```
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the `EmailReaderService` class in `service/`. It must
|
||||
> connect to an IMAP server using env vars (`IMAP_HOST`, `IMAP_PORT`, `MAIL_USERNAME`,
|
||||
> `MAIL_PASSWORD`, `IMAP_INBOX_FOLDER`), fetch emails from the last N days, and return them
|
||||
> as a list of `EmailContext` data objects sorted chronologically. Use Jakarta Mail."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `EmailReaderService.kt` exists in `service/`.
|
||||
- [ ] `EmailContext.kt` data class exists.
|
||||
- [ ] Service reads real emails when tested against a live IMAP account.
|
||||
- [ ] 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
|
||||
```
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create `PromptBuilderService` in `service/`. It must implement
|
||||
> `buildPrompt(entity, emailContext)` following the prompt template defined in CLAUDE.md exactly.
|
||||
> This is the only class allowed to build prompt strings."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `PromptBuilderService.kt` exists in `service/`.
|
||||
- [ ] Output prompt matches the template from `CLAUDE.md` with fields correctly substituted.
|
||||
- [ ] Unit test verifies the prompt structure.
|
||||
|
||||
---
|
||||
|
||||
## 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 should be instructed to return
|
||||
them in a structured format:
|
||||
```
|
||||
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.
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create `AiService` in `service/`. It must call the OpenAI
|
||||
> Chat Completions API using Spring's `RestClient`, using the `OPENAI_API_KEY` and `OPENAI_MODEL`
|
||||
> env vars. Return the AI's text response. Also implement a `parseResponse(raw: String)` method
|
||||
> that extracts the subject and body from a response formatted as `SUBJECT: ...\nBODY:\n...`."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `AiService.kt` exists in `service/`.
|
||||
- [ ] Calling the service with a real API key returns a valid AI-generated email.
|
||||
- [ ] `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 (loaded from config or DB — TBD in Step 9).
|
||||
- `body` may be plain text or simple HTML — send as both `text/plain` and `text/html` (multipart).
|
||||
- Log every send attempt.
|
||||
|
||||
**Note on recipients:** For now, the list of recipients (the real friends' emails) will be stored
|
||||
as a comma-separated string in `application.yml` under `app.recipients`. This can be made dynamic later.
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create `EmailSenderService` in `service/`. It must use
|
||||
> `JavaMailSender` to send emails. The `from` address comes from the entity, the `to` list
|
||||
> comes from `app.recipients` in config, and the body is sent as both plain text and HTML."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `EmailSenderService.kt` exists in `service/`.
|
||||
- [ ] Email is received in a real inbox when tested with valid SMTP credentials.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Scheduler (Trigger Per Entity)
|
||||
|
||||
**Goal:** Automatically trigger each active `VirtualEntity` at its configured schedule.
|
||||
|
||||
**Class:** `EntityScheduler` in `scheduler/` package.
|
||||
|
||||
**Approach:**
|
||||
- Spring's `@Scheduled` with a fixed-rate tick (every 60 seconds) checks which entities are due.
|
||||
- Use a `ScheduledTaskRegistrar` (or dynamic scheduling) to register a task per entity using its
|
||||
`schedule_cron` expression.
|
||||
- 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` record with status SENT or FAILED.
|
||||
- If the pipeline fails at any step, save a `DispatchLog` with status FAILED and the error message.
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create `EntityScheduler` in `scheduler/`. It must dynamically
|
||||
> schedule a cron job per active `VirtualEntity` using `SchedulingConfigurer`. When triggered,
|
||||
> it must run the full pipeline: read emails → build prompt → call AI → send email → save
|
||||
> `DispatchLog`. Handle failures gracefully and always write a `DispatchLog`."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `EntityScheduler.kt` exists in `scheduler/`.
|
||||
- [ ] `@EnableScheduling` is present on the main app or a config class.
|
||||
- [ ] An active entity triggers at its scheduled time and a `DispatchLog` record is created.
|
||||
- [ ] End-to-end test: entity fires → email arrives in inbox.
|
||||
|
||||
---
|
||||
|
||||
## 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 to create (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
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create `VirtualEntityController` and `DispatchLogController` in
|
||||
> `controller/`, and all DTOs in `dto/`. Controllers return `ResponseEntity<T>`. The `/trigger`
|
||||
> endpoint manually runs the entity pipeline. DTOs use validation annotations."
|
||||
|
||||
**Done when:**
|
||||
- [ ] All controller and DTO files exist.
|
||||
- [ ] Swagger UI shows all endpoints.
|
||||
- [ ] CRUD via Swagger UI or Postman works end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Security (API Key Authentication)
|
||||
|
||||
**Goal:** Protect all API endpoints with a simple API key header.
|
||||
|
||||
**Approach:**
|
||||
- Spring Security with a custom `OncePerRequestFilter`.
|
||||
- Clients must send `X-API-KEY: <value>` header.
|
||||
- Key is read from `API_KEY` environment variable.
|
||||
- Swagger UI and OpenAPI spec (`/swagger-ui.html`, `/v3/api-docs/**`) are public.
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, add API key authentication with Spring Security. Create a
|
||||
> custom filter that checks the `X-API-KEY` header against the `API_KEY` env var. Swagger UI
|
||||
> paths must be excluded from authentication."
|
||||
|
||||
**Done when:**
|
||||
- [ ] All endpoints return `401` without the correct `X-API-KEY` header.
|
||||
- [ ] Swagger UI is still accessible without auth.
|
||||
- [ ] API key is never hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Step 11 — Unit & Integration Tests
|
||||
|
||||
**Goal:** Test every service class and one integration test per controller.
|
||||
|
||||
| Test Class | Type | Covers |
|
||||
|---------------------------------|-------------|----------------------------------------------|
|
||||
| `EmailReaderServiceTest` | Unit | IMAP fetch, empty list on error |
|
||||
| `PromptBuilderServiceTest` | Unit | Prompt matches template, fields substituted |
|
||||
| `AiServiceTest` | Unit | API call mocked, parseResponse parsing |
|
||||
| `EmailSenderServiceTest` | Unit | JavaMailSender called with correct args |
|
||||
| `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint |
|
||||
| `DispatchLogControllerTest` | Integration | List all, list by entity |
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and
|
||||
> integration tests for controllers using `@SpringBootTest` with H2. Follow naming convention
|
||||
> `should_[expectedBehavior]_when_[condition]`."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `./gradlew test` passes all tests green.
|
||||
- [ ] Service class coverage ≥ 80%.
|
||||
|
||||
---
|
||||
|
||||
## Step 12 — Docker & Deployment Config
|
||||
|
||||
**Goal:** Containerize the app and provide a local dev stack.
|
||||
|
||||
**Files to create:**
|
||||
```
|
||||
condado-news-letter/
|
||||
├── Dockerfile # Multi-stage build
|
||||
├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP/IMAP)
|
||||
└── docker-compose.prod.yml # App + PostgreSQL only
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Use [Mailhog](https://github.com/mailhog/MailHog) in dev to capture outgoing emails
|
||||
(SMTP on port 1025, web UI on port 8025).
|
||||
- For IMAP in dev, consider [Greenmail](https://greenmail-mail-test.github.io/greenmail/) as
|
||||
a local IMAP server for end-to-end testing.
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create a multi-stage `Dockerfile` for the Spring Boot app, a
|
||||
> `docker-compose.yml` for local dev (PostgreSQL + Mailhog + Greenmail), and a
|
||||
> `docker-compose.prod.yml` for production (PostgreSQL only, env vars from host)."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `docker-compose up` starts the full stack.
|
||||
- [ ] App connects to PostgreSQL container.
|
||||
- [ ] Outgoing emails appear in Mailhog at `http://localhost:8025`.
|
||||
- [ ] `docker build -t condado-newsletter .` succeeds.
|
||||
|
||||
---
|
||||
|
||||
## 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 | API key auth (not JWT) | Simple internal tool, can upgrade to OAuth2 later |
|
||||
| 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 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — Define the Project & Write CLAUDE.md
|
||||
|
||||
**Goal:** Establish the project scope and create the persistent AI instructions file.
|
||||
|
||||
**What was done:**
|
||||
- Decided on Kotlin + Spring Boot 3.x as the core stack.
|
||||
- Chose PostgreSQL for persistence, Spring Mail for email, and Gradle (Kotlin DSL) as the build tool.
|
||||
- Defined the four core domain concepts: `Subscriber`, `NewsletterIssue`, `Campaign`, `SendLog`.
|
||||
- Created `CLAUDE.md` with project structure, coding standards, naming conventions, and environment variables.
|
||||
|
||||
**Key decisions:**
|
||||
- Use Gradle Kotlin DSL (`build.gradle.kts`) instead of Groovy DSL.
|
||||
- Use MockK for tests, not Mockito (more idiomatic for Kotlin).
|
||||
- Use Springdoc OpenAPI for automatic API documentation.
|
||||
- Thymeleaf for HTML email templates.
|
||||
|
||||
**Output files:**
|
||||
- `CLAUDE.md` ✅
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Scaffold the Project Structure
|
||||
|
||||
**Goal:** Generate the full Gradle project skeleton with all dependencies configured.
|
||||
|
||||
**What the AI should create:**
|
||||
|
||||
```
|
||||
condado-news-letter/
|
||||
├── build.gradle.kts
|
||||
├── settings.gradle.kts
|
||||
├── gradle/
|
||||
│ └── wrapper/
|
||||
│ ├── gradle-wrapper.jar
|
||||
│ └── gradle-wrapper.properties
|
||||
├── gradlew
|
||||
├── gradlew.bat
|
||||
├── .gitignore
|
||||
├── .env.example
|
||||
└── src/
|
||||
├── main/
|
||||
│ ├── kotlin/com/condado/newsletter/
|
||||
│ │ └── CondadoNewsletterApplication.kt
|
||||
│ └── resources/
|
||||
│ ├── application.yml
|
||||
│ └── application-dev.yml
|
||||
└── test/
|
||||
└── kotlin/com/condado/newsletter/
|
||||
└── CondadoNewsletterApplicationTests.kt
|
||||
```
|
||||
|
||||
**Dependencies to include in `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-thymeleaf` | HTML email templates |
|
||||
| `thymeleaf-extras-spring6` | Thymeleaf + Spring 6 integration |
|
||||
| `postgresql` | PostgreSQL JDBC driver |
|
||||
| `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 |
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, generate the full `build.gradle.kts`, `settings.gradle.kts`, `application.yml`, `application-dev.yml`, `.gitignore`, `.env.example`, and the main application entry point `CondadoNewsletterApplication.kt`."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `./gradlew build` runs successfully (compile only, no logic yet).
|
||||
- [ ] Application starts with `./gradlew bootRun` without errors.
|
||||
- [ ] Swagger UI is accessible at `http://localhost:8080/swagger-ui.html`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Domain Model (JPA Entities)
|
||||
|
||||
**Goal:** Create all database entities and their relationships.
|
||||
|
||||
**Entities to create:**
|
||||
|
||||
### `Subscriber`
|
||||
| Column | Type | Notes |
|
||||
|-----------------|-------------|-------------------------------|
|
||||
| `id` | UUID | Primary key, auto-generated |
|
||||
| `email` | String | Unique, not null |
|
||||
| `name` | String | Not null |
|
||||
| `subscribed_at` | LocalDateTime | Auto-set on creation |
|
||||
| `active` | Boolean | Default true |
|
||||
|
||||
### `NewsletterIssue`
|
||||
| Column | Type | Notes |
|
||||
|-----------------|-------------|-------------------------------|
|
||||
| `id` | UUID | Primary key, auto-generated |
|
||||
| `title` | String | Not null |
|
||||
| `subject` | String | Email subject line |
|
||||
| `html_body` | Text | Full HTML content |
|
||||
| `created_at` | LocalDateTime | Auto-set on creation |
|
||||
| `status` | Enum | DRAFT / READY / ARCHIVED |
|
||||
|
||||
### `Campaign`
|
||||
| Column | Type | Notes |
|
||||
|---------------------|---------------|------------------------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `newsletter_issue_id` | UUID (FK) | References `NewsletterIssue` |
|
||||
| `scheduled_at` | LocalDateTime | When to send |
|
||||
| `sent_at` | LocalDateTime | Nullable, set when sent |
|
||||
| `status` | Enum | SCHEDULED / RUNNING / DONE / FAILED |
|
||||
|
||||
### `SendLog`
|
||||
| Column | Type | Notes |
|
||||
|----------------|---------------|-------------------------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `campaign_id` | UUID (FK) | References `Campaign` |
|
||||
| `subscriber_id`| UUID (FK) | References `Subscriber` |
|
||||
| `sent_at` | LocalDateTime | Nullable |
|
||||
| `status` | Enum | PENDING / SENT / FAILED |
|
||||
| `error_message`| String | Nullable, stores failure reason |
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create all four JPA entities: `Subscriber`, `NewsletterIssue`, `Campaign`, and `SendLog`. Place them in the `model/` package. Use UUIDs as primary keys, Kotlin data classes where appropriate, and proper JPA annotations."
|
||||
|
||||
**Done when:**
|
||||
- [ ] All four entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
||||
- [ ] `./gradlew build` still compiles cleanly.
|
||||
- [ ] Database tables are auto-created by Hibernate on startup (with `ddl-auto: create-drop` in dev profile).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Repositories
|
||||
|
||||
**Goal:** Create Spring Data JPA repositories for each entity.
|
||||
|
||||
**Repositories to create:**
|
||||
|
||||
| Repository | Entity | Custom queries needed |
|
||||
|-----------------------------|-------------------|------------------------------------------------|
|
||||
| `SubscriberRepository` | `Subscriber` | `findByEmail()`, `findAllByActiveTrue()` |
|
||||
| `NewsletterIssueRepository` | `NewsletterIssue` | `findAllByStatus()` |
|
||||
| `CampaignRepository` | `Campaign` | `findAllByStatus()`, `findByScheduledAtBefore()` |
|
||||
| `SendLogRepository` | `SendLog` | `findByCampaignId()`, `countByStatus()` |
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the four JPA repositories in the `repository/` package. Each must extend `JpaRepository` and include the custom query methods listed in the instructions."
|
||||
|
||||
**Done when:**
|
||||
- [ ] All four repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`.
|
||||
- [ ] `./gradlew build` compiles cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Services (Business Logic)
|
||||
|
||||
**Goal:** Implement the core business logic for each domain area.
|
||||
|
||||
**Services to create:**
|
||||
|
||||
### `SubscriberService`
|
||||
- `subscribe(dto: SubscriberCreateDto): Subscriber` — register a new subscriber
|
||||
- `unsubscribe(email: String)` — set `active = false`
|
||||
- `listActive(): List<Subscriber>` — get all active subscribers
|
||||
- `findByEmail(email: String): Subscriber`
|
||||
|
||||
### `NewsletterIssueService`
|
||||
- `createIssue(dto: NewsletterIssueCreateDto): NewsletterIssue`
|
||||
- `updateIssue(id: UUID, dto: NewsletterIssueUpdateDto): NewsletterIssue`
|
||||
- `archiveIssue(id: UUID)`
|
||||
- `listIssues(status: IssueStatus?): List<NewsletterIssue>`
|
||||
|
||||
### `CampaignService`
|
||||
- `scheduleCampaign(dto: CampaignCreateDto): Campaign`
|
||||
- `cancelCampaign(id: UUID)`
|
||||
- `listCampaigns(status: CampaignStatus?): List<Campaign>`
|
||||
- `triggerCampaign(id: UUID)` — manually kick off sending
|
||||
|
||||
### `EmailService`
|
||||
- `sendNewsletterEmail(subscriber: Subscriber, issue: NewsletterIssue): Boolean`
|
||||
- Uses `JavaMailSender` and Thymeleaf template engine
|
||||
- Returns `true` on success, `false` on failure (logs error)
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the four service classes: `SubscriberService`, `NewsletterIssueService`, `CampaignService`, and `EmailService`. Place them in the `service/` package. Follow all coding standards from CLAUDE.md."
|
||||
|
||||
**Done when:**
|
||||
- [ ] All four service files exist in `src/main/kotlin/com/condado/newsletter/service/`.
|
||||
- [ ] `./gradlew build` compiles cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — REST Controllers & DTOs
|
||||
|
||||
**Goal:** Expose the service layer as a REST API with proper request/response DTOs.
|
||||
|
||||
**Controllers to create:**
|
||||
|
||||
| Controller | Base Path | Methods |
|
||||
|-------------------------------|----------------------------|-----------------------------------|
|
||||
| `SubscriberController` | `/api/v1/subscribers` | `POST`, `GET`, `DELETE /{email}` |
|
||||
| `NewsletterIssueController` | `/api/v1/newsletter-issues`| `POST`, `GET`, `PUT /{id}`, `DELETE /{id}` |
|
||||
| `CampaignController` | `/api/v1/campaigns` | `POST`, `GET`, `POST /{id}/trigger`, `DELETE /{id}` |
|
||||
|
||||
**DTOs to create (in `dto/` package):**
|
||||
- `SubscriberCreateDto`, `SubscriberResponseDto`
|
||||
- `NewsletterIssueCreateDto`, `NewsletterIssueUpdateDto`, `NewsletterIssueResponseDto`
|
||||
- `CampaignCreateDto`, `CampaignResponseDto`
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the REST controllers and all DTOs. Controllers go in `controller/`, DTOs in `dto/`. All controllers must return `ResponseEntity<T>`. DTOs must use validation annotations."
|
||||
|
||||
**Done when:**
|
||||
- [ ] All controller and DTO files exist.
|
||||
- [ ] Swagger UI at `http://localhost:8080/swagger-ui.html` shows all endpoints.
|
||||
- [ ] Manual test with a REST client (curl / Postman / Swagger UI) succeeds for basic CRUD.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Email Sending (Spring Mail + Thymeleaf)
|
||||
|
||||
**Goal:** Send real HTML emails using a Thymeleaf template.
|
||||
|
||||
**What to create:**
|
||||
- `src/main/resources/templates/newsletter-email.html` — the HTML email template
|
||||
- `EmailService` (already defined in Step 4) — connect it to the template engine
|
||||
|
||||
**Template variables:**
|
||||
- `${subscriber.name}` — recipient's name
|
||||
- `${issue.title}` — newsletter title
|
||||
- `${issue.htmlBody}` — main newsletter content
|
||||
- `${unsubscribeUrl}` — one-click unsubscribe link
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the Thymeleaf HTML email template and complete the `EmailService` to render it and send via `JavaMailSender`. Include an unsubscribe link in the template."
|
||||
|
||||
**Done when:**
|
||||
- [ ] Template file exists at `src/main/resources/templates/newsletter-email.html`.
|
||||
- [ ] `EmailService.sendNewsletterEmail()` renders the template and sends via SMTP.
|
||||
- [ ] Tested with a real SMTP server (e.g., Mailtrap or Gmail SMTP in dev).
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Scheduler (Automated Campaigns)
|
||||
|
||||
**Goal:** Automatically trigger campaigns at their `scheduled_at` time.
|
||||
|
||||
**What to create:**
|
||||
- `NewsletterScheduler` class in `scheduler/` package
|
||||
- Uses `@Scheduled(fixedDelay = 60000)` — checks every 60 seconds
|
||||
- Finds all `SCHEDULED` campaigns where `scheduled_at <= now()`
|
||||
- Calls `CampaignService.triggerCampaign()` for each
|
||||
- Updates `SendLog` entries for every subscriber
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create the `NewsletterScheduler` class. It should run every 60 seconds, find campaigns due to be sent, and dispatch emails to all active subscribers. Update `SendLog` with SENT or FAILED status for each attempt."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `NewsletterScheduler.kt` exists in `scheduler/` package.
|
||||
- [ ] Scheduler correctly processes due campaigns.
|
||||
- [ ] `SendLog` records are created for each send attempt.
|
||||
- [ ] `@EnableScheduling` is added to the main application class or a config class.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Security (API Key Authentication)
|
||||
|
||||
**Goal:** Protect the API with a simple API key header check.
|
||||
|
||||
**Approach:**
|
||||
- Use Spring Security with a custom `OncePerRequestFilter`
|
||||
- Clients must pass `X-API-KEY: <key>` header
|
||||
- The key is stored in an environment variable `API_KEY`
|
||||
- Public endpoints (unsubscribe link) are excluded from auth
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, add API key authentication using Spring Security. Create a custom filter that checks the `X-API-KEY` header. The key must come from the `API_KEY` environment variable. Exclude `GET /api/v1/subscribers/unsubscribe/**` from auth."
|
||||
|
||||
**Done when:**
|
||||
- [ ] All API endpoints return `401 Unauthorized` without a valid API key.
|
||||
- [ ] Unsubscribe endpoint works without auth.
|
||||
- [ ] API key is read from environment variable, never hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Unit & Integration Tests
|
||||
|
||||
**Goal:** Test every service class and at least one integration test per controller.
|
||||
|
||||
**Tests to create:**
|
||||
|
||||
| Test class | Type | Covers |
|
||||
|-----------------------------------|-------------|---------------------------------|
|
||||
| `SubscriberServiceTest` | Unit | subscribe, unsubscribe, list |
|
||||
| `NewsletterIssueServiceTest` | Unit | create, update, archive |
|
||||
| `CampaignServiceTest` | Unit | schedule, cancel, trigger |
|
||||
| `EmailServiceTest` | Unit | template rendering, send logic |
|
||||
| `SubscriberControllerTest` | Integration | POST /api/v1/subscribers |
|
||||
| `NewsletterIssueControllerTest` | Integration | CRUD /api/v1/newsletter-issues |
|
||||
| `CampaignControllerTest` | Integration | POST, trigger, cancel |
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and integration tests for all controllers using `@SpringBootTest` with H2 in-memory database. Follow the naming convention `should_[expectedBehavior]_when_[condition]`."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `./gradlew test` passes with all tests green.
|
||||
- [ ] Code coverage for service classes is ≥ 80%.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Docker & Deployment Config
|
||||
|
||||
**Goal:** Containerize the application and provide a `docker-compose.yml` for local development.
|
||||
|
||||
**What to create:**
|
||||
|
||||
```
|
||||
condado-news-letter/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP)
|
||||
└── docker-compose.prod.yml # App + PostgreSQL only
|
||||
```
|
||||
|
||||
**Prompt to use with AI:**
|
||||
> "Using the CLAUDE.md context, create a multi-stage `Dockerfile` for the Spring Boot app, a `docker-compose.yml` for local development (includes PostgreSQL and Mailhog for email testing), and a `docker-compose.prod.yml` for production (PostgreSQL only, env vars from host)."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `docker-compose up` starts the full stack.
|
||||
- [ ] App connects to the PostgreSQL container.
|
||||
- [ ] Emails sent in dev are captured by Mailhog at `http://localhost:8025`.
|
||||
- [ ] `./gradlew build && docker build -t condado-newsletter .` succeeds.
|
||||
|
||||
---
|
||||
|
||||
## Notes & Decisions Log
|
||||
|
||||
> Use this section to record important decisions made during the build. Add entries as you go.
|
||||
|
||||
| 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 | Thymeleaf for email templates | Native Spring Boot support |
|
||||
| 2026-03-26 | API key auth (not JWT) for simplicity in step 1 | Can be upgraded to OAuth2 later |
|
||||
Reference in New Issue
Block a user