Refactor project instructions and structure for monorepo setup
- Updated Step 1 to scaffold a monorepo structure for both backend and frontend. - Renamed dependencies and adjusted project structure in INSTRUCTIONS.md. - Added frontend dependencies and outlined the React application structure. - Revised authentication method from API key to JWT for enhanced security. - Created detailed instructions for frontend development, including page structure and API integration. - Added steps for Docker configuration, including an all-in-one Docker image for deployment. - Implemented CI/CD workflows for automated testing and Docker Hub publishing.
This commit is contained in:
397
CLAUDE.md
397
CLAUDE.md
@@ -1,29 +1,31 @@
|
|||||||
# Condado Abaixo da Média SA — Email Bot
|
# Condado Abaixo da Média SA — Email Bot
|
||||||
|
|
||||||
A backend service built with **Kotlin** and **Spring Boot**. This file gives the AI persistent
|
A **monorepo** containing a Kotlin/Spring Boot backend and a React frontend. This file gives
|
||||||
instructions and context about the project so every session starts with the right knowledge.
|
the AI persistent instructions and context about the project so every session starts with the
|
||||||
|
right knowledge.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
- **Language:** Kotlin (JVM)
|
- **Type:** Monorepo (backend + frontend in the same repository)
|
||||||
- **Framework:** Spring Boot 3.x
|
|
||||||
- **Purpose:** Simulate virtual employees of the fictional company "Condado Abaixo da Média SA".
|
- **Purpose:** Simulate virtual employees of the fictional company "Condado Abaixo da Média SA".
|
||||||
Each entity is a virtual employee with a name, email address, job title, personality, and an
|
Each entity is an AI-powered fictional employee with a name, email address, job title,
|
||||||
email schedule. At the scheduled time, the system reads recent emails from the company mailbox
|
personality, and an email schedule. At the scheduled time, the system reads recent emails
|
||||||
(filtered by a configurable time window), builds a prompt from the entity's profile + the email
|
from the company mailbox (IMAP), builds a prompt, calls the OpenAI API, and dispatches the
|
||||||
history, sends that prompt to an AI (OpenAI API), and dispatches the AI-generated email via SMTP.
|
AI-generated email via SMTP.
|
||||||
- **Tone rule (critical):** Every generated email must be written in an **extremely formal,
|
- **Tone rule (critical):** Every generated email must be written in an **extremely formal,
|
||||||
corporate tone** — but the **content is completely casual and nonsensical**, like internal
|
corporate tone** — but the **content is completely casual and nonsensical**, like internal
|
||||||
jokes between friends. This contrast is the core joke of the project and must be preserved
|
jokes between friends. This contrast is the core joke of the project and must be preserved
|
||||||
in every generated email.
|
in every generated email.
|
||||||
- **Architecture:** REST API backend + scheduled AI-driven email dispatch
|
- **Architecture:** React SPA → Spring Boot REST API → PostgreSQL + IMAP + SMTP + OpenAI
|
||||||
|
- **Deployment:** Fully containerized with Docker and Docker Compose
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|----------------|---------------------------------------------------|
|
|----------------|---------------------------------------------------|
|
||||||
| Language | Kotlin (JVM) |
|
| Language | Kotlin (JVM) |
|
||||||
@@ -32,64 +34,270 @@ instructions and context about the project so every session starts with the righ
|
|||||||
| Database | PostgreSQL (via Spring Data JPA) |
|
| Database | PostgreSQL (via Spring Data JPA) |
|
||||||
| Email Reading | Jakarta Mail (IMAP) — read inbox for context |
|
| Email Reading | Jakarta Mail (IMAP) — read inbox for context |
|
||||||
| Email Sending | Spring Mail (SMTP / JavaMailSender) |
|
| Email Sending | Spring Mail (SMTP / JavaMailSender) |
|
||||||
| AI Integration | OpenAI API (`gpt-4o`) via HTTP client |
|
| AI Integration | OpenAI API (`gpt-4o`) via Spring `RestClient` |
|
||||||
| Scheduler | Spring `@Scheduled` tasks |
|
| Scheduler | Spring `@Scheduled` + `SchedulingConfigurer` |
|
||||||
|
| Auth | JWT — issued by backend on login |
|
||||||
| Testing | JUnit 5 + MockK |
|
| Testing | JUnit 5 + MockK |
|
||||||
| Docs | Springdoc OpenAPI (Swagger UI) |
|
| Docs | Springdoc OpenAPI (Swagger UI) |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
| Layer | Technology |
|
||||||
|
|----------------|---------------------------------------------------|
|
||||||
|
| Language | TypeScript |
|
||||||
|
| Framework | React 18 + Vite |
|
||||||
|
| UI Library | shadcn/ui (Radix UI + Tailwind CSS) |
|
||||||
|
| State | React Query (TanStack Query v5) |
|
||||||
|
| Routing | React Router v6 |
|
||||||
|
| HTTP Client | Axios |
|
||||||
|
| Auth | JWT stored in `httpOnly` cookie (set by backend) |
|
||||||
|
| Testing | Vitest + React Testing Library |
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------------------|--------------------------------------------------------------|
|
||||||
|
| Containers | Docker |
|
||||||
|
| Orchestration | Docker Compose — three flavours (see below) |
|
||||||
|
| Reverse Proxy | Nginx (serves frontend + proxies `/api` to backend) |
|
||||||
|
| Dev Mail | Mailhog (SMTP trap + web UI) |
|
||||||
|
| All-in-one image | Single Docker image: Nginx + Spring Boot + PostgreSQL + Supervisor |
|
||||||
|
| Image registry | Docker Hub (`<dockerhub-user>/condado-newsletter`) |
|
||||||
|
| CI/CD | GitHub Actions — build, test, push to Docker Hub on merge to `main` |
|
||||||
|
|
||||||
|
## Deployment Flavours
|
||||||
|
|
||||||
|
There are **three ways to run the project**:
|
||||||
|
|
||||||
|
| Flavour | Command | When to use |
|
||||||
|
|---------------------|---------------------------------|------------------------------------------------|
|
||||||
|
| **Dev** | `docker compose up` | Local development — includes Mailhog |
|
||||||
|
| **Prod (compose)** | `docker compose -f docker-compose.prod.yml up` | Production with external DB/SMTP |
|
||||||
|
| **All-in-one** | `docker run ...` | Simplest deploy — everything in one container |
|
||||||
|
|
||||||
|
### All-in-one Image
|
||||||
|
|
||||||
|
The all-in-one image (`Dockerfile.allinone`) bundles **everything** into a single container:
|
||||||
|
- **Nginx** — serves the React SPA and proxies `/api` to Spring Boot
|
||||||
|
- **Spring Boot** — the backend API + scheduler
|
||||||
|
- **PostgreSQL** — embedded database
|
||||||
|
- **Supervisor** — process manager that starts and supervises all three processes
|
||||||
|
|
||||||
|
This image is published to Docker Hub at `<dockerhub-user>/condado-newsletter:latest`.
|
||||||
|
|
||||||
|
**Minimal `docker run` command:**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 80:80 \
|
||||||
|
-e APP_PASSWORD=yourpassword \
|
||||||
|
-e JWT_SECRET=yoursecret \
|
||||||
|
-e OPENAI_API_KEY=sk-... \
|
||||||
|
-e MAIL_HOST=smtp.example.com \
|
||||||
|
-e MAIL_PORT=587 \
|
||||||
|
-e MAIL_USERNAME=company@example.com \
|
||||||
|
-e MAIL_PASSWORD=secret \
|
||||||
|
-e IMAP_HOST=imap.example.com \
|
||||||
|
-e IMAP_PORT=993 \
|
||||||
|
-e APP_RECIPIENTS=friend1@example.com,friend2@example.com \
|
||||||
|
-v condado-data:/var/lib/postgresql/data \
|
||||||
|
<dockerhub-user>/condado-newsletter:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is then available at `http://localhost`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## System Topology
|
||||||
|
|
||||||
|
### Multi-container (Docker Compose)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Docker Compose │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ nginx :80 / :443 │ │
|
||||||
|
│ │ • Serves React SPA (static files) │ │
|
||||||
|
│ │ • Proxies /api/** ──────────────────────────────────► │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ backend :8080 │ │
|
||||||
|
│ │ Spring Boot (Kotlin) │ │
|
||||||
|
│ │ • REST API /api/v1/** │ │
|
||||||
|
│ │ • JWT auth /api/auth/login │ │
|
||||||
|
│ │ • Swagger /swagger-ui.html │ │
|
||||||
|
│ │ • Scheduler (cron per VirtualEntity) │ │
|
||||||
|
│ └──────────┬───────────────┬──────────────────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────────┐ ┌────────────────────────────────────────┐ │
|
||||||
|
│ │ postgres :5432│ │ External services (outside Docker) │ │
|
||||||
|
│ │ PostgreSQL │ │ • OpenAI API (HTTPS) │ │
|
||||||
|
│ │ DB: condado │ │ • SMTP server (send emails) │ │
|
||||||
|
│ └──────────────┘ │ • IMAP server (read inbox) │ │
|
||||||
|
│ └────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ mailhog :1025 (SMTP) / :8025 (UI) │ │
|
||||||
|
│ │ DEV ONLY — catches outgoing emails │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Browser ──────► nginx :80
|
||||||
|
├── / → React SPA (index.html)
|
||||||
|
├── /assets → Static JS/CSS
|
||||||
|
└── /api/** → backend :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### All-in-one Image (single `docker run`)
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ condado-newsletter :80 (single container) │
|
||||||
|
│ │
|
||||||
|
│ supervisord │
|
||||||
|
│ ├── nginx (port 80 — SPA + /api proxy) │
|
||||||
|
│ ├── java -jar app (Spring Boot :8080 — internal only) │
|
||||||
|
│ └── postgres (PostgreSQL :5432 — internal only) │
|
||||||
|
│ │
|
||||||
|
│ Persistent data → Docker volume mounted at │
|
||||||
|
│ /var/lib/postgresql/data │
|
||||||
|
│ │
|
||||||
|
│ External (via env vars): │
|
||||||
|
│ • OpenAI API (HTTPS) │
|
||||||
|
│ • SMTP server (send emails) │
|
||||||
|
│ • IMAP server (read inbox) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data flows:**
|
||||||
|
1. User opens browser → Nginx serves the React app.
|
||||||
|
2. React calls `POST /api/auth/login` with password → backend validates against `APP_PASSWORD`
|
||||||
|
env var → returns JWT in `httpOnly` cookie.
|
||||||
|
3. React calls `/api/v1/virtual-entities` (JWT cookie sent automatically) → backend responds.
|
||||||
|
4. Scheduler ticks → for each due `VirtualEntity`:
|
||||||
|
- Backend reads IMAP inbox (external IMAP server).
|
||||||
|
- Builds prompt → calls OpenAI API.
|
||||||
|
- Sends generated email via SMTP (Mailhog in dev, real SMTP in prod).
|
||||||
|
- Saves `DispatchLog` to PostgreSQL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monorepo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
condado-news-letter/ ← repo root
|
||||||
├── main/
|
├── CLAUDE.md
|
||||||
│ ├── kotlin/com/condado/newsletter/
|
├── INSTRUCTIONS.md
|
||||||
│ │ ├── CondadoApplication.kt # App entry point
|
├── .env.example ← template for all env vars
|
||||||
│ │ ├── config/ # Spring configuration classes
|
├── .gitignore
|
||||||
│ │ ├── controller/ # REST controllers
|
├── docker-compose.yml ← dev stack (Nginx + Backend + PostgreSQL + Mailhog)
|
||||||
│ │ ├── service/ # Business logic
|
├── docker-compose.prod.yml ← prod stack (Nginx + Backend + PostgreSQL)
|
||||||
│ │ │ ├── EntityService.kt # CRUD for virtual entities
|
├── Dockerfile.allinone ← all-in-one image (Nginx + Backend + PostgreSQL + Supervisor)
|
||||||
│ │ │ ├── EmailReaderService.kt # Reads emails via IMAP
|
│
|
||||||
│ │ │ ├── PromptBuilderService.kt # Builds AI prompt from entity + emails
|
├── .github/
|
||||||
│ │ │ ├── AiService.kt # Calls OpenAI API
|
│ └── workflows/
|
||||||
│ │ │ └── EmailSenderService.kt # Sends email via SMTP
|
│ ├── ci.yml ← run tests on every PR
|
||||||
│ │ ├── repository/ # Spring Data JPA repositories
|
│ └── publish.yml ← build & push all-in-one image to Docker Hub on main merge
|
||||||
│ │ ├── model/ # JPA entities
|
│
|
||||||
│ │ ├── dto/ # Data Transfer Objects
|
├── backend/ ← Spring Boot (Kotlin + Gradle)
|
||||||
│ │ └── scheduler/ # Scheduled tasks (trigger per entity)
|
│ ├── build.gradle.kts
|
||||||
│ └── resources/
|
│ ├── settings.gradle.kts
|
||||||
│ ├── application.yml # Main config
|
│ ├── Dockerfile
|
||||||
│ └── application-dev.yml # Dev profile config
|
│ └── src/
|
||||||
└── test/
|
│ ├── main/kotlin/com/condado/newsletter/
|
||||||
└── kotlin/com/condado/newsletter/ # Tests mirror main structure
|
│ │ ├── CondadoApplication.kt
|
||||||
|
│ │ ├── config/
|
||||||
|
│ │ ├── controller/
|
||||||
|
│ │ ├── service/
|
||||||
|
│ │ │ ├── AuthService.kt
|
||||||
|
│ │ │ ├── EntityService.kt
|
||||||
|
│ │ │ ├── EmailReaderService.kt
|
||||||
|
│ │ │ ├── PromptBuilderService.kt
|
||||||
|
│ │ │ ├── AiService.kt
|
||||||
|
│ │ │ └── EmailSenderService.kt
|
||||||
|
│ │ ├── repository/
|
||||||
|
│ │ ├── model/
|
||||||
|
│ │ ├── dto/
|
||||||
|
│ │ └── scheduler/
|
||||||
|
│ └── main/resources/
|
||||||
|
│ ├── application.yml
|
||||||
|
│ └── application-dev.yml
|
||||||
|
│
|
||||||
|
├── frontend/ ← React (TypeScript + Vite)
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.tsx
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── api/ ← Axios client + React Query hooks
|
||||||
|
│ ├── components/ ← Reusable UI components (shadcn/ui)
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── LoginPage.tsx
|
||||||
|
│ │ ├── DashboardPage.tsx
|
||||||
|
│ │ ├── EntitiesPage.tsx
|
||||||
|
│ │ └── LogsPage.tsx
|
||||||
|
│ └── router/ ← React Router config
|
||||||
|
│
|
||||||
|
└── nginx/
|
||||||
|
└── nginx.conf ← Shared Nginx config (used in both Docker flavours)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build & Run Commands
|
## Build & Run Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
```bash
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
./gradlew build
|
./gradlew build
|
||||||
|
|
||||||
# Run the application (dev profile)
|
# Run (dev profile)
|
||||||
./gradlew bootRun --args='--spring.profiles.active=dev'
|
./gradlew bootRun --args='--spring.profiles.active=dev'
|
||||||
|
|
||||||
# Run all tests
|
# Run tests
|
||||||
./gradlew test
|
./gradlew test
|
||||||
|
|
||||||
# Run a specific test class
|
# Run a specific test
|
||||||
./gradlew test --tests "com.condado.newsletter.service.PromptBuilderServiceTest"
|
./gradlew test --tests "com.condado.newsletter.service.PromptBuilderServiceTest"
|
||||||
|
```
|
||||||
|
|
||||||
# OpenAPI docs available at runtime
|
### Frontend
|
||||||
# http://localhost:8080/swagger-ui.html
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Dev server (with Vite proxy to backend)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Stack (Docker Compose)
|
||||||
|
```bash
|
||||||
|
# Dev (Mailhog included)
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# Prod
|
||||||
|
docker compose -f docker-compose.prod.yml up --build
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
|
### Backend (Kotlin)
|
||||||
- Use **Kotlin idiomatic** style: data classes, extension functions, and null-safety operators.
|
- Use **Kotlin idiomatic** style: data classes, extension functions, and null-safety operators.
|
||||||
- Prefer `val` over `var` wherever possible.
|
- Prefer `val` over `var` wherever possible.
|
||||||
- Use **constructor injection** for dependencies (never field injection with `@Autowired`).
|
- Use **constructor injection** for dependencies (never field injection with `@Autowired`).
|
||||||
@@ -101,15 +309,37 @@ src/
|
|||||||
- All public functions must have **KDoc** comments.
|
- All public functions must have **KDoc** comments.
|
||||||
- Use **`snake_case`** for database columns and **`camelCase`** for Kotlin properties.
|
- Use **`snake_case`** for database columns and **`camelCase`** for Kotlin properties.
|
||||||
- Keep controllers thin — business logic belongs in services.
|
- Keep controllers thin — business logic belongs in services.
|
||||||
- The AI prompt construction logic must live **exclusively** in `PromptBuilderService` — no other
|
- The AI prompt construction logic must live **exclusively** in `PromptBuilderService`.
|
||||||
class should build or modify prompt strings.
|
|
||||||
|
### Frontend (TypeScript / React)
|
||||||
|
- All components must be **functional** — no class components.
|
||||||
|
- Use **TypeScript strict mode** — no `any` types.
|
||||||
|
- All API calls go through the `src/api/` layer — never call `axios` directly in a component.
|
||||||
|
- Use **React Query** for all server state — never store server data in `useState`.
|
||||||
|
- Use **shadcn/ui** components for UI — do not build custom UI primitives from scratch.
|
||||||
|
- All pages live in `src/pages/` and are lazy-loaded via React Router.
|
||||||
|
- Protected routes must check for a valid JWT before rendering.
|
||||||
|
- No hardcoded strings that face the user — use constants or i18n keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Model
|
||||||
|
|
||||||
|
- There is **one single user** — the admin (you and your friends sharing the app).
|
||||||
|
- The password is set via the `APP_PASSWORD` environment variable on the backend.
|
||||||
|
- `POST /api/auth/login` accepts `{ "password": "..." }` and returns a JWT in an `httpOnly`
|
||||||
|
cookie (no username needed).
|
||||||
|
- The JWT is validated by Spring Security on every protected backend request.
|
||||||
|
- The React frontend redirects to `/login` if the cookie is absent or the JWT is expired.
|
||||||
|
- There is **no user registration, no user table, no role system**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
|
|
||||||
|
### Backend
|
||||||
| Artifact | Convention | Example |
|
| Artifact | Convention | Example |
|
||||||
|----------------|-----------------------------------|-------------------------------|
|
|----------------|----------------------|-------------------------------|
|
||||||
| Classes | PascalCase | `PromptBuilderService` |
|
| Classes | PascalCase | `PromptBuilderService` |
|
||||||
| Functions | camelCase | `buildPrompt()` |
|
| Functions | camelCase | `buildPrompt()` |
|
||||||
| Variables | camelCase | `entityList` |
|
| Variables | camelCase | `entityList` |
|
||||||
@@ -118,35 +348,57 @@ src/
|
|||||||
| REST endpoints | kebab-case | `/api/v1/virtual-entities` |
|
| REST endpoints | kebab-case | `/api/v1/virtual-entities` |
|
||||||
| Packages | lowercase | `com.condado.newsletter` |
|
| Packages | lowercase | `com.condado.newsletter` |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
| Artifact | Convention | Example |
|
||||||
|
|----------------|----------------------|-------------------------------|
|
||||||
|
| Components | PascalCase | `EntityCard.tsx` |
|
||||||
|
| Hooks | camelCase + `use` | `useEntities.ts` |
|
||||||
|
| API files | camelCase | `entitiesApi.ts` |
|
||||||
|
| Pages | PascalCase + `Page` | `EntitiesPage.tsx` |
|
||||||
|
| CSS classes | kebab-case (Tailwind)| `text-sm font-medium` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
|
### Backend
|
||||||
- Every service class must have a corresponding unit test class.
|
- Every service class must have a corresponding unit test class.
|
||||||
- Use **MockK** for mocking (not Mockito).
|
- Use **MockK** for mocking (not Mockito).
|
||||||
- Integration tests use `@SpringBootTest` and an **H2 in-memory** database.
|
- Integration tests use `@SpringBootTest` and an **H2 in-memory** database.
|
||||||
- Test method names follow the pattern: `should_[expectedBehavior]_when_[condition]`.
|
- Test method names follow: `should_[expectedBehavior]_when_[condition]`.
|
||||||
- Minimum 80% code coverage for service classes.
|
- Minimum 80% code coverage for service classes.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Every page component must have at least a smoke test (renders without crashing).
|
||||||
|
- API layer functions must be tested with mocked Axios responses.
|
||||||
|
- Use **Vitest** as the test runner and **React Testing Library** for component tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
All variables are defined in `.env` (root of the monorepo) and injected by Docker Compose.
|
||||||
|---------------------------|------------------------------------------------------|
|
Never hardcode any of these values.
|
||||||
| `SPRING_DATASOURCE_URL` | PostgreSQL connection URL |
|
|
||||||
| `SPRING_DATASOURCE_USERNAME` | DB username |
|
| Variable | Used by | Description |
|
||||||
| `SPRING_DATASOURCE_PASSWORD` | DB password |
|
|------------------------------|----------|------------------------------------------------------|
|
||||||
| `MAIL_HOST` | SMTP host (for sending emails) |
|
| `APP_PASSWORD` | Backend | Single admin password for login |
|
||||||
| `MAIL_PORT` | SMTP port |
|
| `JWT_SECRET` | Backend | Secret key for signing/verifying JWTs |
|
||||||
| `MAIL_USERNAME` | SMTP username (also used as IMAP login) |
|
| `JWT_EXPIRATION_MS` | Backend | JWT expiry in milliseconds (e.g. `86400000` = 1 day) |
|
||||||
| `MAIL_PASSWORD` | SMTP/IMAP password |
|
| `SPRING_DATASOURCE_URL` | Backend | PostgreSQL connection URL |
|
||||||
| `IMAP_HOST` | IMAP host (for reading the shared inbox) |
|
| `SPRING_DATASOURCE_USERNAME` | Backend | DB username |
|
||||||
| `IMAP_PORT` | IMAP port (default: 993) |
|
| `SPRING_DATASOURCE_PASSWORD` | Backend | DB password |
|
||||||
| `IMAP_INBOX_FOLDER` | IMAP folder to read (default: `INBOX`) |
|
| `MAIL_HOST` | Backend | SMTP host (Mailhog in dev, real SMTP in prod) |
|
||||||
| `OPENAI_API_KEY` | OpenAI API key for AI generation |
|
| `MAIL_PORT` | Backend | SMTP port |
|
||||||
| `OPENAI_MODEL` | OpenAI model to use (default: `gpt-4o`) |
|
| `MAIL_USERNAME` | Backend | SMTP username (also IMAP login) |
|
||||||
| `API_KEY` | API key to protect the REST endpoints |
|
| `MAIL_PASSWORD` | Backend | SMTP/IMAP password |
|
||||||
|
| `IMAP_HOST` | Backend | IMAP host for reading the shared inbox |
|
||||||
|
| `IMAP_PORT` | Backend | IMAP port (default: 993) |
|
||||||
|
| `IMAP_INBOX_FOLDER` | Backend | IMAP folder to read (default: `INBOX`) |
|
||||||
|
| `OPENAI_API_KEY` | Backend | OpenAI API key |
|
||||||
|
| `OPENAI_MODEL` | Backend | OpenAI model (default: `gpt-4o`) |
|
||||||
|
| `APP_RECIPIENTS` | Backend | Comma-separated list of recipient emails |
|
||||||
|
| `VITE_API_BASE_URL` | Frontend | Backend API base URL (used by Vite at build time) |
|
||||||
|
|
||||||
> ⚠️ Never hardcode credentials. Always use environment variables or a `.env` file (gitignored).
|
> ⚠️ Never hardcode credentials. Always use environment variables or a `.env` file (gitignored).
|
||||||
|
|
||||||
@@ -159,7 +411,8 @@ src/
|
|||||||
(cron expression), and an email context window (how many days back to read emails for context).
|
(cron expression), and an email context window (how many days back to read emails for context).
|
||||||
|
|
||||||
- **EmailContext:** A snapshot of recent emails read from the shared IMAP inbox, filtered by the
|
- **EmailContext:** A snapshot of recent emails read from the shared IMAP inbox, filtered by the
|
||||||
entity's configured context window (e.g., last 3 days). Used to give the AI conversational context.
|
entity's configured context window (e.g., last 3 days). Used to give the AI conversational
|
||||||
|
context.
|
||||||
|
|
||||||
- **Prompt:** The full text sent to the OpenAI API. Built by `PromptBuilderService` from the
|
- **Prompt:** The full text sent to the OpenAI API. Built by `PromptBuilderService` from the
|
||||||
entity's profile + the `EmailContext`. Always instructs the AI to write in an extremely formal
|
entity's profile + the `EmailContext`. Always instructs the AI to write in an extremely formal
|
||||||
@@ -191,13 +444,37 @@ for context:
|
|||||||
|
|
||||||
Write a new email to be sent to the company group, continuing the conversation naturally.
|
Write a new email to be sent to the company group, continuing the conversation naturally.
|
||||||
Reply or react to the recent emails if relevant. Sign off as [entity.name], [entity.jobTitle].
|
Reply or react to the recent emails if relevant. Sign off as [entity.name], [entity.jobTitle].
|
||||||
|
|
||||||
|
Format your response exactly as:
|
||||||
|
SUBJECT: <subject line here>
|
||||||
|
BODY:
|
||||||
|
<full email body here>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Git Workflow
|
## Git Workflow & CI/CD
|
||||||
|
|
||||||
- Branch naming: `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>`
|
- Branch naming: `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>`
|
||||||
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, `test:`
|
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, `test:`
|
||||||
- PRs require at least one passing CI check before merging.
|
- Scope your commits: `feat(backend):`, `feat(frontend):`, `chore(docker):`
|
||||||
|
- PRs require all CI checks to pass before merging.
|
||||||
- Never commit directly to `main`.
|
- Never commit directly to `main`.
|
||||||
|
|
||||||
|
### GitHub Actions Workflows
|
||||||
|
|
||||||
|
| Workflow file | Trigger | What it does |
|
||||||
|
|----------------------------|----------------------------|-----------------------------------------------------------|
|
||||||
|
| `.github/workflows/ci.yml` | Push / PR to any branch | Backend tests (`./gradlew test`) + Frontend tests (`npm run test`) |
|
||||||
|
| `.github/workflows/publish.yml` | Push to `main` | Builds `Dockerfile.allinone`, tags as `latest` + git SHA, pushes to Docker Hub |
|
||||||
|
|
||||||
|
**Required GitHub Secrets:**
|
||||||
|
|
||||||
|
| Secret | Description |
|
||||||
|
|-----------------------|--------------------------------------------|
|
||||||
|
| `DOCKERHUB_USERNAME` | Docker Hub account username |
|
||||||
|
| `DOCKERHUB_TOKEN` | Docker Hub access token (not password) |
|
||||||
|
|
||||||
|
**Image tags pushed on every `main` merge:**
|
||||||
|
- `<dockerhub-user>/condado-newsletter:latest`
|
||||||
|
- `<dockerhub-user>/condado-newsletter:<git-sha>` (for pinning)
|
||||||
|
|||||||
794
INSTRUCTIONS.md
794
INSTRUCTIONS.md
@@ -37,7 +37,7 @@ employee is an AI-powered entity that:
|
|||||||
| Step | Description | Status |
|
| Step | Description | Status |
|
||||||
|------|-----------------------------------------|-------------|
|
|------|-----------------------------------------|-------------|
|
||||||
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
||||||
| 1 | Scaffold project structure | ⬜ Pending |
|
| 1 | Scaffold monorepo structure | ⬜ Pending |
|
||||||
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
||||||
| 3 | Repositories | ⬜ Pending |
|
| 3 | Repositories | ⬜ Pending |
|
||||||
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
|
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
|
||||||
@@ -46,9 +46,12 @@ employee is an AI-powered entity that:
|
|||||||
| 7 | Email Sender Service (SMTP) | ⬜ Pending |
|
| 7 | Email Sender Service (SMTP) | ⬜ Pending |
|
||||||
| 8 | Scheduler (trigger per entity) | ⬜ Pending |
|
| 8 | Scheduler (trigger per entity) | ⬜ Pending |
|
||||||
| 9 | REST Controllers & DTOs | ⬜ Pending |
|
| 9 | REST Controllers & DTOs | ⬜ Pending |
|
||||||
| 10 | Security (API Key auth) | ⬜ Pending |
|
| 10 | Authentication (JWT login) | ⬜ Pending |
|
||||||
| 11 | Unit & integration tests | ⬜ Pending |
|
| 11 | React Frontend | ⬜ Pending |
|
||||||
| 12 | Docker & deployment config | ⬜ Pending |
|
| 12 | Unit & Integration Tests | ⬜ Pending |
|
||||||
|
| 13 | Docker Compose (dev + prod) | ⬜ Pending |
|
||||||
|
| 14 | All-in-one Docker image | ⬜ Pending |
|
||||||
|
| 15 | CI/CD — GitHub Actions + Docker Hub | ⬜ Pending |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,36 +80,51 @@ employee is an AI-powered entity that:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1 — Scaffold the Project Structure
|
## Step 1 — Scaffold the Monorepo Structure
|
||||||
|
|
||||||
**Goal:** Generate the full Gradle project skeleton with all dependencies configured.
|
**Goal:** Create the full project skeleton for both backend and frontend, with all
|
||||||
|
dependencies configured and the root-level Docker/CI files in place.
|
||||||
|
|
||||||
**What the AI should create:**
|
**What the AI should create:**
|
||||||
|
|
||||||
```
|
```
|
||||||
condado-news-letter/
|
condado-news-letter/
|
||||||
├── build.gradle.kts
|
|
||||||
├── settings.gradle.kts
|
|
||||||
├── gradle/wrapper/
|
|
||||||
│ ├── gradle-wrapper.jar
|
|
||||||
│ └── gradle-wrapper.properties
|
|
||||||
├── gradlew
|
|
||||||
├── gradlew.bat
|
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── src/
|
├── .gitignore
|
||||||
├── main/
|
├── docker-compose.yml
|
||||||
│ ├── kotlin/com/condado/newsletter/
|
├── docker-compose.prod.yml
|
||||||
│ │ └── CondadoApplication.kt
|
├── Dockerfile.allinone
|
||||||
│ └── resources/
|
├── nginx/
|
||||||
│ ├── application.yml
|
│ └── nginx.conf
|
||||||
│ └── application-dev.yml
|
├── .github/
|
||||||
└── test/
|
│ └── workflows/
|
||||||
└── kotlin/com/condado/newsletter/
|
│ ├── ci.yml
|
||||||
└── CondadoApplicationTests.kt
|
│ └── publish.yml
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── build.gradle.kts
|
||||||
|
│ ├── settings.gradle.kts
|
||||||
|
│ ├── gradlew / gradlew.bat
|
||||||
|
│ ├── gradle/wrapper/
|
||||||
|
│ └── src/main/kotlin/com/condado/newsletter/
|
||||||
|
│ └── CondadoApplication.kt
|
||||||
|
│ └── src/main/resources/
|
||||||
|
│ ├── application.yml
|
||||||
|
│ └── application-dev.yml
|
||||||
|
│ └── src/test/kotlin/com/condado/newsletter/
|
||||||
|
│ └── CondadoApplicationTests.kt
|
||||||
|
└── frontend/
|
||||||
|
├── Dockerfile
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts
|
||||||
|
├── tsconfig.json
|
||||||
|
├── index.html
|
||||||
|
└── src/
|
||||||
|
├── main.tsx
|
||||||
|
└── App.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
**Dependencies to include in `build.gradle.kts`:**
|
**Backend dependencies (`backend/build.gradle.kts`):**
|
||||||
|
|
||||||
| Dependency | Purpose |
|
| Dependency | Purpose |
|
||||||
|-----------------------------------------|----------------------------------------------|
|
|-----------------------------------------|----------------------------------------------|
|
||||||
@@ -114,9 +132,10 @@ condado-news-letter/
|
|||||||
| `spring-boot-starter-data-jpa` | Database access (JPA/Hibernate) |
|
| `spring-boot-starter-data-jpa` | Database access (JPA/Hibernate) |
|
||||||
| `spring-boot-starter-mail` | Email sending via SMTP |
|
| `spring-boot-starter-mail` | Email sending via SMTP |
|
||||||
| `spring-boot-starter-validation` | DTO validation |
|
| `spring-boot-starter-validation` | DTO validation |
|
||||||
| `spring-boot-starter-security` | API key authentication |
|
| `spring-boot-starter-security` | JWT authentication |
|
||||||
|
| `jjwt-api`, `jjwt-impl`, `jjwt-jackson` | JWT creation and validation (JJWT library) |
|
||||||
| `postgresql` | PostgreSQL JDBC driver |
|
| `postgresql` | PostgreSQL JDBC driver |
|
||||||
| `jakarta.mail` / `angus-mail` | IMAP email reading |
|
| `angus-mail` | IMAP email reading (Jakarta Mail impl) |
|
||||||
| `springdoc-openapi-starter-webmvc-ui` | Swagger UI / OpenAPI docs |
|
| `springdoc-openapi-starter-webmvc-ui` | Swagger UI / OpenAPI docs |
|
||||||
| `kotlin-reflect` | Required by Spring for Kotlin |
|
| `kotlin-reflect` | Required by Spring for Kotlin |
|
||||||
| `jackson-module-kotlin` | JSON serialization for Kotlin |
|
| `jackson-module-kotlin` | JSON serialization for Kotlin |
|
||||||
@@ -125,32 +144,42 @@ condado-news-letter/
|
|||||||
| `mockk` | Kotlin mocking library |
|
| `mockk` | Kotlin mocking library |
|
||||||
| `springmockk` | MockK integration for Spring |
|
| `springmockk` | MockK integration for Spring |
|
||||||
|
|
||||||
**`.env.example` should contain:**
|
**Frontend dependencies (`frontend/package.json`):**
|
||||||
```env
|
|
||||||
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado
|
| Package | Purpose |
|
||||||
SPRING_DATASOURCE_USERNAME=postgres
|
|-------------------------------|------------------------------------------|
|
||||||
SPRING_DATASOURCE_PASSWORD=postgres
|
| `react`, `react-dom` | Core React |
|
||||||
MAIL_HOST=smtp.example.com
|
| `typescript` | TypeScript |
|
||||||
MAIL_PORT=587
|
| `vite` | Build tool and dev server |
|
||||||
MAIL_USERNAME=company@example.com
|
| `@vitejs/plugin-react` | Vite React plugin |
|
||||||
MAIL_PASSWORD=secret
|
| `react-router-dom` | Client-side routing |
|
||||||
IMAP_HOST=imap.example.com
|
| `@tanstack/react-query` | Server state management |
|
||||||
IMAP_PORT=993
|
| `axios` | HTTP client |
|
||||||
IMAP_INBOX_FOLDER=INBOX
|
| `tailwindcss`, `postcss`, `autoprefixer` | Styling |
|
||||||
OPENAI_API_KEY=sk-...
|
| `@radix-ui/*`, `shadcn/ui` | UI component library |
|
||||||
OPENAI_MODEL=gpt-4o
|
| `lucide-react` | Icon library (used by shadcn) |
|
||||||
API_KEY=change-me
|
| `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:**
|
**Prompt to use with AI:**
|
||||||
> "Using the CLAUDE.md context, generate the full `build.gradle.kts`, `settings.gradle.kts`,
|
> "Using the CLAUDE.md context, scaffold the full monorepo. Create the backend Gradle project
|
||||||
> `application.yml`, `application-dev.yml`, `.gitignore`, `.env.example`, and the main
|
> with all dependencies, the frontend Vite+React project with all packages, the root `.env.example`,
|
||||||
> application entry point `CondadoApplication.kt`."
|
> `.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:**
|
**Done when:**
|
||||||
- [ ] `./gradlew build` runs successfully (compile only, no logic yet).
|
- [ ] `cd backend && ./gradlew build` compiles with no errors.
|
||||||
- [ ] Application starts with `./gradlew bootRun` without errors.
|
- [ ] `cd frontend && npm install && npm run build` succeeds.
|
||||||
- [ ] Swagger UI is accessible at `http://localhost:8080/swagger-ui.html`.
|
- [ ] Application starts with `./gradlew bootRun` (backend) without errors.
|
||||||
|
- [ ] `npm run dev` starts the Vite dev server.
|
||||||
|
- [ ] `docker compose up --build` starts all containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -429,80 +458,319 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 10 — Security (API Key Authentication)
|
## Step 10 — Authentication (JWT Login)
|
||||||
|
|
||||||
**Goal:** Protect all API endpoints with a simple API key header.
|
**Goal:** Implement the single-admin JWT login that protects all API endpoints.
|
||||||
|
|
||||||
**Approach:**
|
**Approach:**
|
||||||
- Spring Security with a custom `OncePerRequestFilter`.
|
- `POST /api/auth/login` accepts `{ "password": "..." }`, validates against `APP_PASSWORD` env var.
|
||||||
- Clients must send `X-API-KEY: <value>` header.
|
- On success, generates a JWT (signed with `JWT_SECRET`, expiry from `JWT_EXPIRATION_MS`) and
|
||||||
- Key is read from `API_KEY` environment variable.
|
sets it as an `httpOnly` cookie in the response.
|
||||||
- Swagger UI and OpenAPI spec (`/swagger-ui.html`, `/v3/api-docs/**`) are public.
|
- Spring Security `JwtAuthFilter` (extends `OncePerRequestFilter`) validates the cookie on every
|
||||||
|
protected request.
|
||||||
|
- Public paths: `POST /api/auth/login`, `/swagger-ui.html`, `/v3/api-docs/**`.
|
||||||
|
- There is **no user table** — the password lives only in the environment variable.
|
||||||
|
|
||||||
|
**Classes to create:**
|
||||||
|
- `AuthController` — `POST /api/auth/login` endpoint
|
||||||
|
- `AuthService` — validates password, generates JWT
|
||||||
|
- `JwtService` — signs and validates JWT tokens
|
||||||
|
- `JwtAuthFilter` — reads cookie, validates JWT, sets `SecurityContext`
|
||||||
|
- `SecurityConfig` — Spring Security HTTP config (permit login + swagger, protect everything else)
|
||||||
|
|
||||||
|
**DTOs:**
|
||||||
|
- `LoginRequest` — `{ "password": String }`
|
||||||
|
- `AuthResponse` — `{ "message": String }` (cookie is set on the response; no token in body)
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
**Prompt to use with AI:**
|
||||||
> "Using the CLAUDE.md context, add API key authentication with Spring Security. Create a
|
> "Using the CLAUDE.md context, implement JWT authentication for the single-admin model.
|
||||||
> custom filter that checks the `X-API-KEY` header against the `API_KEY` env var. Swagger UI
|
> Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`.
|
||||||
> paths must be excluded from authentication."
|
> `POST /api/auth/login` validates against `APP_PASSWORD` env var and returns a JWT in an
|
||||||
|
> httpOnly cookie. All other endpoints require the JWT cookie. Swagger UI is public."
|
||||||
|
|
||||||
**Done when:**
|
**Done when:**
|
||||||
- [ ] All endpoints return `401` without the correct `X-API-KEY` header.
|
- [ ] `POST /api/auth/login` with correct password sets an `httpOnly` JWT cookie and returns `200`.
|
||||||
- [ ] Swagger UI is still accessible without auth.
|
- [ ] `POST /api/auth/login` with wrong password returns `401`.
|
||||||
- [ ] API key is never hardcoded.
|
- [ ] All `/api/v1/**` endpoints return `401` without a valid JWT cookie.
|
||||||
|
- [ ] Swagger UI remains accessible without auth.
|
||||||
|
- [ ] Password and JWT secret are never hardcoded.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 11 — Unit & Integration Tests
|
## Step 11 — React Frontend
|
||||||
|
|
||||||
|
**Goal:** Build the admin SPA that communicates with the backend over the JWT cookie session.
|
||||||
|
|
||||||
|
**Pages to create:**
|
||||||
|
|
||||||
|
| Page | Path | Description |
|
||||||
|
|--------------------|-------------------|----------------------------------------------------------|
|
||||||
|
| `LoginPage` | `/login` | Password input form → calls `POST /api/auth/login` |
|
||||||
|
| `DashboardPage` | `/` | Overview: entity count, recent dispatch log summary |
|
||||||
|
| `EntitiesPage` | `/entities` | List, create, edit, delete, toggle active virtual entities|
|
||||||
|
| `LogsPage` | `/logs` | Paginated dispatch logs with status badges and full details|
|
||||||
|
|
||||||
|
**Structure under `frontend/src/`:**
|
||||||
|
```
|
||||||
|
api/
|
||||||
|
authApi.ts — login, logout calls
|
||||||
|
entitiesApi.ts — CRUD for VirtualEntity
|
||||||
|
logsApi.ts — fetch DispatchLog records
|
||||||
|
components/
|
||||||
|
EntityCard.tsx — card for a single entity
|
||||||
|
LogRow.tsx — row for a dispatch log entry
|
||||||
|
ProtectedRoute.tsx — redirects to /login if no valid session
|
||||||
|
NavBar.tsx — top navigation bar
|
||||||
|
pages/
|
||||||
|
LoginPage.tsx
|
||||||
|
DashboardPage.tsx
|
||||||
|
EntitiesPage.tsx
|
||||||
|
LogsPage.tsx
|
||||||
|
router/
|
||||||
|
index.tsx — React Router config with lazy-loaded routes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rules:**
|
||||||
|
- All server state via **React Query** — no `useState` for API data.
|
||||||
|
- All API calls go through `src/api/` — never call `axios` directly in a component.
|
||||||
|
- Use **shadcn/ui** for all UI components (Button, Input, Table, Badge, Dialog, etc.).
|
||||||
|
- `ProtectedRoute` checks for a live backend session by calling `GET /api/auth/me`
|
||||||
|
(add this endpoint to `AuthController`).
|
||||||
|
- Login form submits to `POST /api/auth/login` — on success React Query invalidates and
|
||||||
|
React Router navigates to `/`.
|
||||||
|
|
||||||
|
**Prompt to use with AI:**
|
||||||
|
> "Using the CLAUDE.md context, build the React frontend. Create all four pages (Login,
|
||||||
|
> Dashboard, Entities, Logs) with React Query for data fetching, shadcn/ui for components,
|
||||||
|
> and React Router for navigation. Implement `ProtectedRoute` using `GET /api/auth/me`.
|
||||||
|
> All API calls must go through the `src/api/` layer."
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
- [ ] `npm run build` succeeds with no TypeScript errors.
|
||||||
|
- [ ] `npm run dev` serves the app and login flow works end-to-end.
|
||||||
|
- [ ] Unauthenticated users are redirected to `/login`.
|
||||||
|
- [ ] Entities can be created, edited, toggled active, and deleted via the UI.
|
||||||
|
- [ ] Dispatch logs are visible and filterable by entity.
|
||||||
|
- [ ] All pages have at least a smoke test (`npm run test` passes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 12 — Unit & Integration Tests
|
||||||
|
|
||||||
**Goal:** Test every service class and one integration test per controller.
|
**Goal:** Test every service class and one integration test per controller.
|
||||||
|
|
||||||
|
### Backend tests
|
||||||
|
|
||||||
| Test Class | Type | Covers |
|
| Test Class | Type | Covers |
|
||||||
|---------------------------------|-------------|----------------------------------------------|
|
|---------------------------------|-------------|----------------------------------------------|
|
||||||
| `EmailReaderServiceTest` | Unit | IMAP fetch, empty list on error |
|
| `EmailReaderServiceTest` | Unit | IMAP fetch, empty list on error |
|
||||||
| `PromptBuilderServiceTest` | Unit | Prompt matches template, fields substituted |
|
| `PromptBuilderServiceTest` | Unit | Prompt matches template, fields substituted |
|
||||||
| `AiServiceTest` | Unit | API call mocked, parseResponse parsing |
|
| `AiServiceTest` | Unit | API call mocked, `parseResponse` parsing |
|
||||||
| `EmailSenderServiceTest` | Unit | JavaMailSender called with correct args |
|
| `EmailSenderServiceTest` | Unit | `JavaMailSender` called with correct args |
|
||||||
|
| `AuthServiceTest` | Unit | Correct/incorrect password, JWT generation |
|
||||||
| `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint |
|
| `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint |
|
||||||
| `DispatchLogControllerTest` | Integration | List all, list by entity |
|
| `DispatchLogControllerTest` | Integration | List all, list by entity |
|
||||||
|
|
||||||
|
### Frontend tests
|
||||||
|
|
||||||
|
| Test File | Covers |
|
||||||
|
|---------------------------------|--------------------------------------------------------|
|
||||||
|
| `LoginPage.test.tsx` | Renders, submits form, shows error on wrong password |
|
||||||
|
| `EntitiesPage.test.tsx` | Lists entities, opens create dialog, handles delete |
|
||||||
|
| `authApi.test.ts` | `login()` calls correct endpoint with correct payload |
|
||||||
|
| `entitiesApi.test.ts` | CRUD functions call correct endpoints |
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
**Prompt to use with AI:**
|
||||||
> "Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and
|
> "Using the CLAUDE.md context, generate unit tests for all backend service classes using MockK,
|
||||||
> integration tests for controllers using `@SpringBootTest` with H2. Follow naming convention
|
> integration tests for controllers using `@SpringBootTest` with H2, and frontend component and
|
||||||
> `should_[expectedBehavior]_when_[condition]`."
|
> API layer tests using Vitest + React Testing Library. Follow naming convention
|
||||||
|
> `should_[expectedBehavior]_when_[condition]` for backend tests."
|
||||||
|
|
||||||
**Done when:**
|
**Done when:**
|
||||||
- [ ] `./gradlew test` passes all tests green.
|
- [ ] `./gradlew test` passes all backend tests green.
|
||||||
- [ ] Service class coverage ≥ 80%.
|
- [ ] Backend service class coverage ≥ 80%.
|
||||||
|
- [ ] `npm run test` passes all frontend tests green.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 12 — Docker & Deployment Config
|
## Step 13 — Docker Compose (Dev + Prod)
|
||||||
|
|
||||||
**Goal:** Containerize the app and provide a local dev stack.
|
**Goal:** Containerize both services and wire them together for local dev and production.
|
||||||
|
|
||||||
|
**Files to create / update:**
|
||||||
|
|
||||||
**Files to create:**
|
|
||||||
```
|
```
|
||||||
condado-news-letter/
|
condado-news-letter/
|
||||||
├── Dockerfile # Multi-stage build
|
├── backend/Dockerfile # Multi-stage: Gradle build → slim JRE runtime
|
||||||
├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP/IMAP)
|
├── frontend/Dockerfile # Multi-stage: Node build → Nginx static file server
|
||||||
└── docker-compose.prod.yml # App + PostgreSQL only
|
├── 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:**
|
**Notes:**
|
||||||
- Use [Mailhog](https://github.com/mailhog/MailHog) in dev to capture outgoing emails
|
- Use [Mailhog](https://github.com/mailhog/MailHog) in dev (SMTP port 1025, web UI port 8025).
|
||||||
(SMTP on port 1025, web UI on port 8025).
|
- The `nginx` service serves the built React SPA and proxies `/api/**` to `backend:8080`.
|
||||||
- For IMAP in dev, consider [Greenmail](https://greenmail-mail-test.github.io/greenmail/) as
|
- Backend and Postgres communicate over an internal Docker network.
|
||||||
a local IMAP server for end-to-end testing.
|
- Env vars come from `.env` at the repo root (copied from `.env.example`).
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
**Prompt to use with AI:**
|
||||||
> "Using the CLAUDE.md context, create a multi-stage `Dockerfile` for the Spring Boot app, a
|
> "Using the CLAUDE.md context, create multi-stage Dockerfiles for the backend and frontend,
|
||||||
> `docker-compose.yml` for local dev (PostgreSQL + Mailhog + Greenmail), and a
|
> an `nginx/nginx.conf` that serves the React SPA and proxies `/api` to the backend, a
|
||||||
> `docker-compose.prod.yml` for production (PostgreSQL only, env vars from host)."
|
> `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:**
|
**Done when:**
|
||||||
- [ ] `docker-compose up` starts the full stack.
|
- [ ] `docker compose up --build` starts all services without errors.
|
||||||
- [ ] App connects to PostgreSQL container.
|
- [ ] `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`.
|
- [ ] Outgoing emails appear in Mailhog at `http://localhost:8025`.
|
||||||
- [ ] `docker build -t condado-newsletter .` succeeds.
|
- [ ] `docker compose -f docker-compose.prod.yml up --build` works (no Mailhog).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 14 — All-in-one Docker Image
|
||||||
|
|
||||||
|
**Goal:** Build a single Docker image that runs the entire stack (Nginx + Spring Boot + PostgreSQL)
|
||||||
|
under Supervisor, deployable with a single `docker run` command.
|
||||||
|
|
||||||
|
**File to create:** `Dockerfile.allinone` at the repo root.
|
||||||
|
|
||||||
|
**What the image bundles:**
|
||||||
|
- **Nginx** — serves the React SPA and proxies `/api` to Spring Boot
|
||||||
|
- **Spring Boot** — the backend (from the multi-stage backend build)
|
||||||
|
- **PostgreSQL** — embedded database
|
||||||
|
- **Supervisor** — starts and supervises all three processes
|
||||||
|
|
||||||
|
**Base approach:**
|
||||||
|
1. Stage 1: Build frontend (`node:20-alpine` → `npm run build`)
|
||||||
|
2. Stage 2: Build backend (`gradle:8-jdk21-alpine` → `./gradlew bootJar`)
|
||||||
|
3. Stage 3: Final image (`ubuntu:24.04` or `debian:bookworm-slim`)
|
||||||
|
- Install: `nginx`, `postgresql`, `supervisor`, `openjdk-21-jre-headless`
|
||||||
|
- Copy frontend build → `/usr/share/nginx/html/`
|
||||||
|
- Copy backend JAR → `/app/app.jar`
|
||||||
|
- Copy `nginx/nginx.conf` → `/etc/nginx/nginx.conf`
|
||||||
|
- Add a `supervisord.conf` that starts all three processes
|
||||||
|
- Add an `entrypoint.sh` that initialises the PostgreSQL data directory on first run
|
||||||
|
and sets `SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado`
|
||||||
|
|
||||||
|
**Supervisor config (`supervisord.conf`):**
|
||||||
|
```ini
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
|
||||||
|
[program:postgres]
|
||||||
|
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
|
||||||
|
user=postgres
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
|
||||||
|
[program:backend]
|
||||||
|
command=java -jar /app/app.jar
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=10
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g "daemon off;"
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minimal run command (from `CLAUDE.md`):**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 80:80 \
|
||||||
|
-e APP_PASSWORD=yourpassword \
|
||||||
|
-e JWT_SECRET=yoursecret \
|
||||||
|
-e OPENAI_API_KEY=sk-... \
|
||||||
|
-e MAIL_HOST=smtp.example.com \
|
||||||
|
-e MAIL_PORT=587 \
|
||||||
|
-e MAIL_USERNAME=company@example.com \
|
||||||
|
-e MAIL_PASSWORD=secret \
|
||||||
|
-e IMAP_HOST=imap.example.com \
|
||||||
|
-e IMAP_PORT=993 \
|
||||||
|
-e APP_RECIPIENTS=friend1@example.com,friend2@example.com \
|
||||||
|
-v condado-data:/var/lib/postgresql/data \
|
||||||
|
<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 15 — CI/CD (GitHub Actions + Docker Hub)
|
||||||
|
|
||||||
|
**Goal:** Automate testing on every PR and publish the all-in-one image to Docker Hub on every
|
||||||
|
merge to `main`.
|
||||||
|
|
||||||
|
**Files to create:**
|
||||||
|
```
|
||||||
|
.github/
|
||||||
|
└── workflows/
|
||||||
|
├── ci.yml — run backend + frontend tests on every push / PR
|
||||||
|
└── publish.yml — build Dockerfile.allinone and push to Docker Hub on push to main
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ci.yml` — Continuous Integration
|
||||||
|
|
||||||
|
**Triggers:** `push` and `pull_request` on any branch.
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
1. **`backend-test`**
|
||||||
|
- `actions/checkout`
|
||||||
|
- `actions/setup-java` (JDK 21)
|
||||||
|
- `./gradlew test` in `backend/`
|
||||||
|
- Upload test results as artifact
|
||||||
|
2. **`frontend-test`**
|
||||||
|
- `actions/checkout`
|
||||||
|
- `actions/setup-node` (Node 20)
|
||||||
|
- `npm ci` then `npm run test` in `frontend/`
|
||||||
|
|
||||||
|
### `publish.yml` — Docker Hub Publish
|
||||||
|
|
||||||
|
**Triggers:** `push` to `main` only.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `actions/checkout`
|
||||||
|
2. `docker/setup-buildx-action`
|
||||||
|
3. `docker/login-action` — uses `DOCKERHUB_USERNAME` + `DOCKERHUB_TOKEN` secrets
|
||||||
|
4. `docker/build-push-action`
|
||||||
|
- File: `Dockerfile.allinone`
|
||||||
|
- Tags:
|
||||||
|
- `${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:latest`
|
||||||
|
- `${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:${{ github.sha }}`
|
||||||
|
- `push: true`
|
||||||
|
|
||||||
|
**Required GitHub repository secrets:**
|
||||||
|
|
||||||
|
| Secret | Where to set | Value |
|
||||||
|
|----------------------|-------------------------------|----------------------------------|
|
||||||
|
| `DOCKERHUB_USERNAME` | Repo → Settings → Secrets | Your Docker Hub username |
|
||||||
|
| `DOCKERHUB_TOKEN` | Repo → Settings → Secrets | Docker Hub access token (not password) |
|
||||||
|
|
||||||
|
**Prompt to use with AI:**
|
||||||
|
> "Using the CLAUDE.md context, create `.github/workflows/ci.yml` that runs backend Gradle tests
|
||||||
|
> and frontend Vitest tests on every push/PR. Also create `.github/workflows/publish.yml` that
|
||||||
|
> builds `Dockerfile.allinone` and pushes two tags (`latest` + git SHA) to Docker Hub using
|
||||||
|
> `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets, triggered only on push to `main`."
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
- [ ] Every PR shows green CI checks for both backend and frontend tests.
|
||||||
|
- [ ] Merging to `main` triggers an image build and push to Docker Hub.
|
||||||
|
- [ ] Both `latest` and `<git-sha>` tags are visible on Docker Hub after a push.
|
||||||
|
- [ ] Workflow files pass YAML linting (`actionlint` or similar).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -514,359 +782,11 @@ condado-news-letter/
|
|||||||
| 2026-03-26 | MockK over Mockito | More idiomatic for Kotlin |
|
| 2026-03-26 | MockK over Mockito | More idiomatic for Kotlin |
|
||||||
| 2026-03-26 | UUID as primary keys | Better for distributed systems |
|
| 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 | No Thymeleaf — AI generates email content directly | Email body is AI-produced, no template needed |
|
||||||
| 2026-03-26 | API key auth (not JWT) | Simple internal tool, can upgrade to OAuth2 later |
|
| 2026-03-26 | JWT auth (single admin, password via env var) | No user table needed; simple and secure for a private tool |
|
||||||
| 2026-03-26 | Use Spring `RestClient` for OpenAI (not WebClient) | Spring Boot 3.2+ preferred HTTP client |
|
| 2026-03-26 | 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 | 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 | `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 | 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 |
|
||||||
|
|
||||||
## Step 0 — Define the Project & Write CLAUDE.md
|
|
||||||
|
|
||||||
**Goal:** Establish the project scope and create the persistent AI instructions file.
|
|
||||||
|
|
||||||
**What was done:**
|
|
||||||
- Decided on Kotlin + Spring Boot 3.x as the core stack.
|
|
||||||
- Chose PostgreSQL for persistence, Spring Mail for email, and Gradle (Kotlin DSL) as the build tool.
|
|
||||||
- Defined the four core domain concepts: `Subscriber`, `NewsletterIssue`, `Campaign`, `SendLog`.
|
|
||||||
- Created `CLAUDE.md` with project structure, coding standards, naming conventions, and environment variables.
|
|
||||||
|
|
||||||
**Key decisions:**
|
|
||||||
- Use Gradle Kotlin DSL (`build.gradle.kts`) instead of Groovy DSL.
|
|
||||||
- Use MockK for tests, not Mockito (more idiomatic for Kotlin).
|
|
||||||
- Use Springdoc OpenAPI for automatic API documentation.
|
|
||||||
- Thymeleaf for HTML email templates.
|
|
||||||
|
|
||||||
**Output files:**
|
|
||||||
- `CLAUDE.md` ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1 — Scaffold the Project Structure
|
|
||||||
|
|
||||||
**Goal:** Generate the full Gradle project skeleton with all dependencies configured.
|
|
||||||
|
|
||||||
**What the AI should create:**
|
|
||||||
|
|
||||||
```
|
|
||||||
condado-news-letter/
|
|
||||||
├── build.gradle.kts
|
|
||||||
├── settings.gradle.kts
|
|
||||||
├── gradle/
|
|
||||||
│ └── wrapper/
|
|
||||||
│ ├── gradle-wrapper.jar
|
|
||||||
│ └── gradle-wrapper.properties
|
|
||||||
├── gradlew
|
|
||||||
├── gradlew.bat
|
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
|
||||||
└── src/
|
|
||||||
├── main/
|
|
||||||
│ ├── kotlin/com/condado/newsletter/
|
|
||||||
│ │ └── CondadoNewsletterApplication.kt
|
|
||||||
│ └── resources/
|
|
||||||
│ ├── application.yml
|
|
||||||
│ └── application-dev.yml
|
|
||||||
└── test/
|
|
||||||
└── kotlin/com/condado/newsletter/
|
|
||||||
└── CondadoNewsletterApplicationTests.kt
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependencies to include in `build.gradle.kts`:**
|
|
||||||
|
|
||||||
| Dependency | Purpose |
|
|
||||||
|-----------------------------------------|----------------------------------|
|
|
||||||
| `spring-boot-starter-web` | REST API |
|
|
||||||
| `spring-boot-starter-data-jpa` | Database access (JPA/Hibernate) |
|
|
||||||
| `spring-boot-starter-mail` | Email sending via SMTP |
|
|
||||||
| `spring-boot-starter-validation` | DTO validation |
|
|
||||||
| `spring-boot-starter-thymeleaf` | HTML email templates |
|
|
||||||
| `thymeleaf-extras-spring6` | Thymeleaf + Spring 6 integration |
|
|
||||||
| `postgresql` | PostgreSQL JDBC driver |
|
|
||||||
| `springdoc-openapi-starter-webmvc-ui` | Swagger UI / OpenAPI docs |
|
|
||||||
| `kotlin-reflect` | Required by Spring for Kotlin |
|
|
||||||
| `jackson-module-kotlin` | JSON serialization for Kotlin |
|
|
||||||
| `h2` (testRuntimeOnly) | In-memory DB for tests |
|
|
||||||
| `spring-boot-starter-test` | JUnit 5 test support |
|
|
||||||
| `mockk` | Kotlin mocking library |
|
|
||||||
| `springmockk` | MockK integration for Spring |
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, generate the full `build.gradle.kts`, `settings.gradle.kts`, `application.yml`, `application-dev.yml`, `.gitignore`, `.env.example`, and the main application entry point `CondadoNewsletterApplication.kt`."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] `./gradlew build` runs successfully (compile only, no logic yet).
|
|
||||||
- [ ] Application starts with `./gradlew bootRun` without errors.
|
|
||||||
- [ ] Swagger UI is accessible at `http://localhost:8080/swagger-ui.html`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2 — Domain Model (JPA Entities)
|
|
||||||
|
|
||||||
**Goal:** Create all database entities and their relationships.
|
|
||||||
|
|
||||||
**Entities to create:**
|
|
||||||
|
|
||||||
### `Subscriber`
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|-----------------|-------------|-------------------------------|
|
|
||||||
| `id` | UUID | Primary key, auto-generated |
|
|
||||||
| `email` | String | Unique, not null |
|
|
||||||
| `name` | String | Not null |
|
|
||||||
| `subscribed_at` | LocalDateTime | Auto-set on creation |
|
|
||||||
| `active` | Boolean | Default true |
|
|
||||||
|
|
||||||
### `NewsletterIssue`
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|-----------------|-------------|-------------------------------|
|
|
||||||
| `id` | UUID | Primary key, auto-generated |
|
|
||||||
| `title` | String | Not null |
|
|
||||||
| `subject` | String | Email subject line |
|
|
||||||
| `html_body` | Text | Full HTML content |
|
|
||||||
| `created_at` | LocalDateTime | Auto-set on creation |
|
|
||||||
| `status` | Enum | DRAFT / READY / ARCHIVED |
|
|
||||||
|
|
||||||
### `Campaign`
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---------------------|---------------|------------------------------|
|
|
||||||
| `id` | UUID | Primary key |
|
|
||||||
| `newsletter_issue_id` | UUID (FK) | References `NewsletterIssue` |
|
|
||||||
| `scheduled_at` | LocalDateTime | When to send |
|
|
||||||
| `sent_at` | LocalDateTime | Nullable, set when sent |
|
|
||||||
| `status` | Enum | SCHEDULED / RUNNING / DONE / FAILED |
|
|
||||||
|
|
||||||
### `SendLog`
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|----------------|---------------|-------------------------------|
|
|
||||||
| `id` | UUID | Primary key |
|
|
||||||
| `campaign_id` | UUID (FK) | References `Campaign` |
|
|
||||||
| `subscriber_id`| UUID (FK) | References `Subscriber` |
|
|
||||||
| `sent_at` | LocalDateTime | Nullable |
|
|
||||||
| `status` | Enum | PENDING / SENT / FAILED |
|
|
||||||
| `error_message`| String | Nullable, stores failure reason |
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, create all four JPA entities: `Subscriber`, `NewsletterIssue`, `Campaign`, and `SendLog`. Place them in the `model/` package. Use UUIDs as primary keys, Kotlin data classes where appropriate, and proper JPA annotations."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] All four entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
|
||||||
- [ ] `./gradlew build` still compiles cleanly.
|
|
||||||
- [ ] Database tables are auto-created by Hibernate on startup (with `ddl-auto: create-drop` in dev profile).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Repositories
|
|
||||||
|
|
||||||
**Goal:** Create Spring Data JPA repositories for each entity.
|
|
||||||
|
|
||||||
**Repositories to create:**
|
|
||||||
|
|
||||||
| Repository | Entity | Custom queries needed |
|
|
||||||
|-----------------------------|-------------------|------------------------------------------------|
|
|
||||||
| `SubscriberRepository` | `Subscriber` | `findByEmail()`, `findAllByActiveTrue()` |
|
|
||||||
| `NewsletterIssueRepository` | `NewsletterIssue` | `findAllByStatus()` |
|
|
||||||
| `CampaignRepository` | `Campaign` | `findAllByStatus()`, `findByScheduledAtBefore()` |
|
|
||||||
| `SendLogRepository` | `SendLog` | `findByCampaignId()`, `countByStatus()` |
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, create the four JPA repositories in the `repository/` package. Each must extend `JpaRepository` and include the custom query methods listed in the instructions."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] All four repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`.
|
|
||||||
- [ ] `./gradlew build` compiles cleanly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4 — Services (Business Logic)
|
|
||||||
|
|
||||||
**Goal:** Implement the core business logic for each domain area.
|
|
||||||
|
|
||||||
**Services to create:**
|
|
||||||
|
|
||||||
### `SubscriberService`
|
|
||||||
- `subscribe(dto: SubscriberCreateDto): Subscriber` — register a new subscriber
|
|
||||||
- `unsubscribe(email: String)` — set `active = false`
|
|
||||||
- `listActive(): List<Subscriber>` — get all active subscribers
|
|
||||||
- `findByEmail(email: String): Subscriber`
|
|
||||||
|
|
||||||
### `NewsletterIssueService`
|
|
||||||
- `createIssue(dto: NewsletterIssueCreateDto): NewsletterIssue`
|
|
||||||
- `updateIssue(id: UUID, dto: NewsletterIssueUpdateDto): NewsletterIssue`
|
|
||||||
- `archiveIssue(id: UUID)`
|
|
||||||
- `listIssues(status: IssueStatus?): List<NewsletterIssue>`
|
|
||||||
|
|
||||||
### `CampaignService`
|
|
||||||
- `scheduleCampaign(dto: CampaignCreateDto): Campaign`
|
|
||||||
- `cancelCampaign(id: UUID)`
|
|
||||||
- `listCampaigns(status: CampaignStatus?): List<Campaign>`
|
|
||||||
- `triggerCampaign(id: UUID)` — manually kick off sending
|
|
||||||
|
|
||||||
### `EmailService`
|
|
||||||
- `sendNewsletterEmail(subscriber: Subscriber, issue: NewsletterIssue): Boolean`
|
|
||||||
- Uses `JavaMailSender` and Thymeleaf template engine
|
|
||||||
- Returns `true` on success, `false` on failure (logs error)
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, create the four service classes: `SubscriberService`, `NewsletterIssueService`, `CampaignService`, and `EmailService`. Place them in the `service/` package. Follow all coding standards from CLAUDE.md."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] All four service files exist in `src/main/kotlin/com/condado/newsletter/service/`.
|
|
||||||
- [ ] `./gradlew build` compiles cleanly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5 — REST Controllers & DTOs
|
|
||||||
|
|
||||||
**Goal:** Expose the service layer as a REST API with proper request/response DTOs.
|
|
||||||
|
|
||||||
**Controllers to create:**
|
|
||||||
|
|
||||||
| Controller | Base Path | Methods |
|
|
||||||
|-------------------------------|----------------------------|-----------------------------------|
|
|
||||||
| `SubscriberController` | `/api/v1/subscribers` | `POST`, `GET`, `DELETE /{email}` |
|
|
||||||
| `NewsletterIssueController` | `/api/v1/newsletter-issues`| `POST`, `GET`, `PUT /{id}`, `DELETE /{id}` |
|
|
||||||
| `CampaignController` | `/api/v1/campaigns` | `POST`, `GET`, `POST /{id}/trigger`, `DELETE /{id}` |
|
|
||||||
|
|
||||||
**DTOs to create (in `dto/` package):**
|
|
||||||
- `SubscriberCreateDto`, `SubscriberResponseDto`
|
|
||||||
- `NewsletterIssueCreateDto`, `NewsletterIssueUpdateDto`, `NewsletterIssueResponseDto`
|
|
||||||
- `CampaignCreateDto`, `CampaignResponseDto`
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, create the REST controllers and all DTOs. Controllers go in `controller/`, DTOs in `dto/`. All controllers must return `ResponseEntity<T>`. DTOs must use validation annotations."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] All controller and DTO files exist.
|
|
||||||
- [ ] Swagger UI at `http://localhost:8080/swagger-ui.html` shows all endpoints.
|
|
||||||
- [ ] Manual test with a REST client (curl / Postman / Swagger UI) succeeds for basic CRUD.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 6 — Email Sending (Spring Mail + Thymeleaf)
|
|
||||||
|
|
||||||
**Goal:** Send real HTML emails using a Thymeleaf template.
|
|
||||||
|
|
||||||
**What to create:**
|
|
||||||
- `src/main/resources/templates/newsletter-email.html` — the HTML email template
|
|
||||||
- `EmailService` (already defined in Step 4) — connect it to the template engine
|
|
||||||
|
|
||||||
**Template variables:**
|
|
||||||
- `${subscriber.name}` — recipient's name
|
|
||||||
- `${issue.title}` — newsletter title
|
|
||||||
- `${issue.htmlBody}` — main newsletter content
|
|
||||||
- `${unsubscribeUrl}` — one-click unsubscribe link
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, create the Thymeleaf HTML email template and complete the `EmailService` to render it and send via `JavaMailSender`. Include an unsubscribe link in the template."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] Template file exists at `src/main/resources/templates/newsletter-email.html`.
|
|
||||||
- [ ] `EmailService.sendNewsletterEmail()` renders the template and sends via SMTP.
|
|
||||||
- [ ] Tested with a real SMTP server (e.g., Mailtrap or Gmail SMTP in dev).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7 — Scheduler (Automated Campaigns)
|
|
||||||
|
|
||||||
**Goal:** Automatically trigger campaigns at their `scheduled_at` time.
|
|
||||||
|
|
||||||
**What to create:**
|
|
||||||
- `NewsletterScheduler` class in `scheduler/` package
|
|
||||||
- Uses `@Scheduled(fixedDelay = 60000)` — checks every 60 seconds
|
|
||||||
- Finds all `SCHEDULED` campaigns where `scheduled_at <= now()`
|
|
||||||
- Calls `CampaignService.triggerCampaign()` for each
|
|
||||||
- Updates `SendLog` entries for every subscriber
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, create the `NewsletterScheduler` class. It should run every 60 seconds, find campaigns due to be sent, and dispatch emails to all active subscribers. Update `SendLog` with SENT or FAILED status for each attempt."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] `NewsletterScheduler.kt` exists in `scheduler/` package.
|
|
||||||
- [ ] Scheduler correctly processes due campaigns.
|
|
||||||
- [ ] `SendLog` records are created for each send attempt.
|
|
||||||
- [ ] `@EnableScheduling` is added to the main application class or a config class.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 8 — Security (API Key Authentication)
|
|
||||||
|
|
||||||
**Goal:** Protect the API with a simple API key header check.
|
|
||||||
|
|
||||||
**Approach:**
|
|
||||||
- Use Spring Security with a custom `OncePerRequestFilter`
|
|
||||||
- Clients must pass `X-API-KEY: <key>` header
|
|
||||||
- The key is stored in an environment variable `API_KEY`
|
|
||||||
- Public endpoints (unsubscribe link) are excluded from auth
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, add API key authentication using Spring Security. Create a custom filter that checks the `X-API-KEY` header. The key must come from the `API_KEY` environment variable. Exclude `GET /api/v1/subscribers/unsubscribe/**` from auth."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] All API endpoints return `401 Unauthorized` without a valid API key.
|
|
||||||
- [ ] Unsubscribe endpoint works without auth.
|
|
||||||
- [ ] API key is read from environment variable, never hardcoded.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 9 — Unit & Integration Tests
|
|
||||||
|
|
||||||
**Goal:** Test every service class and at least one integration test per controller.
|
|
||||||
|
|
||||||
**Tests to create:**
|
|
||||||
|
|
||||||
| Test class | Type | Covers |
|
|
||||||
|-----------------------------------|-------------|---------------------------------|
|
|
||||||
| `SubscriberServiceTest` | Unit | subscribe, unsubscribe, list |
|
|
||||||
| `NewsletterIssueServiceTest` | Unit | create, update, archive |
|
|
||||||
| `CampaignServiceTest` | Unit | schedule, cancel, trigger |
|
|
||||||
| `EmailServiceTest` | Unit | template rendering, send logic |
|
|
||||||
| `SubscriberControllerTest` | Integration | POST /api/v1/subscribers |
|
|
||||||
| `NewsletterIssueControllerTest` | Integration | CRUD /api/v1/newsletter-issues |
|
|
||||||
| `CampaignControllerTest` | Integration | POST, trigger, cancel |
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and integration tests for all controllers using `@SpringBootTest` with H2 in-memory database. Follow the naming convention `should_[expectedBehavior]_when_[condition]`."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] `./gradlew test` passes with all tests green.
|
|
||||||
- [ ] Code coverage for service classes is ≥ 80%.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 10 — Docker & Deployment Config
|
|
||||||
|
|
||||||
**Goal:** Containerize the application and provide a `docker-compose.yml` for local development.
|
|
||||||
|
|
||||||
**What to create:**
|
|
||||||
|
|
||||||
```
|
|
||||||
condado-news-letter/
|
|
||||||
├── Dockerfile
|
|
||||||
├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP)
|
|
||||||
└── docker-compose.prod.yml # App + PostgreSQL only
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prompt to use with AI:**
|
|
||||||
> "Using the CLAUDE.md context, create a multi-stage `Dockerfile` for the Spring Boot app, a `docker-compose.yml` for local development (includes PostgreSQL and Mailhog for email testing), and a `docker-compose.prod.yml` for production (PostgreSQL only, env vars from host)."
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
- [ ] `docker-compose up` starts the full stack.
|
|
||||||
- [ ] App connects to the PostgreSQL container.
|
|
||||||
- [ ] Emails sent in dev are captured by Mailhog at `http://localhost:8025`.
|
|
||||||
- [ ] `./gradlew build && docker build -t condado-newsletter .` succeeds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes & Decisions Log
|
|
||||||
|
|
||||||
> Use this section to record important decisions made during the build. Add entries as you go.
|
|
||||||
|
|
||||||
| Date | Decision | Reason |
|
|
||||||
|------------|-------------------------------------------------|-----------------------------------------|
|
|
||||||
| 2026-03-26 | Chose Kotlin + Spring Boot 3.x | Modern, type-safe, great Spring support |
|
|
||||||
| 2026-03-26 | MockK over Mockito | More idiomatic for Kotlin |
|
|
||||||
| 2026-03-26 | UUID as primary keys | Better for distributed systems |
|
|
||||||
| 2026-03-26 | Thymeleaf for email templates | Native Spring Boot support |
|
|
||||||
| 2026-03-26 | API key auth (not JWT) for simplicity in step 1 | Can be upgraded to OAuth2 later |
|
|
||||||
|
|||||||
Reference in New Issue
Block a user