# Build Instructions — Condado Abaixo da Média SA Email Bot This file documents every step to build the project from scratch using AI. After each session, both this file and `CLAUDE.md` will be updated to reflect progress. --- ## What Are We Building? A group of friends created a fictional company called **"Condado Abaixo da Média SA"**. Their dynamic works entirely over email — they write to each other in an **extremely formal, corporate tone**, but the **content is completely casual and nonsensical** (inside jokes, mundane topics, etc.). The contrast is the joke. This service allows registering **virtual employees** of that fictional company. Each virtual employee is an AI-powered entity that: 1. Has a name, email address, job title, and a personality description. 2. Has a **scheduled time** to send emails (e.g., every Monday at 9am). 3. Has a configurable **context window** (e.g., "read emails from the last 3 days") so it can react to what was already said in the thread. 4. At the scheduled time: reads recent emails from the shared inbox via IMAP → builds a prompt → sends the prompt to the OpenAI API → sends the generated email via SMTP. --- ## How to Use This File - Follow steps **in order** — each step builds on the previous one. - At the start of each AI session, share the contents of `CLAUDE.md` for context. - After completing each step, mark it ✅ **Done** and note any decisions made. - If anything changes (new library, schema change, etc.), update `CLAUDE.md` too. --- ## Progress Tracker | Step | Description | Status | |------|-----------------------------------------|-------------| | 0 | Define project & write CLAUDE.md | ✅ Done | | 1 | Scaffold monorepo structure | ⬜ Pending | | 2 | Domain model (JPA entities) | ⬜ Pending | | 3 | Repositories | ⬜ Pending | | 4 | Email Reader Service (IMAP) | ⬜ Pending | | 5 | Prompt Builder Service | ⬜ Pending | | 6 | AI Service (OpenAI integration) | ⬜ Pending | | 7 | Email Sender Service (SMTP) | ⬜ Pending | | 8 | Scheduler (trigger per entity) | ⬜ Pending | | 9 | REST Controllers & DTOs | ⬜ Pending | | 10 | 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 | --- ## Step 0 — Define the Project & Write CLAUDE.md **Goal:** Establish the project scope and create the persistent AI instructions file. **What was done:** - Understood the project concept: AI-driven fictional company employees that send formal-toned but casually-contented emails, reacting to each other via IMAP context reading. - Decided on Kotlin + Spring Boot 3.x as the core stack. - Chose PostgreSQL for persistence, Jakarta Mail for IMAP, Spring Mail for SMTP, and Gradle (Kotlin DSL) as the build tool. - Defined the core domain concepts: `VirtualEntity`, `EmailContext`, `Prompt`, `DispatchLog`. - Created `CLAUDE.md` with the prompt template, project structure, coding standards, and env vars. **Key decisions:** - Use Gradle Kotlin DSL (`build.gradle.kts`) instead of Groovy DSL. - Use MockK for tests, not Mockito (more idiomatic for Kotlin). - Use OpenAI `gpt-4o` as the AI model (configurable via env var). - No Thymeleaf — emails are plain text or simple HTML generated entirely by the AI. - The prompt template is defined in `CLAUDE.md` and must be respected exactly. **Output files:** - `CLAUDE.md` ✅ --- ## Step 1 — Scaffold the Monorepo Structure **Goal:** Create the full project skeleton for both backend and frontend, with all dependencies configured and the root-level Docker/CI files in place. **What the AI should create:** ``` condado-news-letter/ ├── .env.example ├── .gitignore ├── docker-compose.yml ├── docker-compose.prod.yml ├── Dockerfile.allinone ├── nginx/ │ └── nginx.conf ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── publish.yml ├── backend/ │ ├── Dockerfile │ ├── build.gradle.kts │ ├── settings.gradle.kts │ ├── gradlew / gradlew.bat │ ├── gradle/wrapper/ │ └── src/main/kotlin/com/condado/newsletter/ │ └── CondadoApplication.kt │ └── src/main/resources/ │ ├── application.yml │ └── application-dev.yml │ └── src/test/kotlin/com/condado/newsletter/ │ └── CondadoApplicationTests.kt └── frontend/ ├── Dockerfile ├── package.json ├── vite.config.ts ├── tsconfig.json ├── index.html └── src/ ├── main.tsx └── App.tsx ``` **Backend dependencies (`backend/build.gradle.kts`):** | Dependency | Purpose | |-----------------------------------------|----------------------------------------------| | `spring-boot-starter-web` | REST API | | `spring-boot-starter-data-jpa` | Database access (JPA/Hibernate) | | `spring-boot-starter-mail` | Email sending via SMTP | | `spring-boot-starter-validation` | DTO validation | | `spring-boot-starter-security` | JWT authentication | | `jjwt-api`, `jjwt-impl`, `jjwt-jackson` | JWT creation and validation (JJWT library) | | `postgresql` | PostgreSQL JDBC driver | | `angus-mail` | IMAP email reading (Jakarta Mail impl) | | `springdoc-openapi-starter-webmvc-ui` | Swagger UI / OpenAPI docs | | `kotlin-reflect` | Required by Spring for Kotlin | | `jackson-module-kotlin` | JSON serialization for Kotlin | | `h2` (testRuntimeOnly) | In-memory DB for tests | | `spring-boot-starter-test` | JUnit 5 test support | | `mockk` | Kotlin mocking library | | `springmockk` | MockK integration for Spring | **Frontend dependencies (`frontend/package.json`):** | Package | Purpose | |-------------------------------|------------------------------------------| | `react`, `react-dom` | Core React | | `typescript` | TypeScript | | `vite` | Build tool and dev server | | `@vitejs/plugin-react` | Vite React plugin | | `react-router-dom` | Client-side routing | | `@tanstack/react-query` | Server state management | | `axios` | HTTP client | | `tailwindcss`, `postcss`, `autoprefixer` | Styling | | `@radix-ui/*`, `shadcn/ui` | UI component library | | `lucide-react` | Icon library (used by shadcn) | | `vitest` | Test runner | | `@testing-library/react` | Component testing | | `@testing-library/jest-dom` | DOM matchers | | `jsdom` | Browser environment for tests | **`.env.example` must contain all variables from the Environment Variables table in `CLAUDE.md`.** **Prompt to use with AI:** > "Using the CLAUDE.md context, scaffold the full monorepo. Create the backend Gradle project > with all dependencies, the frontend Vite+React project with all packages, the root `.env.example`, > `.gitignore`, placeholder `docker-compose.yml`, `docker-compose.prod.yml`, `Dockerfile.allinone`, > `nginx/nginx.conf`, and GitHub Actions workflow stubs at `.github/workflows/ci.yml` and > `.github/workflows/publish.yml`. Do not implement business logic yet — just the skeleton." **Done when:** - [ ] `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. --- --- ## Step 2 — Domain Model (JPA Entities) **Goal:** Create all database entities and their relationships. **Entities to create:** ### `VirtualEntity` Represents a fictional employee of "Condado Abaixo da Média SA". | Column | Type | Notes | |-------------------------|---------------|----------------------------------------------------| | `id` | UUID | Primary key, auto-generated | | `name` | String | The character's full name. Not null. | | `email` | String | Sender email address. Unique, not null. | | `job_title` | String | Job title in the fictional company. Not null. | | `personality` | Text | Free-text personality description for the prompt. | | `schedule_cron` | String | Cron expression for when to send emails. | | `context_window_days` | Int | How many days back to read emails for context. | | `active` | Boolean | Whether this entity is active. Default true. | | `created_at` | LocalDateTime | Auto-set on creation. | ### `DispatchLog` A record of every AI generation + email send attempt. | Column | Type | Notes | |-------------------|---------------|----------------------------------------------------| | `id` | UUID | Primary key, auto-generated | | `entity_id` | UUID (FK) | References `VirtualEntity` | | `prompt_sent` | Text | The full prompt that was sent to the AI | | `ai_response` | Text | The raw text returned by the AI | | `email_subject` | String | The subject line parsed from the AI response | | `email_body` | Text | The email body parsed from the AI response | | `status` | Enum | PENDING / SENT / FAILED | | `error_message` | String | Nullable — stores failure reason if FAILED | | `dispatched_at` | LocalDateTime | When the dispatch was triggered | **Prompt to use with AI:** > "Using the CLAUDE.md context, create the two JPA entities: `VirtualEntity` and `DispatchLog`. > Place them in the `model/` package. Use UUIDs as primary keys and proper JPA annotations. > `DispatchLog` has a `@ManyToOne` relationship to `VirtualEntity`." **Done when:** - [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`. - [ ] `./gradlew build` compiles cleanly. - [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev). --- ## Step 3 — Repositories **Goal:** Create Spring Data JPA repositories for each entity. | Repository | Entity | Custom queries needed | |--------------------------|-----------------|----------------------------------------------------------| | `VirtualEntityRepository`| `VirtualEntity` | `findAllByActiveTrue()`, `findByEmail()` | | `DispatchLogRepository` | `DispatchLog` | `findAllByEntityId()`, `findTopByEntityIdOrderByDispatchedAtDesc()` | **Prompt to use with AI:** > "Using the CLAUDE.md context, create the two JPA repositories in the `repository/` package. > Each must extend `JpaRepository` and include the custom query methods listed in the instructions." **Done when:** - [ ] Both repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`. - [ ] `./gradlew build` compiles cleanly. --- ## Step 4 — Email Reader Service (IMAP) **Goal:** Read recent emails from the shared company inbox via IMAP to use as AI context. **Class:** `EmailReaderService` in `service/` package. **Responsibilities:** - Connect to the IMAP server using credentials from environment variables. - Fetch all emails from the configured folder received within the last `N` days. - Return a list of `EmailContext` data objects, each containing: - `from: String` — sender name/address - `subject: String` — email subject - `body: String` — plain text body (strip HTML if needed) - `receivedAt: LocalDateTime` - Sort results from oldest to newest (chronological order, for natural prompt reading). - Handle IMAP errors gracefully — log the error and return an empty list rather than crashing. **Data class to create (in `dto/` or `model/`):** ```kotlin data class EmailContext( val from: String, val subject: String, val body: String, val receivedAt: LocalDateTime ) ``` **Prompt to use with AI:** > "Using the CLAUDE.md context, create the `EmailReaderService` class in `service/`. It must > connect to an IMAP server using env vars (`IMAP_HOST`, `IMAP_PORT`, `MAIL_USERNAME`, > `MAIL_PASSWORD`, `IMAP_INBOX_FOLDER`), fetch emails from the last N days, and return them > as a list of `EmailContext` data objects sorted chronologically. Use Jakarta Mail." **Done when:** - [ ] `EmailReaderService.kt` exists in `service/`. - [ ] `EmailContext.kt` data class exists. - [ ] Service reads real emails when tested against a live IMAP account. - [ ] Returns empty list (not exception) on connection failure. --- ## Step 5 — Prompt Builder Service **Goal:** Transform a `VirtualEntity` + a list of `EmailContext` into a final AI prompt string. **Class:** `PromptBuilderService` in `service/` package. **Rule:** This is the ONLY place in the codebase where prompt strings are built. No other class may construct or modify prompts. **The prompt template is defined in `CLAUDE.md` under "The Prompt Template (Core Logic)".** It must be followed exactly, with the entity fields and email context filled in dynamically. **Method signature:** ```kotlin fun buildPrompt(entity: VirtualEntity, emailContext: List): String ``` **Prompt to use with AI:** > "Using the CLAUDE.md context, create `PromptBuilderService` in `service/`. It must implement > `buildPrompt(entity, emailContext)` following the prompt template defined in CLAUDE.md exactly. > This is the only class allowed to build prompt strings." **Done when:** - [ ] `PromptBuilderService.kt` exists in `service/`. - [ ] Output prompt matches the template from `CLAUDE.md` with fields correctly substituted. - [ ] Unit test verifies the prompt structure. --- ## Step 6 — AI Service (OpenAI Integration) **Goal:** Send the prompt to OpenAI and get back the generated email text. **Class:** `AiService` in `service/` package. **Responsibilities:** - Call the OpenAI Chat Completions API (`POST https://api.openai.com/v1/chat/completions`). - Use the model configured in `OPENAI_MODEL` env var (default: `gpt-4o`). - Send the prompt as a `user` message. - Return the AI's response as a plain `String`. - Parse the response to extract `subject` and `body` — the AI should be instructed to return them in a structured format: ``` SUBJECT: BODY: ``` - Handle API errors gracefully — throw a descriptive exception that `DispatchLog` can record. **HTTP Client:** Use Spring's `RestClient` (Spring Boot 3.2+) — do NOT use WebClient or Feign. **Prompt to use with AI:** > "Using the CLAUDE.md context, create `AiService` in `service/`. It must call the OpenAI > Chat Completions API using Spring's `RestClient`, using the `OPENAI_API_KEY` and `OPENAI_MODEL` > env vars. Return the AI's text response. Also implement a `parseResponse(raw: String)` method > that extracts the subject and body from a response formatted as `SUBJECT: ...\nBODY:\n...`." **Done when:** - [ ] `AiService.kt` exists in `service/`. - [ ] Calling the service with a real API key returns a valid AI-generated email. - [ ] `parseResponse()` correctly extracts subject and body. --- ## Step 7 — Email Sender Service (SMTP) **Goal:** Send the AI-generated email via SMTP using Spring Mail. **Class:** `EmailSenderService` in `service/` package. **Method signature:** ```kotlin fun send(from: String, to: List, subject: String, body: String) ``` - `from` is the `VirtualEntity.email` (the fictional employee's address). - `to` is the list of all real participants' emails (loaded from config or DB — TBD in Step 9). - `body` may be plain text or simple HTML — send as both `text/plain` and `text/html` (multipart). - Log every send attempt. **Note on recipients:** For now, the list of recipients (the real friends' emails) will be stored as a comma-separated string in `application.yml` under `app.recipients`. This can be made dynamic later. **Prompt to use with AI:** > "Using the CLAUDE.md context, create `EmailSenderService` in `service/`. It must use > `JavaMailSender` to send emails. The `from` address comes from the entity, the `to` list > comes from `app.recipients` in config, and the body is sent as both plain text and HTML." **Done when:** - [ ] `EmailSenderService.kt` exists in `service/`. - [ ] Email is received in a real inbox when tested with valid SMTP credentials. --- ## Step 8 — Scheduler (Trigger Per Entity) **Goal:** Automatically trigger each active `VirtualEntity` at its configured schedule. **Class:** `EntityScheduler` in `scheduler/` package. **Approach:** - Spring's `@Scheduled` with a fixed-rate tick (every 60 seconds) checks which entities are due. - Use a `ScheduledTaskRegistrar` (or dynamic scheduling) to register a task per entity using its `schedule_cron` expression. - When triggered, orchestrate the full pipeline: 1. Read emails via `EmailReaderService` (using `entity.contextWindowDays`). 2. Build prompt via `PromptBuilderService`. 3. Call AI via `AiService`. 4. Parse AI response (subject + body). 5. Send email via `EmailSenderService`. 6. Save a `DispatchLog` record with status SENT or FAILED. - If the pipeline fails at any step, save a `DispatchLog` with status FAILED and the error message. **Prompt to use with AI:** > "Using the CLAUDE.md context, create `EntityScheduler` in `scheduler/`. It must dynamically > schedule a cron job per active `VirtualEntity` using `SchedulingConfigurer`. When triggered, > it must run the full pipeline: read emails → build prompt → call AI → send email → save > `DispatchLog`. Handle failures gracefully and always write a `DispatchLog`." **Done when:** - [ ] `EntityScheduler.kt` exists in `scheduler/`. - [ ] `@EnableScheduling` is present on the main app or a config class. - [ ] An active entity triggers at its scheduled time and a `DispatchLog` record is created. - [ ] End-to-end test: entity fires → email arrives in inbox. --- ## Step 9 — REST Controllers & DTOs **Goal:** Expose CRUD operations for `VirtualEntity` and read-only access to `DispatchLog`. **Controllers:** ### `VirtualEntityController` — `/api/v1/virtual-entities` | Method | Path | Description | |--------|---------------|--------------------------------------| | POST | `/` | Create a new virtual entity | | GET | `/` | List all virtual entities | | GET | `/{id}` | Get one entity by ID | | PUT | `/{id}` | Update an entity | | DELETE | `/{id}` | Deactivate an entity (soft delete) | | POST | `/{id}/trigger` | Manually trigger the entity pipeline | ### `DispatchLogController` — `/api/v1/dispatch-logs` | Method | Path | Description | |--------|-------------------|------------------------------------| | GET | `/` | List all dispatch logs | | GET | `/entity/{id}` | List logs for a specific entity | **DTOs to create (in `dto/` package):** - `VirtualEntityCreateDto` — name, email, jobTitle, personality, scheduleCron, contextWindowDays - `VirtualEntityUpdateDto` — same fields, all optional - `VirtualEntityResponseDto` — full entity fields + id + createdAt - `DispatchLogResponseDto` — all DispatchLog fields **Prompt to use with AI:** > "Using the CLAUDE.md context, create `VirtualEntityController` and `DispatchLogController` in > `controller/`, and all DTOs in `dto/`. Controllers return `ResponseEntity`. The `/trigger` > endpoint manually runs the entity pipeline. DTOs use validation annotations." **Done when:** - [ ] All controller and DTO files exist. - [ ] Swagger UI shows all endpoints. - [ ] CRUD via Swagger UI or Postman works end-to-end. --- ## Step 10 — Authentication (JWT Login) **Goal:** Implement the single-admin JWT login that protects all API endpoints. **Approach:** - `POST /api/auth/login` accepts `{ "password": "..." }`, validates against `APP_PASSWORD` env var. - On success, generates a JWT (signed with `JWT_SECRET`, 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, 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:** - [ ] `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 — 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 | | `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 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 backend tests green. - [ ] Backend service class coverage ≥ 80%. - [ ] `npm run test` passes all frontend tests green. --- ## Step 13 — Docker Compose (Dev + Prod) **Goal:** Containerize both services and wire them together for local dev and production. **Files to create / update:** ``` condado-news-letter/ ├── backend/Dockerfile # Multi-stage: Gradle build → slim JRE runtime ├── frontend/Dockerfile # Multi-stage: Node build → Nginx static file server ├── nginx/nginx.conf # Serve SPA + proxy /api to backend ├── docker-compose.yml # Dev: Nginx + Backend + PostgreSQL + Mailhog └── docker-compose.prod.yml # Prod: Nginx + Backend + PostgreSQL (no Mailhog) ``` **Notes:** - Use [Mailhog](https://github.com/mailhog/MailHog) in dev (SMTP port 1025, web UI port 8025). - The `nginx` service serves the built React SPA and proxies `/api/**` to `backend:8080`. - Backend and Postgres communicate over an internal Docker network. - Env vars come from `.env` at the repo root (copied from `.env.example`). **Prompt to use with AI:** > "Using the CLAUDE.md context, create multi-stage Dockerfiles for the backend and frontend, > an `nginx/nginx.conf` that serves the React SPA and proxies `/api` to the backend, a > `docker-compose.yml` for dev (includes Mailhog), and a `docker-compose.prod.yml` for > production. Use `.env` at the repo root for all env vars." **Done when:** - [ ] `docker compose up --build` starts all services without errors. - [ ] `http://localhost` serves the React SPA. - [ ] `http://localhost/api/v1/virtual-entities` is proxied to the backend. - [ ] Outgoing emails appear in Mailhog at `http://localhost:8025`. - [ ] `docker compose -f docker-compose.prod.yml up --build` works (no Mailhog). --- ## Step 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). --- ## Notes & Decisions Log | Date | Decision | Reason | |------------|--------------------------------------------------------|-----------------------------------------------------| | 2026-03-26 | Chose Kotlin + Spring Boot 3.x | Modern, type-safe, great Spring support | | 2026-03-26 | MockK over Mockito | More idiomatic for Kotlin | | 2026-03-26 | UUID as primary keys | Better for distributed systems | | 2026-03-26 | No Thymeleaf — AI generates email content directly | Email body is AI-produced, no template needed | | 2026-03-26 | JWT auth (single admin, password via env var) | No user table needed; simple and secure for a private tool | | 2026-03-26 | Use Spring `RestClient` for OpenAI (not WebClient) | Spring Boot 3.2+ preferred HTTP client | | 2026-03-26 | Recipients stored in `app.recipients` config | Simple starting point, can be made dynamic later | | 2026-03-26 | `PromptBuilderService` is the only prompt builder | Keeps prompt logic centralized and testable | | 2026-03-26 | AI must format response as `SUBJECT: ...\nBODY:\n...` | Allows reliable parsing of subject vs body | | 2026-03-26 | React + Vite + shadcn/ui for frontend | Modern, fast DX; Tailwind + Radix keeps UI consistent | | 2026-03-26 | All-in-one Docker image (Supervisor + Nginx + PG + JVM)| Simplest possible single-command deployment for friends | | 2026-03-26 | GitHub Actions CI/CD → Docker Hub publish on `main` | Automated image publishing; pinnable via git SHA tags |