Refactor project instructions and structure for monorepo setup

- Updated Step 1 to scaffold a monorepo structure for both backend and frontend.
- Renamed dependencies and adjusted project structure in INSTRUCTIONS.md.
- Added frontend dependencies and outlined the React application structure.
- Revised authentication method from API key to JWT for enhanced security.
- Created detailed instructions for frontend development, including page structure and API integration.
- Added steps for Docker configuration, including an all-in-one Docker image for deployment.
- Implemented CI/CD workflows for automated testing and Docker Hub publishing.
This commit is contained in:
2026-03-26 14:31:25 -03:00
parent d834ca85b0
commit fa6731de98
2 changed files with 702 additions and 505 deletions

413
CLAUDE.md
View File

@@ -1,29 +1,31 @@
# Condado Abaixo da Média SA — Email Bot # Condado Abaixo da Média SA — Email Bot
A backend service built with **Kotlin** and **Spring Boot**. This file gives the AI persistent A **monorepo** containing a Kotlin/Spring Boot backend and a React frontend. This file gives
instructions and context about the project so every session starts with the right knowledge. the AI persistent instructions and context about the project so every session starts with the
right knowledge.
--- ---
## Project Overview ## Project Overview
- **Language:** Kotlin (JVM) - **Type:** Monorepo (backend + frontend in the same repository)
- **Framework:** Spring Boot 3.x
- **Purpose:** Simulate virtual employees of the fictional company "Condado Abaixo da Média SA". - **Purpose:** Simulate virtual employees of the fictional company "Condado Abaixo da Média SA".
Each entity is a virtual employee with a name, email address, job title, personality, and an Each entity is an AI-powered fictional employee with a name, email address, job title,
email schedule. At the scheduled time, the system reads recent emails from the company mailbox personality, and an email schedule. At the scheduled time, the system reads recent emails
(filtered by a configurable time window), builds a prompt from the entity's profile + the email from the company mailbox (IMAP), builds a prompt, calls the OpenAI API, and dispatches the
history, sends that prompt to an AI (OpenAI API), and dispatches the AI-generated email via SMTP. AI-generated email via SMTP.
- **Tone rule (critical):** Every generated email must be written in an **extremely formal, - **Tone rule (critical):** Every generated email must be written in an **extremely formal,
corporate tone** — but the **content is completely casual and nonsensical**, like internal corporate tone** — but the **content is completely casual and nonsensical**, like internal
jokes between friends. This contrast is the core joke of the project and must be preserved jokes between friends. This contrast is the core joke of the project and must be preserved
in every generated email. in every generated email.
- **Architecture:** REST API backend + scheduled AI-driven email dispatch - **Architecture:** React SPA → Spring Boot REST API → PostgreSQL + IMAP + SMTP + OpenAI
- **Deployment:** Fully containerized with Docker and Docker Compose
--- ---
## Tech Stack ## Tech Stack
### Backend
| Layer | Technology | | Layer | Technology |
|----------------|---------------------------------------------------| |----------------|---------------------------------------------------|
| Language | Kotlin (JVM) | | Language | Kotlin (JVM) |
@@ -32,64 +34,270 @@ instructions and context about the project so every session starts with the righ
| Database | PostgreSQL (via Spring Data JPA) | | Database | PostgreSQL (via Spring Data JPA) |
| Email Reading | Jakarta Mail (IMAP) — read inbox for context | | Email Reading | Jakarta Mail (IMAP) — read inbox for context |
| Email Sending | Spring Mail (SMTP / JavaMailSender) | | Email Sending | Spring Mail (SMTP / JavaMailSender) |
| AI Integration | OpenAI API (`gpt-4o`) via HTTP client | | AI Integration | OpenAI API (`gpt-4o`) via Spring `RestClient` |
| Scheduler | Spring `@Scheduled` tasks | | Scheduler | Spring `@Scheduled` + `SchedulingConfigurer` |
| Auth | JWT — issued by backend on login |
| Testing | JUnit 5 + MockK | | Testing | JUnit 5 + MockK |
| Docs | Springdoc OpenAPI (Swagger UI) | | Docs | Springdoc OpenAPI (Swagger UI) |
### Frontend
| Layer | Technology |
|----------------|---------------------------------------------------|
| Language | TypeScript |
| Framework | React 18 + Vite |
| UI Library | shadcn/ui (Radix UI + Tailwind CSS) |
| State | React Query (TanStack Query v5) |
| Routing | React Router v6 |
| HTTP Client | Axios |
| Auth | JWT stored in `httpOnly` cookie (set by backend) |
| Testing | Vitest + React Testing Library |
### Infrastructure
| Layer | Technology |
|-------------------|--------------------------------------------------------------|
| Containers | Docker |
| Orchestration | Docker Compose — three flavours (see below) |
| Reverse Proxy | Nginx (serves frontend + proxies `/api` to backend) |
| Dev Mail | Mailhog (SMTP trap + web UI) |
| All-in-one image | Single Docker image: Nginx + Spring Boot + PostgreSQL + Supervisor |
| Image registry | Docker Hub (`<dockerhub-user>/condado-newsletter`) |
| CI/CD | GitHub Actions — build, test, push to Docker Hub on merge to `main` |
## Deployment Flavours
There are **three ways to run the project**:
| Flavour | Command | When to use |
|---------------------|---------------------------------|------------------------------------------------|
| **Dev** | `docker compose up` | Local development — includes Mailhog |
| **Prod (compose)** | `docker compose -f docker-compose.prod.yml up` | Production with external DB/SMTP |
| **All-in-one** | `docker run ...` | Simplest deploy — everything in one container |
### All-in-one Image
The all-in-one image (`Dockerfile.allinone`) bundles **everything** into a single container:
- **Nginx** — serves the React SPA and proxies `/api` to Spring Boot
- **Spring Boot** — the backend API + scheduler
- **PostgreSQL** — embedded database
- **Supervisor** — process manager that starts and supervises all three processes
This image is published to Docker Hub at `<dockerhub-user>/condado-newsletter:latest`.
**Minimal `docker run` command:**
```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
```
The app is then available at `http://localhost`.
--- ---
## Project Structure ## System Topology
### Multi-container (Docker Compose)
```
┌─────────────────────────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ nginx :80 / :443 │ │
│ │ • Serves React SPA (static files) │ │
│ │ • Proxies /api/** ──────────────────────────────────► │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ backend :8080 │ │
│ │ Spring Boot (Kotlin) │ │
│ │ • REST API /api/v1/** │ │
│ │ • JWT auth /api/auth/login │ │
│ │ • Swagger /swagger-ui.html │ │
│ │ • Scheduler (cron per VirtualEntity) │ │
│ └──────────┬───────────────┬──────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────────────────────────────────┐ │
│ │ postgres :5432│ │ External services (outside Docker) │ │
│ │ PostgreSQL │ │ • OpenAI API (HTTPS) │ │
│ │ DB: condado │ │ • SMTP server (send emails) │ │
│ └──────────────┘ │ • IMAP server (read inbox) │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ mailhog :1025 (SMTP) / :8025 (UI) │ │
│ │ DEV ONLY — catches outgoing emails │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Browser ──────► nginx :80
├── / → React SPA (index.html)
├── /assets → Static JS/CSS
└── /api/** → backend :8080
```
### All-in-one Image (single `docker run`)
```
┌──────────────────────────────────────────────────────────────┐
│ condado-newsletter :80 (single container) │
│ │
│ supervisord │
│ ├── nginx (port 80 — SPA + /api proxy) │
│ ├── java -jar app (Spring Boot :8080 — internal only) │
│ └── postgres (PostgreSQL :5432 — internal only) │
│ │
│ Persistent data → Docker volume mounted at │
│ /var/lib/postgresql/data │
│ │
│ External (via env vars): │
│ • OpenAI API (HTTPS) │
│ • SMTP server (send emails) │
│ • IMAP server (read inbox) │
└──────────────────────────────────────────────────────────────┘
```
**Data flows:**
1. User opens browser → Nginx serves the React app.
2. React calls `POST /api/auth/login` with password → backend validates against `APP_PASSWORD`
env var → returns JWT in `httpOnly` cookie.
3. React calls `/api/v1/virtual-entities` (JWT cookie sent automatically) → backend responds.
4. Scheduler ticks → for each due `VirtualEntity`:
- Backend reads IMAP inbox (external IMAP server).
- Builds prompt → calls OpenAI API.
- Sends generated email via SMTP (Mailhog in dev, real SMTP in prod).
- Saves `DispatchLog` to PostgreSQL.
---
## Monorepo Structure
``` ```
src/ condado-news-letter/ ← repo root
├── main/ ├── CLAUDE.md
│ ├── kotlin/com/condado/newsletter/ ├── INSTRUCTIONS.md
│ │ ├── CondadoApplication.kt # App entry point ├── .env.example ← template for all env vars
│ │ ├── config/ # Spring configuration classes ├── .gitignore
│ │ ├── controller/ # REST controllers ├── docker-compose.yml ← dev stack (Nginx + Backend + PostgreSQL + Mailhog)
│ │ ├── service/ # Business logic ├── docker-compose.prod.yml ← prod stack (Nginx + Backend + PostgreSQL)
│ │ │ ├── EntityService.kt # CRUD for virtual entities ├── Dockerfile.allinone ← all-in-one image (Nginx + Backend + PostgreSQL + Supervisor)
│ │ ├── EmailReaderService.kt # Reads emails via IMAP
│ │ │ ├── PromptBuilderService.kt # Builds AI prompt from entity + emails ├── .github/
│ │ ├── AiService.kt # Calls OpenAI API └── workflows/
│ └── EmailSenderService.kt # Sends email via SMTP ├── ci.yml ← run tests on every PR
── repository/ # Spring Data JPA repositories ── publish.yml ← build & push all-in-one image to Docker Hub on main merge
│ ├── model/ # JPA entities
│ │ ├── dto/ # Data Transfer Objects ├── backend/ ← Spring Boot (Kotlin + Gradle)
│ └── scheduler/ # Scheduled tasks (trigger per entity) ├── build.gradle.kts
── resources/ ── settings.gradle.kts
├── application.yml # Main config ├── Dockerfile
└── application-dev.yml # Dev profile config └── src/
└── test/ │ ├── main/kotlin/com/condado/newsletter/
└── kotlin/com/condado/newsletter/ # Tests mirror main structure │ │ ├── CondadoApplication.kt
│ │ ├── config/
│ │ ├── controller/
│ │ ├── service/
│ │ │ ├── AuthService.kt
│ │ │ ├── EntityService.kt
│ │ │ ├── EmailReaderService.kt
│ │ │ ├── PromptBuilderService.kt
│ │ │ ├── AiService.kt
│ │ │ └── EmailSenderService.kt
│ │ ├── repository/
│ │ ├── model/
│ │ ├── dto/
│ │ └── scheduler/
│ └── main/resources/
│ ├── application.yml
│ └── application-dev.yml
├── frontend/ ← React (TypeScript + Vite)
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── Dockerfile
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── api/ ← Axios client + React Query hooks
│ ├── components/ ← Reusable UI components (shadcn/ui)
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── DashboardPage.tsx
│ │ ├── EntitiesPage.tsx
│ │ └── LogsPage.tsx
│ └── router/ ← React Router config
└── nginx/
└── nginx.conf ← Shared Nginx config (used in both Docker flavours)
``` ```
--- ---
## Build & Run Commands ## Build & Run Commands
### Backend
```bash ```bash
cd backend
# Build the project # Build the project
./gradlew build ./gradlew build
# Run the application (dev profile) # Run (dev profile)
./gradlew bootRun --args='--spring.profiles.active=dev' ./gradlew bootRun --args='--spring.profiles.active=dev'
# Run all tests # Run tests
./gradlew test ./gradlew test
# Run a specific test class # Run a specific test
./gradlew test --tests "com.condado.newsletter.service.PromptBuilderServiceTest" ./gradlew test --tests "com.condado.newsletter.service.PromptBuilderServiceTest"
```
# OpenAPI docs available at runtime ### Frontend
# http://localhost:8080/swagger-ui.html ```bash
cd frontend
# Install dependencies
npm install
# Dev server (with Vite proxy to backend)
npm run dev
# Build for production
npm run build
# Run tests
npm run test
```
### Full Stack (Docker Compose)
```bash
# Dev (Mailhog included)
docker compose up --build
# Prod
docker compose -f docker-compose.prod.yml up --build
# Stop
docker compose down
``` ```
--- ---
## Coding Standards ## Coding Standards
### Backend (Kotlin)
- Use **Kotlin idiomatic** style: data classes, extension functions, and null-safety operators. - Use **Kotlin idiomatic** style: data classes, extension functions, and null-safety operators.
- Prefer `val` over `var` wherever possible. - Prefer `val` over `var` wherever possible.
- Use **constructor injection** for dependencies (never field injection with `@Autowired`). - Use **constructor injection** for dependencies (never field injection with `@Autowired`).
@@ -101,52 +309,96 @@ src/
- All public functions must have **KDoc** comments. - All public functions must have **KDoc** comments.
- Use **`snake_case`** for database columns and **`camelCase`** for Kotlin properties. - Use **`snake_case`** for database columns and **`camelCase`** for Kotlin properties.
- Keep controllers thin — business logic belongs in services. - Keep controllers thin — business logic belongs in services.
- The AI prompt construction logic must live **exclusively** in `PromptBuilderService` — no other - The AI prompt construction logic must live **exclusively** in `PromptBuilderService`.
class should build or modify prompt strings.
### Frontend (TypeScript / React)
- All components must be **functional** — no class components.
- Use **TypeScript strict mode** — no `any` types.
- All API calls go through the `src/api/` layer — never call `axios` directly in a component.
- Use **React Query** for all server state — never store server data in `useState`.
- Use **shadcn/ui** components for UI — do not build custom UI primitives from scratch.
- All pages live in `src/pages/` and are lazy-loaded via React Router.
- Protected routes must check for a valid JWT before rendering.
- No hardcoded strings that face the user — use constants or i18n keys.
---
## Authentication Model
- There is **one single user** — the admin (you and your friends sharing the app).
- The password is set via the `APP_PASSWORD` environment variable on the backend.
- `POST /api/auth/login` accepts `{ "password": "..." }` and returns a JWT in an `httpOnly`
cookie (no username needed).
- The JWT is validated by Spring Security on every protected backend request.
- The React frontend redirects to `/login` if the cookie is absent or the JWT is expired.
- There is **no user registration, no user table, no role system**.
--- ---
## Naming Conventions ## Naming Conventions
| Artifact | Convention | Example | ### Backend
|----------------|-----------------------------------|-------------------------------| | Artifact | Convention | Example |
| Classes | PascalCase | `PromptBuilderService` | |----------------|----------------------|-------------------------------|
| Functions | camelCase | `buildPrompt()` | | Classes | PascalCase | `PromptBuilderService` |
| Variables | camelCase | `entityList` | | Functions | camelCase | `buildPrompt()` |
| Constants | SCREAMING_SNAKE_CASE | `MAX_EMAIL_CONTEXT_DAYS` | | Variables | camelCase | `entityList` |
| DB tables | snake_case (plural) | `virtual_entities` | | Constants | SCREAMING_SNAKE_CASE | `MAX_EMAIL_CONTEXT_DAYS` |
| REST endpoints | kebab-case | `/api/v1/virtual-entities` | | DB tables | snake_case (plural) | `virtual_entities` |
| Packages | lowercase | `com.condado.newsletter` | | REST endpoints | kebab-case | `/api/v1/virtual-entities` |
| Packages | lowercase | `com.condado.newsletter` |
### Frontend
| Artifact | Convention | Example |
|----------------|----------------------|-------------------------------|
| Components | PascalCase | `EntityCard.tsx` |
| Hooks | camelCase + `use` | `useEntities.ts` |
| API files | camelCase | `entitiesApi.ts` |
| Pages | PascalCase + `Page` | `EntitiesPage.tsx` |
| CSS classes | kebab-case (Tailwind)| `text-sm font-medium` |
--- ---
## Testing Guidelines ## Testing Guidelines
### Backend
- Every service class must have a corresponding unit test class. - Every service class must have a corresponding unit test class.
- Use **MockK** for mocking (not Mockito). - Use **MockK** for mocking (not Mockito).
- Integration tests use `@SpringBootTest` and an **H2 in-memory** database. - Integration tests use `@SpringBootTest` and an **H2 in-memory** database.
- Test method names follow the pattern: `should_[expectedBehavior]_when_[condition]`. - Test method names follow: `should_[expectedBehavior]_when_[condition]`.
- Minimum 80% code coverage for service classes. - Minimum 80% code coverage for service classes.
### Frontend
- Every page component must have at least a smoke test (renders without crashing).
- API layer functions must be tested with mocked Axios responses.
- Use **Vitest** as the test runner and **React Testing Library** for component tests.
--- ---
## Environment Variables ## Environment Variables
| Variable | Description | All variables are defined in `.env` (root of the monorepo) and injected by Docker Compose.
|---------------------------|------------------------------------------------------| Never hardcode any of these values.
| `SPRING_DATASOURCE_URL` | PostgreSQL connection URL |
| `SPRING_DATASOURCE_USERNAME` | DB username | | Variable | Used by | Description |
| `SPRING_DATASOURCE_PASSWORD` | DB password | |------------------------------|----------|------------------------------------------------------|
| `MAIL_HOST` | SMTP host (for sending emails) | | `APP_PASSWORD` | Backend | Single admin password for login |
| `MAIL_PORT` | SMTP port | | `JWT_SECRET` | Backend | Secret key for signing/verifying JWTs |
| `MAIL_USERNAME` | SMTP username (also used as IMAP login) | | `JWT_EXPIRATION_MS` | Backend | JWT expiry in milliseconds (e.g. `86400000` = 1 day) |
| `MAIL_PASSWORD` | SMTP/IMAP password | | `SPRING_DATASOURCE_URL` | Backend | PostgreSQL connection URL |
| `IMAP_HOST` | IMAP host (for reading the shared inbox) | | `SPRING_DATASOURCE_USERNAME` | Backend | DB username |
| `IMAP_PORT` | IMAP port (default: 993) | | `SPRING_DATASOURCE_PASSWORD` | Backend | DB password |
| `IMAP_INBOX_FOLDER` | IMAP folder to read (default: `INBOX`) | | `MAIL_HOST` | Backend | SMTP host (Mailhog in dev, real SMTP in prod) |
| `OPENAI_API_KEY` | OpenAI API key for AI generation | | `MAIL_PORT` | Backend | SMTP port |
| `OPENAI_MODEL` | OpenAI model to use (default: `gpt-4o`) | | `MAIL_USERNAME` | Backend | SMTP username (also IMAP login) |
| `API_KEY` | API key to protect the REST endpoints | | `MAIL_PASSWORD` | Backend | SMTP/IMAP password |
| `IMAP_HOST` | Backend | IMAP host for reading the shared inbox |
| `IMAP_PORT` | Backend | IMAP port (default: 993) |
| `IMAP_INBOX_FOLDER` | Backend | IMAP folder to read (default: `INBOX`) |
| `OPENAI_API_KEY` | Backend | OpenAI API key |
| `OPENAI_MODEL` | Backend | OpenAI model (default: `gpt-4o`) |
| `APP_RECIPIENTS` | Backend | Comma-separated list of recipient emails |
| `VITE_API_BASE_URL` | Frontend | Backend API base URL (used by Vite at build time) |
> ⚠️ Never hardcode credentials. Always use environment variables or a `.env` file (gitignored). > ⚠️ Never hardcode credentials. Always use environment variables or a `.env` file (gitignored).
@@ -159,7 +411,8 @@ src/
(cron expression), and an email context window (how many days back to read emails for context). (cron expression), and an email context window (how many days back to read emails for context).
- **EmailContext:** A snapshot of recent emails read from the shared IMAP inbox, filtered by the - **EmailContext:** A snapshot of recent emails read from the shared IMAP inbox, filtered by the
entity's configured context window (e.g., last 3 days). Used to give the AI conversational context. entity's configured context window (e.g., last 3 days). Used to give the AI conversational
context.
- **Prompt:** The full text sent to the OpenAI API. Built by `PromptBuilderService` from the - **Prompt:** The full text sent to the OpenAI API. Built by `PromptBuilderService` from the
entity's profile + the `EmailContext`. Always instructs the AI to write in an extremely formal entity's profile + the `EmailContext`. Always instructs the AI to write in an extremely formal
@@ -191,13 +444,37 @@ for context:
Write a new email to be sent to the company group, continuing the conversation naturally. Write a new email to be sent to the company group, continuing the conversation naturally.
Reply or react to the recent emails if relevant. Sign off as [entity.name], [entity.jobTitle]. Reply or react to the recent emails if relevant. Sign off as [entity.name], [entity.jobTitle].
Format your response exactly as:
SUBJECT: <subject line here>
BODY:
<full email body here>
``` ```
--- ---
## Git Workflow ## Git Workflow & CI/CD
- Branch naming: `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>` - Branch naming: `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>`
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, `test:` - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, `test:`
- PRs require at least one passing CI check before merging. - Scope your commits: `feat(backend):`, `feat(frontend):`, `chore(docker):`
- PRs require all CI checks to pass before merging.
- Never commit directly to `main`. - Never commit directly to `main`.
### GitHub Actions Workflows
| Workflow file | Trigger | What it does |
|----------------------------|----------------------------|-----------------------------------------------------------|
| `.github/workflows/ci.yml` | Push / PR to any branch | Backend tests (`./gradlew test`) + Frontend tests (`npm run test`) |
| `.github/workflows/publish.yml` | Push to `main` | Builds `Dockerfile.allinone`, tags as `latest` + git SHA, pushes to Docker Hub |
**Required GitHub Secrets:**
| Secret | Description |
|-----------------------|--------------------------------------------|
| `DOCKERHUB_USERNAME` | Docker Hub account username |
| `DOCKERHUB_TOKEN` | Docker Hub access token (not password) |
**Image tags pushed on every `main` merge:**
- `<dockerhub-user>/condado-newsletter:latest`
- `<dockerhub-user>/condado-newsletter:<git-sha>` (for pinning)

