Files
condado-newsletter/INSTRUCTIONS.md

45 KiB
Raw Blame History

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 211 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:

  • 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

TDD — Write tests first

Test file: src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt

Tests to write (all should fail before implementation):

// 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:

  • EntityMappingTest.kt exists with all 5 tests.
  • Both entity files exist in src/main/kotlin/com/condado/newsletter/model/.
  • ./gradlew test is green.
  • 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):

// 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/):

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):

// 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:

fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String

TDD — Write tests first

Test file: src/test/kotlin/com/condado/newsletter/service/PromptBuilderServiceTest.kt

Tests to write (all should fail before implementation):

// 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<EmailContext> 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: <generated subject>
    BODY:
    <generated email body>
    
  • Handle API errors gracefully — throw a descriptive exception that DispatchLog can record.

HTTP Client: Use Spring's RestClient (Spring Boot 3.2+) — do NOT use WebClient or Feign.

TDD — Write tests first

Test file: src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt

Tests to write (all should fail before implementation):

// 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:

fun send(from: String, to: List<String>, subject: String, body: String)
  • from is the VirtualEntity.email (the fictional employee's address).
  • to is the list of all real participants' emails (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):

// 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):

// 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):

// 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:

// 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<T>. 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:

  • AuthControllerPOST /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):

// should_returnJwtToken_when_correctPasswordProvided
// should_throwUnauthorizedException_when_wrongPasswordProvided
// should_returnValidClaims_when_jwtTokenParsed
// should_returnFalse_when_expiredTokenValidated

Tests to write — AuthControllerTest:

// 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):

// 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):

// 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 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-alpinenpm 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):

[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):

docker run -d \
  -p 80:80 \
  -e APP_PASSWORD=yourpassword \
  -e JWT_SECRET=yoursecret \
  -e OPENAI_API_KEY=sk-... \
  -e MAIL_HOST=smtp.example.com \
  -e MAIL_PORT=587 \
  -e MAIL_USERNAME=company@example.com \
  -e MAIL_PASSWORD=secret \
  -e IMAP_HOST=imap.example.com \
  -e IMAP_PORT=993 \
  -e APP_RECIPIENTS=friend1@example.com,friend2@example.com \
  -v condado-data:/var/lib/postgresql/data \
  <dockerhub-user>/condado-newsletter:latest

Prompt to use with AI:

"Using the CLAUDE.md context, create Dockerfile.allinone at the repo root. It must be a multi-stage build: stage 1 builds the frontend, stage 2 builds the backend, stage 3 assembles everything into a single Ubuntu/Debian image with Nginx, PostgreSQL, Spring Boot, and Supervisor. Include an entrypoint.sh that initialises the PostgreSQL data dir on first run."

Done when:

  • docker build -f Dockerfile.allinone -t condado-newsletter . succeeds.
  • docker run -p 80:80 -e APP_PASSWORD=test -e JWT_SECRET=testsecret ... condado-newsletter serves the app at http://localhost.
  • Data persists across container restarts when a volume is mounted.
  • All three processes (nginx, java, postgres) are visible in docker exec ... supervisorctl status.

Step 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 <git-sha> 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