1015 lines
44 KiB
Markdown
1015 lines
44 KiB
Markdown
# 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) | ⬜ 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 | 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:**
|
||
- [ ] `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).
|
||
|
||
---
|
||
|
||
## 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<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):
|
||
```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<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):
|
||
```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<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):
|
||
```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<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:**
|
||
- `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 \
|
||
<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 |
|