diff --git a/.gitignore b/.gitignore
index 6dbfc40..34566dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,9 @@ dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
+### Local secrets ###
+application-local.yaml
+
### Project ###
contacts.zip
contacts.json
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
index 65f8d6b..322f2c6 100644
--- a/.idea/dictionaries/project.xml
+++ b/.idea/dictionaries/project.xml
@@ -4,6 +4,7 @@
hono
htmx
shadcn
+ yamcodes
\ No newline at end of file
diff --git a/.run/Docker Compose Up.run.xml b/.run/Docker Compose Up.run.xml
new file mode 100644
index 0000000..75885b8
--- /dev/null
+++ b/.run/Docker Compose Up.run.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Spring Development.run.xml b/.run/Spring Development.run.xml
index 212f448..bfc3866 100644
--- a/.run/Spring Development.run.xml
+++ b/.run/Spring Development.run.xml
@@ -1,10 +1,11 @@
-
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 1ba173e..5cce395 100644
--- a/README.md
+++ b/README.md
@@ -21,53 +21,90 @@ This app follows the architecture from [Hypermedia Systems](https://hypermedia.s
- Add, edit, and delete contact details
- Server-rendered HTML with Thymeleaf
- Partial page updates (no full reloads)
-- H2 in-memory database with Spring Data JPA
+- PostgreSQL + Spring Data JPA + Flyway migrations
## Development
-### Quickstart
+### Prerequisites
+
+- Java 25
+- Docker (for local Postgres)
+
+### Setup
+
+1. **Create `./config/application-local.yaml`** (gitignored — never committed, lives outside the source tree):
+
+```yaml
+spring:
+ security:
+ user:
+ name:
+ password:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/contacts
+ username: contacts
+ password: contacts
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ sql:
+ init:
+ mode: always
+ thymeleaf:
+ cache: false
+ prefix: file:src/main/resources/templates/
+ devtools:
+ livereload:
+ enabled: true
+ restart:
+ additional-paths: src/main/resources/templates/
+ additional-exclude: "**/*.html"
+ web:
+ resources:
+ static-locations:
+ - file:src/main/resources/static/
+ - classpath:/static/
+ cache:
+ period: 0
+ chain:
+ cache: false
+```
-```bash
-# Start dev server
-./mvnw spring-boot:run
+2. **Start Postgres:**
-# Open in browser
-open http://localhost:8080
+```bash
+./scripts/start-db.sh
```
-### VS Code
+3. **Run the app:**
-1. **Install extensions** - open the Extensions panel, search `@recommended`, and install all workspace recommendations (Java Extension Pack, Spring Boot Extension Pack, Lombok, htmx attributes)
-2. **Java 25 SDK** - VS Code will prompt to download a JDK if none is found; select Java 25 (if you have multiple JDKs, point `java.jdt.ls.java.home` in user settings to your Java 25 installation)
-3. **Run** - use the **Run** task via `Ctrl+Shift+P → Tasks: Run Task → Run`, or press `Ctrl+Shift+P → Spring Boot Dashboard: Run` from the Spring Boot extension
-
-**Tips:**
-
-- Spring Boot DevTools (already included) enables hot reload - save a file and changes apply automatically
-- Thymeleaf templates hot-reload without a restart
-- H2 console is available at `http://localhost:8080/h2-console`
+```bash
+./mvnw spring-boot:run -Dspring-boot.run.profiles=local
+```
### IntelliJ IDEA
1. **Open** - **File → Open**, select `pom.xml`, choose **"Open as Project"**
2. **Java 25 SDK** - **File → Project Structure → SDKs**, add Java 25 if missing (IntelliJ can download it)
3. **Lombok** - install the [Lombok plugin](https://plugins.jetbrains.com/plugin/6317-lombok) via **Settings → Plugins**, then enable annotation processing under **Settings → Build, Execution, Deployment → Compiler → Annotation Processors**
-4. **Run** - use the included **Contacts** run configuration (`.run/Contacts.run.xml`) from the toolbar, or run `spring-boot:run` from the Maven tool window
+4. **Create `./config/application-local.yaml`** - see [Setup](#setup) above
+5. **Run** - use the included **Development** compound run configuration from the toolbar — it starts Docker, waits for Postgres, then launches the app
**Tips:**
- Spring Boot DevTools (already included) enables hot reload - recompile with `Ctrl+F9` without restarting
- Enable **"Build project automatically"** (**Settings → Build, Execution, Deployment → Compiler**) and **"Allow auto-make to start even if developed application is currently running"** (**Settings → Advanced Settings**) for seamless reloads on save
- Thymeleaf templates hot-reload without a restart
-- H2 console is available at `http://localhost:8080/h2-console`
### Scripts
-| Command | Description |
-| ------------------------ | ---------------- |
-| `./mvnw spring-boot:run` | Start dev server |
-| `./mvnw test` | Run tests |
-| `./mvnw package` | Build jar |
+| Command | Description |
+| --------------------------------------------------------- | -------------------- |
+| `./scripts/start-db.sh` | Start local Postgres |
+| `docker compose down` | Stop local Postgres |
+| `./mvnw spring-boot:run -Dspring-boot.run.profiles=local` | Start dev server |
+| `./mvnw test` | Run tests |
+| `./mvnw package` | Build jar |
## Project structure
@@ -81,6 +118,9 @@ src/
│ │ ├── ContactRepository.java # Spring Data JPA (search, pagination)
│ │ └── ContactService.java # Validation, slug generation, business logic
│ └── resources/
+│ ├── db/migration/
+│ │ ├── V1__init.sql # Initial schema (Flyway)
+│ │ └── V2__seed.sql # Seed data (Flyway)
│ ├── templates/
│ │ ├── layout.html # Base layout (Thymeleaf fragment)
│ │ ├── contacts/
@@ -97,13 +137,18 @@ src/
│ │ ├── styles.css
│ │ └── img/
│ │ └── spinning-circles.svg # Loading spinner
-│ └── application.yaml
-└── test/
- └── java/codes/yam/contacts/
- ├── controller/
- │ └── ContactControllerTest.java
- └── service/
- └── ContactServiceTest.java
+│ └── application.yaml # Production config (env var placeholders)
+config/
+└── application-local.yaml # Local overrides — gitignored, never committed
+├── test/
+│ └── java/codes/yam/contacts/
+│ ├── controller/
+│ │ └── ContactControllerTest.java
+│ └── service/
+│ └── ContactServiceTest.java
+scripts/
+└── start-db.sh # docker compose up + wait for Postgres readiness
+docker-compose.yml # Local Postgres service
```
### Routes
@@ -130,7 +175,8 @@ src/
| Framework | [Spring Boot](https://spring.io/projects/spring-boot) 4.0.3 |
| Templating | [Thymeleaf](https://www.thymeleaf.org) |
| Hypermedia | [htmx](https://htmx.org) + [htmx-spring-boot-thymeleaf](https://github.com/wimdeblauwe/htmx-spring-boot) |
-| Database | H2 (in-memory) + Spring Data JPA |
+| Database | PostgreSQL + Spring Data JPA |
+| Migrations | [Flyway](https://flywaydb.org) |
| Build | Maven |
## Branches
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f35bc2f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,19 @@
+services:
+ db:
+ image: postgres:17
+ environment:
+ POSTGRES_DB: contacts
+ POSTGRES_USER: contacts
+ POSTGRES_PASSWORD: contacts
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U contacts -d contacts"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+volumes:
+ postgres_data:
diff --git a/pom.xml b/pom.xml
index 100e0ba..f109dbf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -32,11 +32,19 @@
org.springframework.boot
- spring-boot-h2console
+ spring-boot-starter-data-jpa
org.springframework.boot
- spring-boot-starter-data-jpa
+ spring-boot-starter-flyway
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-database-postgresql
org.springframework.boot
@@ -72,10 +80,15 @@
runtime
true
+
+ org.postgresql
+ postgresql
+ runtime
+
com.h2database
h2
- runtime
+ test
org.projectlombok
diff --git a/scripts/start-db.sh b/scripts/start-db.sh
new file mode 100755
index 0000000..1b4434e
--- /dev/null
+++ b/scripts/start-db.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+MAX_RETRIES=30
+docker compose up -d
+i=0
+until docker compose exec -T db pg_isready -q; do
+ i=$((i + 1))
+ if [ "$i" -ge "$MAX_RETRIES" ]; then
+ echo "Postgres did not become ready after $MAX_RETRIES seconds" >&2
+ exit 1
+ fi
+ sleep 1
+done
diff --git a/src/main/java/codes/yam/contacts/SecurityConfig.java b/src/main/java/codes/yam/contacts/SecurityConfig.java
index 3f9bf65..5c2451c 100644
--- a/src/main/java/codes/yam/contacts/SecurityConfig.java
+++ b/src/main/java/codes/yam/contacts/SecurityConfig.java
@@ -5,7 +5,6 @@
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
-import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@@ -13,12 +12,9 @@
public class SecurityConfig {
@Bean
- SecurityFilterChain securityFilterChain(HttpSecurity http) {
- return http.authorizeHttpRequests(
- auth -> auth.requestMatchers("/h2-console/**").permitAll().anyRequest().authenticated())
+ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ return http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
- .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
- .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**"))
.build();
}
}
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml
deleted file mode 100644
index aac9eca..0000000
--- a/src/main/resources/application-dev.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-spring:
- security:
- user:
- name: admin
- password: admin
- h2:
- console:
- enabled: true
- thymeleaf:
- cache: false
- prefix: file:src/main/resources/templates/
- devtools:
- livereload:
- enabled: true
- restart:
- additional-paths: src/main/resources/templates/
- additional-exclude: "**/*.html"
- web:
- resources:
- static-locations:
- - file:src/main/resources/static/
- - classpath:/static/
- cache:
- period: 0
- chain:
- cache: false
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 1f12822..f0640ff 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -4,9 +4,13 @@ spring:
thymeleaf:
cache: true
jpa:
- defer-datasource-initialization: true
+ open-in-view: false
+ hibernate:
+ ddl-auto: validate
datasource:
- url: jdbc:h2:mem:contacts;DB_CLOSE_DELAY=-1
+ url: ${DATABASE_URL}
+ username: ${DB_USERNAME}
+ password: ${DB_PASSWORD}
security:
user:
name: ${APP_USERNAME}
diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql
new file mode 100644
index 0000000..ab85c24
--- /dev/null
+++ b/src/main/resources/db/migration/V1__init.sql
@@ -0,0 +1,8 @@
+CREATE TABLE contact (
+ id UUID PRIMARY KEY,
+ slug VARCHAR(255),
+ first VARCHAR(255),
+ last VARCHAR(255),
+ email VARCHAR(255),
+ phone VARCHAR(255)
+);
diff --git a/src/main/resources/db/migration/V2__seed.sql b/src/main/resources/db/migration/V2__seed.sql
new file mode 100644
index 0000000..cfb369a
--- /dev/null
+++ b/src/main/resources/db/migration/V2__seed.sql
@@ -0,0 +1,31 @@
+INSERT INTO contact (id, slug, first, last, email, phone) VALUES
+('550e8400-e29b-41d4-a716-446655440000', 'john-doe', 'John', 'Doe', 'john@example.com', '555-1234'),
+('550e8400-e29b-41d4-a716-446655440001', 'jane-doe', 'Jane', 'Doe', 'jane@example.com', '555-5678'),
+('550e8400-e29b-41d4-a716-446655440002', 'alice-smith', 'Alice', 'Smith', 'alice.smith@example.com', '555-1001'),
+('550e8400-e29b-41d4-a716-446655440003', 'bob-johnson', 'Bob', 'Johnson', 'bob.johnson@example.com', '555-1002'),
+('550e8400-e29b-41d4-a716-446655440004', 'carol-williams', 'Carol', 'Williams', 'carol.williams@example.com', '555-1003'),
+('550e8400-e29b-41d4-a716-446655440005', 'david-brown', 'David', 'Brown', 'david.brown@example.com', '555-1004'),
+('550e8400-e29b-41d4-a716-446655440006', 'eve-jones', 'Eve', 'Jones', 'eve.jones@example.com', '555-1005'),
+('550e8400-e29b-41d4-a716-446655440007', 'frank-garcia', 'Frank', 'Garcia', 'frank.garcia@example.com', '555-1006'),
+('550e8400-e29b-41d4-a716-446655440008', 'grace-miller', 'Grace', 'Miller', 'grace.miller@example.com', '555-1007'),
+('550e8400-e29b-41d4-a716-446655440009', 'henry-davis', 'Henry', 'Davis', 'henry.davis@example.com', '555-1008'),
+('550e8400-e29b-41d4-a716-44665544000a', 'iris-martinez', 'Iris', 'Martinez', 'iris.martinez@example.com', '555-1009'),
+('550e8400-e29b-41d4-a716-44665544000b', 'jack-wilson', 'Jack', 'Wilson', 'jack.wilson@example.com', '555-1010'),
+('550e8400-e29b-41d4-a716-44665544000c', 'karen-anderson', 'Karen', 'Anderson', 'karen.anderson@example.com', '555-1011'),
+('550e8400-e29b-41d4-a716-44665544000d', 'leo-thomas', 'Leo', 'Thomas', 'leo.thomas@example.com', '555-1012'),
+('550e8400-e29b-41d4-a716-44665544000e', 'mia-taylor', 'Mia', 'Taylor', 'mia.taylor@example.com', '555-1013'),
+('550e8400-e29b-41d4-a716-44665544000f', 'noah-hernandez', 'Noah', 'Hernandez', 'noah.hernandez@example.com', '555-1014'),
+('550e8400-e29b-41d4-a716-446655440010', 'olivia-moore', 'Olivia', 'Moore', 'olivia.moore@example.com', '555-1015'),
+('550e8400-e29b-41d4-a716-446655440011', 'paul-jackson', 'Paul', 'Jackson', 'paul.jackson@example.com', '555-1016'),
+('550e8400-e29b-41d4-a716-446655440012', 'quinn-martin', 'Quinn', 'Martin', 'quinn.martin@example.com', '555-1017'),
+('550e8400-e29b-41d4-a716-446655440013', 'rachel-lee', 'Rachel', 'Lee', 'rachel.lee@example.com', '555-1018'),
+('550e8400-e29b-41d4-a716-446655440014', 'sam-perez', 'Sam', 'Perez', 'sam.perez@example.com', '555-1019'),
+('550e8400-e29b-41d4-a716-446655440015', 'tina-white', 'Tina', 'White', 'tina.white@example.com', '555-1020'),
+('550e8400-e29b-41d4-a716-446655440016', 'ursula-harris', 'Ursula', 'Harris', 'ursula.harris@example.com', '555-1021'),
+('550e8400-e29b-41d4-a716-446655440017', 'victor-clark', 'Victor', 'Clark', 'victor.clark@example.com', '555-1022'),
+('550e8400-e29b-41d4-a716-446655440018', 'wendy-lewis', 'Wendy', 'Lewis', 'wendy.lewis@example.com', '555-1023'),
+('550e8400-e29b-41d4-a716-446655440019', 'xavier-robinson', 'Xavier', 'Robinson', 'xavier.robinson@example.com', '555-1024'),
+('550e8400-e29b-41d4-a716-44665544001a', 'yara-walker', 'Yara', 'Walker', 'yara.walker@example.com', '555-1025'),
+('550e8400-e29b-41d4-a716-44665544001b', 'zane-hall', 'Zane', 'Hall', 'zane.hall@example.com', '555-1026'),
+('550e8400-e29b-41d4-a716-44665544001c', 'amber-allen', 'Amber', 'Allen', 'amber.allen@example.com', '555-1027'),
+('550e8400-e29b-41d4-a716-44665544001d', 'brian-young', 'Brian', 'Young', 'brian.young@example.com', '555-1028');
diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml
new file mode 100644
index 0000000..35deefc
--- /dev/null
+++ b/src/test/resources/application.yaml
@@ -0,0 +1,16 @@
+spring:
+ flyway:
+ enabled: false
+ datasource:
+ url: jdbc:h2:mem:contacts;DB_CLOSE_DELAY=-1
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ defer-datasource-initialization: true
+ sql:
+ init:
+ mode: always
+ security:
+ user:
+ name: admin
+ password: admin