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