View File

@@ -37,7 +37,7 @@ employee is an AI-powered entity that:
| Step | Description | Status | | Step | Description | Status |
|------|-----------------------------------------|-------------| |------|-----------------------------------------|-------------|
| 0 | Define project & write CLAUDE.md | ✅ Done | | 0 | Define project & write CLAUDE.md | ✅ Done |
| 1 | Scaffold project structure | ⬜ Pending | | 1 | Scaffold monorepo structure | ⬜ Pending |
| 2 | Domain model (JPA entities) | ⬜ Pending | | 2 | Domain model (JPA entities) | ⬜ Pending |
| 3 | Repositories | ⬜ Pending | | 3 | Repositories | ⬜ Pending |
| 4 | Email Reader Service (IMAP) | ⬜ Pending | | 4 | Email Reader Service (IMAP) | ⬜ Pending |
@@ -46,9 +46,12 @@ employee is an AI-powered entity that:
| 7 | Email Sender Service (SMTP) | ⬜ Pending | | 7 | Email Sender Service (SMTP) | ⬜ Pending |
| 8 | Scheduler (trigger per entity) | ⬜ Pending | | 8 | Scheduler (trigger per entity) | ⬜ Pending |
| 9 | REST Controllers & DTOs | ⬜ Pending | | 9 | REST Controllers & DTOs | ⬜ Pending |
| 10 | Security (API Key auth) | ⬜ Pending | | 10 | Authentication (JWT login) | ⬜ Pending |
| 11 | Unit & integration tests | ⬜ Pending | | 11 | React Frontend | ⬜ Pending |
| 12 | Docker & deployment config | ⬜ Pending | | 12 | Unit & Integration Tests | ⬜ Pending |
| 13 | Docker Compose (dev + prod) | ⬜ Pending |
| 14 | All-in-one Docker image | ⬜ Pending |
| 15 | CI/CD — GitHub Actions + Docker Hub | ⬜ Pending |
--- ---
@@ -77,36 +80,51 @@ employee is an AI-powered entity that:
--- ---
## Step 1 — Scaffold the Project Structure ## Step 1 — Scaffold the Monorepo Structure
**Goal:** Generate the full Gradle project skeleton with all dependencies configured. **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:** **What the AI should create:**
``` ```
condado-news-letter/ condado-news-letter/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── .gitignore
├── .env.example ├── .env.example
── src/ ── .gitignore
├── main/ ├── docker-compose.yml
│ ├── kotlin/com/condado/newsletter/ ├── docker-compose.prod.yml
│ │ └── CondadoApplication.kt ├── Dockerfile.allinone
│ └── resources/ ├── nginx/
│ ├── application.yml │ └── nginx.conf
│ └── application-dev.yml ├── .github/
└── test/ └── workflows/
── kotlin/com/condado/newsletter/ ── ci.yml
└── CondadoApplicationTests.kt └── 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
``` ```
**Dependencies to include in `build.gradle.kts`:** **Backend dependencies (`backend/build.gradle.kts`):**
| Dependency | Purpose | | Dependency | Purpose |
|-----------------------------------------|----------------------------------------------| |-----------------------------------------|----------------------------------------------|
@@ -114,9 +132,10 @@ condado-news-letter/
| `spring-boot-starter-data-jpa` | Database access (JPA/Hibernate) | | `spring-boot-starter-data-jpa` | Database access (JPA/Hibernate) |
| `spring-boot-starter-mail` | Email sending via SMTP | | `spring-boot-starter-mail` | Email sending via SMTP |
| `spring-boot-starter-validation` | DTO validation | | `spring-boot-starter-validation` | DTO validation |
| `spring-boot-starter-security` | API key authentication | | `spring-boot-starter-security` | JWT authentication |
| `jjwt-api`, `jjwt-impl`, `jjwt-jackson` | JWT creation and validation (JJWT library) |
| `postgresql` | PostgreSQL JDBC driver | | `postgresql` | PostgreSQL JDBC driver |
| `jakarta.mail` / `angus-mail` | IMAP email reading | | `angus-mail` | IMAP email reading (Jakarta Mail impl) |
| `springdoc-openapi-starter-webmvc-ui` | Swagger UI / OpenAPI docs | | `springdoc-openapi-starter-webmvc-ui` | Swagger UI / OpenAPI docs |
| `kotlin-reflect` | Required by Spring for Kotlin | | `kotlin-reflect` | Required by Spring for Kotlin |
| `jackson-module-kotlin` | JSON serialization for Kotlin | | `jackson-module-kotlin` | JSON serialization for Kotlin |
@@ -125,32 +144,42 @@ condado-news-letter/
| `mockk` | Kotlin mocking library | | `mockk` | Kotlin mocking library |
| `springmockk` | MockK integration for Spring | | `springmockk` | MockK integration for Spring |
**`.env.example` should contain:** **Frontend dependencies (`frontend/package.json`):**
```env
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado | Package | Purpose |
SPRING_DATASOURCE_USERNAME=postgres |-------------------------------|------------------------------------------|
SPRING_DATASOURCE_PASSWORD=postgres | `react`, `react-dom` | Core React |
MAIL_HOST=smtp.example.com | `typescript` | TypeScript |
MAIL_PORT=587 | `vite` | Build tool and dev server |
MAIL_USERNAME=company@example.com | `@vitejs/plugin-react` | Vite React plugin |
MAIL_PASSWORD=secret | `react-router-dom` | Client-side routing |
IMAP_HOST=imap.example.com | `@tanstack/react-query` | Server state management |
IMAP_PORT=993 | `axios` | HTTP client |
IMAP_INBOX_FOLDER=INBOX | `tailwindcss`, `postcss`, `autoprefixer` | Styling |
OPENAI_API_KEY=sk-... | `@radix-ui/*`, `shadcn/ui` | UI component library |
OPENAI_MODEL=gpt-4o | `lucide-react` | Icon library (used by shadcn) |
API_KEY=change-me | `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:** **Prompt to use with AI:**
> "Using the CLAUDE.md context, generate the full `build.gradle.kts`, `settings.gradle.kts`, > "Using the CLAUDE.md context, scaffold the full monorepo. Create the backend Gradle project
> `application.yml`, `application-dev.yml`, `.gitignore`, `.env.example`, and the main > with all dependencies, the frontend Vite+React project with all packages, the root `.env.example`,
> application entry point `CondadoApplication.kt`." > `.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:** **Done when:**
- [ ] `./gradlew build` runs successfully (compile only, no logic yet). - [ ] `cd backend && ./gradlew build` compiles with no errors.
- [ ] Application starts with `./gradlew bootRun` without errors. - [ ] `cd frontend && npm install && npm run build` succeeds.
- [ ] Swagger UI is accessible at `http://localhost:8080/swagger-ui.html`. - [ ] Application starts with `./gradlew bootRun` (backend) without errors.
- [ ] `npm run dev` starts the Vite dev server.
- [ ] `docker compose up --build` starts all containers.
---
--- ---
@@ -429,80 +458,319 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca
--- ---
## Step 10 — Security (API Key Authentication) ## Step 10 — Authentication (JWT Login)
**Goal:** Protect all API endpoints with a simple API key header. **Goal:** Implement the single-admin JWT login that protects all API endpoints.
**Approach:** **Approach:**
- Spring Security with a custom `OncePerRequestFilter`. - `POST /api/auth/login` accepts `{ "password": "..." }`, validates against `APP_PASSWORD` env var.
- Clients must send `X-API-KEY: <value>` header. - On success, generates a JWT (signed with `JWT_SECRET`, expiry from `JWT_EXPIRATION_MS`) and
- Key is read from `API_KEY` environment variable. sets it as an `httpOnly` cookie in the response.
- Swagger UI and OpenAPI spec (`/swagger-ui.html`, `/v3/api-docs/**`) are public. - Spring Security `JwtAuthFilter` (extends `OncePerRequestFilter`) validates the cookie on every
protected request.
- Public paths: `POST /api/auth/login`, `/swagger-ui.html`, `/v3/api-docs/**`.
- There is **no user table** — the password lives only in the environment variable.
**Classes to create:**
- `AuthController` — `POST /api/auth/login` endpoint
- `AuthService` — validates password, generates JWT
- `JwtService` — signs and validates JWT tokens
- `JwtAuthFilter` — reads cookie, validates JWT, sets `SecurityContext`
- `SecurityConfig` — Spring Security HTTP config (permit login + swagger, protect everything else)
**DTOs:**
- `LoginRequest` — `{ "password": String }`
- `AuthResponse` — `{ "message": String }` (cookie is set on the response; no token in body)
**Prompt to use with AI:** **Prompt to use with AI:**
> "Using the CLAUDE.md context, add API key authentication with Spring Security. Create a > "Using the CLAUDE.md context, implement JWT authentication for the single-admin model.
> custom filter that checks the `X-API-KEY` header against the `API_KEY` env var. Swagger UI > Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`.
> paths must be excluded from authentication." > `POST /api/auth/login` validates against `APP_PASSWORD` env var and returns a JWT in an
> httpOnly cookie. All other endpoints require the JWT cookie. Swagger UI is public."
**Done when:** **Done when:**
- [ ] All endpoints return `401` without the correct `X-API-KEY` header. - [ ] `POST /api/auth/login` with correct password sets an `httpOnly` JWT cookie and returns `200`.
- [ ] Swagger UI is still accessible without auth. - [ ] `POST /api/auth/login` with wrong password returns `401`.
- [ ] API key is never hardcoded. - [ ] All `/api/v1/**` endpoints return `401` without a valid JWT cookie.
- [ ] Swagger UI remains accessible without auth.
- [ ] Password and JWT secret are never hardcoded.
--- ---
## Step 11 — Unit & Integration Tests ## 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 `/`.
**Prompt to use with AI:**
> "Using the CLAUDE.md context, build the React frontend. Create all four pages (Login,
> Dashboard, Entities, Logs) with React Query for data fetching, shadcn/ui for components,
> and React Router for navigation. Implement `ProtectedRoute` using `GET /api/auth/me`.
> All API calls must go through the `src/api/` layer."
**Done when:**
- [ ] `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.
- [ ] All pages have at least a smoke test (`npm run test` passes).
---
## Step 12 — Unit & Integration Tests
**Goal:** Test every service class and one integration test per controller. **Goal:** Test every service class and one integration test per controller.
### Backend tests
| Test Class | Type | Covers | | Test Class | Type | Covers |
|---------------------------------|-------------|----------------------------------------------| |---------------------------------|-------------|----------------------------------------------|
| `EmailReaderServiceTest` | Unit | IMAP fetch, empty list on error | | `EmailReaderServiceTest` | Unit | IMAP fetch, empty list on error |
| `PromptBuilderServiceTest` | Unit | Prompt matches template, fields substituted | | `PromptBuilderServiceTest` | Unit | Prompt matches template, fields substituted |
| `AiServiceTest` | Unit | API call mocked, parseResponse parsing | | `AiServiceTest` | Unit | API call mocked, `parseResponse` parsing |
| `EmailSenderServiceTest` | Unit | JavaMailSender called with correct args | | `EmailSenderServiceTest` | Unit | `JavaMailSender` called with correct args |
| `AuthServiceTest` | Unit | Correct/incorrect password, JWT generation |
| `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint | | `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint |
| `DispatchLogControllerTest` | Integration | List all, list by entity | | `DispatchLogControllerTest` | Integration | List all, list by entity |
### Frontend tests
| Test File | Covers |
|---------------------------------|--------------------------------------------------------|
| `LoginPage.test.tsx` | Renders, submits form, shows error on wrong password |
| `EntitiesPage.test.tsx` | Lists entities, opens create dialog, handles delete |
| `authApi.test.ts` | `login()` calls correct endpoint with correct payload |
| `entitiesApi.test.ts` | CRUD functions call correct endpoints |
**Prompt to use with AI:** **Prompt to use with AI:**
> "Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and > "Using the CLAUDE.md context, generate unit tests for all backend service classes using MockK,
> integration tests for controllers using `@SpringBootTest` with H2. Follow naming convention > integration tests for controllers using `@SpringBootTest` with H2, and frontend component and
> `should_[expectedBehavior]_when_[condition]`." > API layer tests using Vitest + React Testing Library. Follow naming convention
> `should_[expectedBehavior]_when_[condition]` for backend tests."
**Done when:** **Done when:**
- [ ] `./gradlew test` passes all tests green. - [ ] `./gradlew test` passes all backend tests green.
- [ ] Service class coverage ≥ 80%. - [ ] Backend service class coverage ≥ 80%.
- [ ] `npm run test` passes all frontend tests green.
--- ---
## Step 12 — Docker & Deployment Config ## Step 13 — Docker Compose (Dev + Prod)
**Goal:** Containerize the app and provide a local dev stack. **Goal:** Containerize both services and wire them together for local dev and production.
**Files to create / update:**
**Files to create:**
``` ```
condado-news-letter/ condado-news-letter/
├── Dockerfile # Multi-stage build ├── backend/Dockerfile # Multi-stage: Gradle build → slim JRE runtime
├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP/IMAP) ├── frontend/Dockerfile # Multi-stage: Node build → Nginx static file server
── docker-compose.prod.yml # App + PostgreSQL only ── 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:** **Notes:**
- Use [Mailhog](https://github.com/mailhog/MailHog) in dev to capture outgoing emails - Use [Mailhog](https://github.com/mailhog/MailHog) in dev (SMTP port 1025, web UI port 8025).
(SMTP on port 1025, web UI on port 8025). - The `nginx` service serves the built React SPA and proxies `/api/**` to `backend:8080`.
- For IMAP in dev, consider [Greenmail](https://greenmail-mail-test.github.io/greenmail/) as - Backend and Postgres communicate over an internal Docker network.
a local IMAP server for end-to-end testing. - Env vars come from `.env` at the repo root (copied from `.env.example`).
**Prompt to use with AI:** **Prompt to use with AI:**
> "Using the CLAUDE.md context, create a multi-stage `Dockerfile` for the Spring Boot app, a > "Using the CLAUDE.md context, create multi-stage Dockerfiles for the backend and frontend,
> `docker-compose.yml` for local dev (PostgreSQL + Mailhog + Greenmail), and a > an `nginx/nginx.conf` that serves the React SPA and proxies `/api` to the backend, a
> `docker-compose.prod.yml` for production (PostgreSQL only, env vars from host)." > `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:** **Done when:**
- [ ] `docker-compose up` starts the full stack. - [ ] `docker compose up --build` starts all services without errors.
- [ ] App connects to PostgreSQL container. - [ ] `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`. - [ ] Outgoing emails appear in Mailhog at `http://localhost:8025`.
- [ ] `docker build -t condado-newsletter .` succeeds. - [ ] `docker compose -f docker-compose.prod.yml up --build` works (no Mailhog).
---
## Step 14 — 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 15 — 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).
--- ---
@@ -514,359 +782,11 @@ condado-news-letter/
| 2026-03-26 | MockK over Mockito | More idiomatic for Kotlin | | 2026-03-26 | MockK over Mockito | More idiomatic for Kotlin |
| 2026-03-26 | UUID as primary keys | Better for distributed systems | | 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 | 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 | 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 | 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 | 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 | `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 | 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 |
## 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 |