diff --git a/CLAUDE.md b/CLAUDE.md index 285e319..464094b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,29 +1,31 @@ # Condado Abaixo da Média SA — Email Bot -A backend service built with **Kotlin** and **Spring Boot**. This file gives the AI persistent -instructions and context about the project so every session starts with the right knowledge. +A **monorepo** containing a Kotlin/Spring Boot backend and a React frontend. This file gives +the AI persistent instructions and context about the project so every session starts with the +right knowledge. --- ## Project Overview -- **Language:** Kotlin (JVM) -- **Framework:** Spring Boot 3.x +- **Type:** Monorepo (backend + frontend in the same repository) - **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 - email schedule. At the scheduled time, the system reads recent emails from the company mailbox - (filtered by a configurable time window), builds a prompt from the entity's profile + the email - history, sends that prompt to an AI (OpenAI API), and dispatches the AI-generated email via SMTP. + Each entity is an AI-powered fictional employee with a name, email address, job title, + personality, and an email schedule. At the scheduled time, the system reads recent emails + from the company mailbox (IMAP), builds a prompt, calls the OpenAI API, and dispatches the + AI-generated email via SMTP. - **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 jokes between friends. This contrast is the core joke of the project and must be preserved 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 +### Backend | Layer | Technology | |----------------|---------------------------------------------------| | 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) | | Email Reading | Jakarta Mail (IMAP) — read inbox for context | | Email Sending | Spring Mail (SMTP / JavaMailSender) | -| AI Integration | OpenAI API (`gpt-4o`) via HTTP client | -| Scheduler | Spring `@Scheduled` tasks | +| AI Integration | OpenAI API (`gpt-4o`) via Spring `RestClient` | +| Scheduler | Spring `@Scheduled` + `SchedulingConfigurer` | +| Auth | JWT — issued by backend on login | | Testing | JUnit 5 + MockK | | 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 (`/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 `/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 \ + /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/ -├── main/ -│ ├── kotlin/com/condado/newsletter/ -│ │ ├── CondadoApplication.kt # App entry point -│ │ ├── config/ # Spring configuration classes -│ │ ├── controller/ # REST controllers -│ │ ├── service/ # Business logic -│ │ │ ├── EntityService.kt # CRUD for virtual entities -│ │ │ ├── EmailReaderService.kt # Reads emails via IMAP -│ │ │ ├── PromptBuilderService.kt # Builds AI prompt from entity + emails -│ │ │ ├── AiService.kt # Calls OpenAI API -│ │ │ └── EmailSenderService.kt # Sends email via SMTP -│ │ ├── repository/ # Spring Data JPA repositories -│ │ ├── model/ # JPA entities -│ │ ├── dto/ # Data Transfer Objects -│ │ └── scheduler/ # Scheduled tasks (trigger per entity) -│ └── resources/ -│ ├── application.yml # Main config -│ └── application-dev.yml # Dev profile config -└── test/ - └── kotlin/com/condado/newsletter/ # Tests mirror main structure +condado-news-letter/ ← repo root +├── CLAUDE.md +├── INSTRUCTIONS.md +├── .env.example ← template for all env vars +├── .gitignore +├── docker-compose.yml ← dev stack (Nginx + Backend + PostgreSQL + Mailhog) +├── docker-compose.prod.yml ← prod stack (Nginx + Backend + PostgreSQL) +├── Dockerfile.allinone ← all-in-one image (Nginx + Backend + PostgreSQL + Supervisor) +│ +├── .github/ +│ └── workflows/ +│ ├── ci.yml ← run tests on every PR +│ └── publish.yml ← build & push all-in-one image to Docker Hub on main merge +│ +├── backend/ ← Spring Boot (Kotlin + Gradle) +│ ├── build.gradle.kts +│ ├── settings.gradle.kts +│ ├── Dockerfile +│ └── src/ +│ ├── main/kotlin/com/condado/newsletter/ +│ │ ├── 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 +### Backend ```bash +cd backend + # Build the project ./gradlew build -# Run the application (dev profile) +# Run (dev profile) ./gradlew bootRun --args='--spring.profiles.active=dev' -# Run all tests +# Run tests ./gradlew test -# Run a specific test class +# Run a specific test ./gradlew test --tests "com.condado.newsletter.service.PromptBuilderServiceTest" +``` -# OpenAPI docs available at runtime -# http://localhost:8080/swagger-ui.html +### Frontend +```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 +### Backend (Kotlin) - Use **Kotlin idiomatic** style: data classes, extension functions, and null-safety operators. - Prefer `val` over `var` wherever possible. - Use **constructor injection** for dependencies (never field injection with `@Autowired`). @@ -101,52 +309,96 @@ src/ - All public functions must have **KDoc** comments. - Use **`snake_case`** for database columns and **`camelCase`** for Kotlin properties. - Keep controllers thin — business logic belongs in services. -- The AI prompt construction logic must live **exclusively** in `PromptBuilderService` — no other - class should build or modify prompt strings. +- The AI prompt construction logic must live **exclusively** in `PromptBuilderService`. + +### 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 -| Artifact | Convention | Example | -|----------------|-----------------------------------|-------------------------------| -| Classes | PascalCase | `PromptBuilderService` | -| Functions | camelCase | `buildPrompt()` | -| Variables | camelCase | `entityList` | -| Constants | SCREAMING_SNAKE_CASE | `MAX_EMAIL_CONTEXT_DAYS` | -| DB tables | snake_case (plural) | `virtual_entities` | -| REST endpoints | kebab-case | `/api/v1/virtual-entities` | -| Packages | lowercase | `com.condado.newsletter` | +### Backend +| Artifact | Convention | Example | +|----------------|----------------------|-------------------------------| +| Classes | PascalCase | `PromptBuilderService` | +| Functions | camelCase | `buildPrompt()` | +| Variables | camelCase | `entityList` | +| Constants | SCREAMING_SNAKE_CASE | `MAX_EMAIL_CONTEXT_DAYS` | +| DB tables | snake_case (plural) | `virtual_entities` | +| 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 +### Backend - Every service class must have a corresponding unit test class. - Use **MockK** for mocking (not Mockito). - 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. +### 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 -| Variable | Description | -|---------------------------|------------------------------------------------------| -| `SPRING_DATASOURCE_URL` | PostgreSQL connection URL | -| `SPRING_DATASOURCE_USERNAME` | DB username | -| `SPRING_DATASOURCE_PASSWORD` | DB password | -| `MAIL_HOST` | SMTP host (for sending emails) | -| `MAIL_PORT` | SMTP port | -| `MAIL_USERNAME` | SMTP username (also used as IMAP login) | -| `MAIL_PASSWORD` | SMTP/IMAP password | -| `IMAP_HOST` | IMAP host (for reading the shared inbox) | -| `IMAP_PORT` | IMAP port (default: 993) | -| `IMAP_INBOX_FOLDER` | IMAP folder to read (default: `INBOX`) | -| `OPENAI_API_KEY` | OpenAI API key for AI generation | -| `OPENAI_MODEL` | OpenAI model to use (default: `gpt-4o`) | -| `API_KEY` | API key to protect the REST endpoints | +All variables are defined in `.env` (root of the monorepo) and injected by Docker Compose. +Never hardcode any of these values. + +| Variable | Used by | Description | +|------------------------------|----------|------------------------------------------------------| +| `APP_PASSWORD` | Backend | Single admin password for login | +| `JWT_SECRET` | Backend | Secret key for signing/verifying JWTs | +| `JWT_EXPIRATION_MS` | Backend | JWT expiry in milliseconds (e.g. `86400000` = 1 day) | +| `SPRING_DATASOURCE_URL` | Backend | PostgreSQL connection URL | +| `SPRING_DATASOURCE_USERNAME` | Backend | DB username | +| `SPRING_DATASOURCE_PASSWORD` | Backend | DB password | +| `MAIL_HOST` | Backend | SMTP host (Mailhog in dev, real SMTP in prod) | +| `MAIL_PORT` | Backend | SMTP port | +| `MAIL_USERNAME` | Backend | SMTP username (also IMAP login) | +| `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). @@ -159,7 +411,8 @@ src/ (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 - 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 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. Reply or react to the recent emails if relevant. Sign off as [entity.name], [entity.jobTitle]. + +Format your response exactly as: +SUBJECT: +BODY: + ``` --- -## Git Workflow +## Git Workflow & CI/CD - Branch naming: `feature/`, `fix/`, `chore/` - 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`. + +### 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:** +- `/condado-newsletter:latest` +- `/condado-newsletter:` (for pinning) diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index ddaae77..21c736b 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -37,7 +37,7 @@ employee is an AI-powered entity that: | Step | Description | Status | |------|-----------------------------------------|-------------| | 0 | Define project & write CLAUDE.md | ✅ Done | -| 1 | Scaffold project structure | ⬜ Pending | +| 1 | Scaffold monorepo structure | ⬜ Pending | | 2 | Domain model (JPA entities) | ⬜ Pending | | 3 | Repositories | ⬜ Pending | | 4 | Email Reader Service (IMAP) | ⬜ Pending | @@ -46,9 +46,12 @@ employee is an AI-powered entity that: | 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 | +| 10 | Authentication (JWT login) | ⬜ Pending | +| 11 | React Frontend | ⬜ 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:** ``` 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 +├── .gitignore +├── docker-compose.yml +├── docker-compose.prod.yml +├── Dockerfile.allinone +├── nginx/ +│ └── nginx.conf +├── .github/ +│ └── workflows/ +│ ├── ci.yml +│ └── publish.yml +├── backend/ +│ ├── Dockerfile +│ ├── build.gradle.kts +│ ├── settings.gradle.kts +│ ├── gradlew / gradlew.bat +│ ├── gradle/wrapper/ +│ └── src/main/kotlin/com/condado/newsletter/ +│ └── CondadoApplication.kt +│ └── src/main/resources/ +│ ├── application.yml +│ └── application-dev.yml +│ └── src/test/kotlin/com/condado/newsletter/ +│ └── CondadoApplicationTests.kt +└── frontend/ + ├── Dockerfile + ├── package.json + ├── vite.config.ts + ├── tsconfig.json + ├── index.html + └── src/ + ├── main.tsx + └── App.tsx ``` -**Dependencies to include in `build.gradle.kts`:** +**Backend dependencies (`backend/build.gradle.kts`):** | Dependency | Purpose | |-----------------------------------------|----------------------------------------------| @@ -114,9 +132,10 @@ condado-news-letter/ | `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 | +| `spring-boot-starter-security` | JWT authentication | +| `jjwt-api`, `jjwt-impl`, `jjwt-jackson` | JWT creation and validation (JJWT library) | | `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 | | `kotlin-reflect` | Required by Spring for Kotlin | | `jackson-module-kotlin` | JSON serialization for Kotlin | @@ -125,32 +144,42 @@ condado-news-letter/ | `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 -``` +**Frontend dependencies (`frontend/package.json`):** + +| Package | Purpose | +|-------------------------------|------------------------------------------| +| `react`, `react-dom` | Core React | +| `typescript` | TypeScript | +| `vite` | Build tool and dev server | +| `@vitejs/plugin-react` | Vite React plugin | +| `react-router-dom` | Client-side routing | +| `@tanstack/react-query` | Server state management | +| `axios` | HTTP client | +| `tailwindcss`, `postcss`, `autoprefixer` | Styling | +| `@radix-ui/*`, `shadcn/ui` | UI component library | +| `lucide-react` | Icon library (used by shadcn) | +| `vitest` | Test runner | +| `@testing-library/react` | Component testing | +| `@testing-library/jest-dom` | DOM matchers | +| `jsdom` | Browser environment for tests | + +**`.env.example` must contain all variables from the Environment Variables table in `CLAUDE.md`.** **Prompt to use with AI:** -> "Using the CLAUDE.md context, 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`." +> "Using the CLAUDE.md context, scaffold the full monorepo. Create the backend Gradle project +> with all dependencies, the frontend Vite+React project with all packages, the root `.env.example`, +> `.gitignore`, placeholder `docker-compose.yml`, `docker-compose.prod.yml`, `Dockerfile.allinone`, +> `nginx/nginx.conf`, and GitHub Actions workflow stubs at `.github/workflows/ci.yml` and +> `.github/workflows/publish.yml`. Do not implement business logic yet — just the skeleton." **Done when:** -- [ ] `./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`. +- [ ] `cd backend && ./gradlew build` compiles with no errors. +- [ ] `cd frontend && npm install && npm run build` succeeds. +- [ ] 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:** -- Spring Security with a custom `OncePerRequestFilter`. -- Clients must send `X-API-KEY: ` header. -- Key is read from `API_KEY` environment variable. -- Swagger UI and OpenAPI spec (`/swagger-ui.html`, `/v3/api-docs/**`) are public. +- `POST /api/auth/login` accepts `{ "password": "..." }`, validates against `APP_PASSWORD` env var. +- On success, generates a JWT (signed with `JWT_SECRET`, expiry from `JWT_EXPIRATION_MS`) and + sets it as an `httpOnly` cookie in the response. +- 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:** -> "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." +> "Using the CLAUDE.md context, implement JWT authentication for the single-admin model. +> Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`. +> `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:** -- [ ] All endpoints return `401` without the correct `X-API-KEY` header. -- [ ] Swagger UI is still accessible without auth. -- [ ] API key is never hardcoded. +- [ ] `POST /api/auth/login` with correct password sets an `httpOnly` JWT cookie and returns `200`. +- [ ] `POST /api/auth/login` with wrong password returns `401`. +- [ ] 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. +### Backend tests + | 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 | +| `AiServiceTest` | Unit | API call mocked, `parseResponse` parsing | +| `EmailSenderServiceTest` | Unit | `JavaMailSender` called with correct args | +| `AuthServiceTest` | Unit | Correct/incorrect password, JWT generation | | `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint | | `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:** -> "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]`." +> "Using the CLAUDE.md context, generate unit tests for all backend service classes using MockK, +> integration tests for controllers using `@SpringBootTest` with H2, and frontend component and +> API layer tests using Vitest + React Testing Library. Follow naming convention +> `should_[expectedBehavior]_when_[condition]` for backend tests." **Done when:** -- [ ] `./gradlew test` passes all tests green. -- [ ] Service class coverage ≥ 80%. +- [ ] `./gradlew test` passes all backend tests green. +- [ ] 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/ -├── Dockerfile # Multi-stage build -├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP/IMAP) -└── docker-compose.prod.yml # App + PostgreSQL only +├── backend/Dockerfile # Multi-stage: Gradle build → slim JRE runtime +├── frontend/Dockerfile # Multi-stage: Node build → Nginx static file server +├── nginx/nginx.conf # Serve SPA + proxy /api to backend +├── docker-compose.yml # Dev: Nginx + Backend + PostgreSQL + Mailhog +└── docker-compose.prod.yml # Prod: Nginx + Backend + PostgreSQL (no Mailhog) ``` **Notes:** -- Use [Mailhog](https://github.com/mailhog/MailHog) in dev 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. +- Use [Mailhog](https://github.com/mailhog/MailHog) in dev (SMTP port 1025, web UI port 8025). +- The `nginx` service serves the built React SPA and proxies `/api/**` to `backend:8080`. +- Backend and Postgres communicate over an internal Docker network. +- Env vars come from `.env` at the repo root (copied from `.env.example`). **Prompt to use with AI:** -> "Using the CLAUDE.md context, create 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)." +> "Using the CLAUDE.md context, create multi-stage Dockerfiles for the backend and frontend, +> an `nginx/nginx.conf` that serves the React SPA and proxies `/api` to the backend, a +> `docker-compose.yml` for dev (includes Mailhog), and a `docker-compose.prod.yml` for +> production. Use `.env` at the repo root for all env vars." **Done when:** -- [ ] `docker-compose up` starts the full stack. -- [ ] App connects to PostgreSQL container. +- [ ] `docker compose up --build` starts all services without errors. +- [ ] `http://localhost` serves the React SPA. +- [ ] `http://localhost/api/v1/virtual-entities` is proxied to the backend. - [ ] Outgoing emails appear in Mailhog at `http://localhost:8025`. -- [ ] `docker 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 \ + /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 `` 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 | 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 | JWT auth (single admin, password via env var) | No user table needed; simple and secure for a private tool | | 2026-03-26 | Use Spring `RestClient` for OpenAI (not WebClient) | Spring Boot 3.2+ preferred HTTP client | | 2026-03-26 | Recipients stored in `app.recipients` config | Simple starting point, can be made dynamic later | | 2026-03-26 | `PromptBuilderService` is the only prompt builder | Keeps prompt logic centralized and testable | | 2026-03-26 | AI must format response as `SUBJECT: ...\nBODY:\n...` | Allows reliable parsing of subject vs body | - - ---- - -## 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` — 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` - -### `CampaignService` -- `scheduleCampaign(dto: CampaignCreateDto): Campaign` -- `cancelCampaign(id: UUID)` -- `listCampaigns(status: CampaignStatus?): List` -- `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`. 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: ` 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 | +| 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 |