diff --git a/.env.example b/.env.example
index 604d10e9..fbe10383 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,6 @@
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
-DB_URL=jdbc:mariadb://mariadb:3306/jobtracker
+DB_URL=jdbc:postgresql://localhost:5432/jobtracker
DB_NAME=jobtracker
DB_USERNAME=jobtracker
DB_PASSWORD=sua_senha_forte_aqui
diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml
index 6ce6a116..3b3e59c9 100644
--- a/.github/workflows/load-test.yml
+++ b/.github/workflows/load-test.yml
@@ -21,28 +21,27 @@ jobs:
- name: Build API image
run: docker build -t job-tracker:loadtest .
- - name: Start MariaDB (test)
+ - name: Start PostgreSQL (test)
run: |
docker run -d \
--name loadtest-db \
--network loadtest-net \
- -e MARIADB_ROOT_PASSWORD=root \
- -e MARIADB_DATABASE=jobtracker \
- -e MARIADB_USER=jobtracker \
- -e MARIADB_PASSWORD=jobtracker \
- mariadb:11
+ -e POSTGRES_DB=jobtracker \
+ -e POSTGRES_USER=jobtracker \
+ -e POSTGRES_PASSWORD=jobtracker \
+ postgres:17
- - name: Wait for MariaDB
+ - name: Wait for PostgreSQL
run: |
for i in {1..30}; do
- if docker exec loadtest-db mariadb-admin ping -h 127.0.0.1 -uroot -proot --silent; then
- echo "MariaDB is ready"
+ if docker exec loadtest-db pg_isready -h 127.0.0.1 -U jobtracker -d jobtracker; then
+ echo "PostgreSQL is ready"
exit 0
fi
- echo "Waiting for MariaDB... ($i/30)"
+ echo "Waiting for PostgreSQL... ($i/30)"
sleep 2
done
- echo "MariaDB did not become ready in time"
+ echo "PostgreSQL did not become ready in time"
exit 1
- name: Start API with server-like limits (1 CPU / 4GB)
@@ -55,7 +54,7 @@ jobs:
--memory-swap=4g \
-p 8080:8080 \
-p 8081:8081 \
- -e DB_URL="jdbc:mariadb://loadtest-db:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true" \
+ -e DB_URL="jdbc:postgresql://loadtest-db:5432/jobtracker" \
-e DB_USERNAME="jobtracker" \
-e DB_PASSWORD="jobtracker" \
-e JWT_SECRET="LoadTestSecretKeyThatIsAtLeast256BitsLongAndOnlyForGithubActions" \
diff --git a/README.md b/README.md
index c7cca46c..e80e0c0f 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[Frontend - React-JobApplyTracker](https://github.com/vitorhugo-java/React-JobApplyTracker)
-A production-ready Spring Boot REST API for tracking job applications, built with Java 21, Spring Security JWT authentication, MariaDB, and comprehensive test coverage. Exposes all domain services via a **Model Context Protocol (MCP) server** so AI assistants (Claude Desktop, Claude.ai, and any MCP-compatible client) can manage applications on behalf of authenticated users.
+A production-ready Spring Boot REST API for tracking job applications, built with Java 21, Spring Security JWT authentication, PostgreSQL, and comprehensive test coverage. Exposes all domain services via a **Model Context Protocol (MCP) server** so AI assistants (Claude Desktop, Claude.ai, and any MCP-compatible client) can manage applications on behalf of authenticated users.
> **Alterações no MCP podem exigir atualização manual dos clientes.**
>
@@ -27,7 +27,7 @@ A production-ready Spring Boot REST API for tracking job applications, built wit
- **Spring Security** with stateless JWT authentication + role-based authorization (`USER`, `BETA`, `ADMIN`)
- **JWT + Refresh Tokens** (access: 15 min, refresh: 7 days with rotation)
- **Resilience4j Rate Limiting** on auth endpoints
-- **MariaDB** (production) / **Testcontainers** (tests)
+- **PostgreSQL** (production and local development) / **H2** (unit tests)
- **Flyway** for DB migrations
- **JUnit 5 + Mockito** (unit tests)
- **Testcontainers + MockMvc** (integration tests)
@@ -331,10 +331,10 @@ By default this compose file pulls image: `ghcr.io/vitorhugo-java/springboot-job
The API will be available at `http://localhost:8080`.
-### With Maven (requires a running MariaDB)
+### With Maven (requires a running PostgreSQL)
```bash
-export DB_URL=jdbc:mariadb://localhost:3306/jobtracker?createDatabaseIfNotExist=true
+export DB_URL=jdbc:postgresql://localhost:5432/jobtracker
export DB_USERNAME=jobtracker
export DB_PASSWORD=jobtracker
export JWT_SECRET=your-secret-key-at-least-256-bits-long
@@ -726,7 +726,7 @@ mvn test -Dtest="com.jobtracker.integration.*"
mvn test -Dtest="com.jobtracker.e2e.*"
```
-> **Note:** Integration and E2E tests require Docker to be running (Testcontainers pulls a MariaDB image automatically).
+> **Note:** Integration and E2E tests require Docker to be running.
## Seed Fake Data
@@ -764,7 +764,7 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use
| Variable | Default | Description |
|----------|---------|-------------|
-| `DB_URL` | `jdbc:mariadb://localhost:3306/jobtracker` | JDBC URL |
+| `DB_URL` | `jdbc:postgresql://localhost:5432/jobtracker` | JDBC URL |
| `DB_USERNAME` | `jobtracker` | DB username |
| `DB_PASSWORD` | `jobtracker` | DB password |
| `JWT_SECRET` | *(dev default)* | JWT signing secret (min 256 bits) |
@@ -865,3 +865,18 @@ funnel status, including moving between interview stages. Yes adds one to the fo
without adding; closing the popup cancels saving. The total can also be corrected manually in the form.
Dashboard and MCP analytics sum stored completed counts. Analytics `interviewRate` is the percentage
of applications with at least one completed interview, rather than interviews divided by applications.
+
+## PostgreSQL migration and rollback
+
+The backend now uses PostgreSQL only. A fresh database is created by the PostgreSQL Flyway location (`db/migration/postgresql`) and validated by Hibernate. The original MariaDB migrations remain unchanged; the PostgreSQL location carries their compatible schema history and adds PostgreSQL full-text search in V36. MariaDB and PostgreSQL UUID representation differ: PostgreSQL uses native `uuid` columns.
+
+For an existing production database, perform the cutover in an isolated environment first. Do not point the application at the source MariaDB database during this process.
+
+1. Take a MariaDB logical backup and prove that it restores to a separate MariaDB instance. Record row counts for every table and preserve the backup outside the migration host.
+2. Create an empty PostgreSQL database and import a restored copy of MariaDB with `pgloader`. Use a reviewed pgloader cast rule for `BINARY(16)` to native UUID (`binary-to-uuid`), `TINYINT(1)` to boolean, and MariaDB temporal types to PostgreSQL timestamps. Exclude `flyway_schema_history` from the import; the PostgreSQL Flyway history is initialized separately. Keep the MariaDB instance untouched.
+3. Compare table/row counts, UUID samples, foreign-key integrity, null/default behavior, OAuth authorization payloads, and timestamp samples between source and target. Reconcile identity sequences with each table's maximum imported ID.
+4. In the imported target only, initialize Flyway's PostgreSQL history at version 35 (`flyway baseline -baselineVersion=35`), then start the application with the PostgreSQL `DB_URL`. Flyway applies V36 and Hibernate validates the schema. Run authenticated REST, OAuth, MCP, assistant search, export, and scheduled-job smoke checks.
+5. Repeat the import and checks from a clean backup as a dry run. Schedule a maintenance window, stop writes to MariaDB, take a final backup, import the final delta/full snapshot, reconcile counts and sequences, then switch `DB_URL` to PostgreSQL.
+6. Keep MariaDB and its backups intact through the agreed observation period. If any validation fails, stop the PostgreSQL application and restore the previous MariaDB `DB_URL`; do not reverse-import partial PostgreSQL writes into MariaDB. Reconcile any writes made during the observation period before reopening the old database.
+
+The repository does not contain production credentials or data, so the dry run and live cutover must be performed by the operator against isolated/production infrastructure. Keep the old instance available until the new target has been explicitly accepted.
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 982129a4..1e605f2b 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -3,7 +3,7 @@ services:
image: ghcr.io/vitorhugo-dotnet/jobapplytracker-backend:latest
environment:
SPRING_PROFILES_ACTIVE: prod
- DB_URL: "jdbc:mariadb://mariadb:3306/${DB_NAME}?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true"
+ DB_URL: "jdbc:postgresql://postgres:5432/${DB_NAME}"
deploy:
resources:
limits:
diff --git a/docker-compose.yml b/docker-compose.yml
index adb3d9da..2d4f323b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,19 @@
services:
+ postgres:
+ image: postgres:17
+ environment:
+ POSTGRES_DB: ${DB_NAME:-jobtracker}
+ POSTGRES_USER: ${DB_USERNAME:-jobtracker}
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-jobtracker}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-jobtracker} -d ${DB_NAME:-jobtracker}"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
app:
build: .
container_name: job-tracker-app
@@ -8,7 +23,7 @@ services:
- .env
environment:
SPRING_PROFILES_ACTIVE: dev
- DB_URL: ${DB_URL}
+ DB_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-jobtracker}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
@@ -38,3 +53,6 @@ services:
timeout: 10s
retries: 5
start_period: 60s
+
+volumes:
+ postgres_data:
diff --git a/pom.xml b/pom.xml
index 9c8b3083..214becd7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -75,13 +75,8 @@
- org.mariadb.jdbc
- mariadb-java-client
- runtime
-
-
- com.mysql
- mysql-connector-j
+ org.postgresql
+ postgresql
runtime
@@ -92,7 +87,7 @@
org.flywaydb
- flyway-mysql
+ flyway-database-postgresql
@@ -293,7 +288,7 @@
org.testcontainers
- mariadb
+ postgresql
${testcontainers.version}
test
diff --git a/src/main/java/com/jobtracker/entity/Achievement.java b/src/main/java/com/jobtracker/entity/Achievement.java
index 08c15581..5265c232 100644
--- a/src/main/java/com/jobtracker/entity/Achievement.java
+++ b/src/main/java/com/jobtracker/entity/Achievement.java
@@ -18,7 +18,7 @@ public class Achievement {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@Column(name = "code", nullable = false, unique = true, length = 100)
diff --git a/src/main/java/com/jobtracker/entity/ExportExecution.java b/src/main/java/com/jobtracker/entity/ExportExecution.java
index 6bd1e3c8..bc7625b4 100644
--- a/src/main/java/com/jobtracker/entity/ExportExecution.java
+++ b/src/main/java/com/jobtracker/entity/ExportExecution.java
@@ -27,7 +27,7 @@ public class ExportExecution {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
diff --git a/src/main/java/com/jobtracker/entity/ExportSchedule.java b/src/main/java/com/jobtracker/entity/ExportSchedule.java
index 0b681bcd..f69dd132 100644
--- a/src/main/java/com/jobtracker/entity/ExportSchedule.java
+++ b/src/main/java/com/jobtracker/entity/ExportSchedule.java
@@ -32,7 +32,7 @@ public class ExportSchedule {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveBaseInformation.java b/src/main/java/com/jobtracker/entity/GoogleDriveBaseInformation.java
index 4c95f37d..806326de 100644
--- a/src/main/java/com/jobtracker/entity/GoogleDriveBaseInformation.java
+++ b/src/main/java/com/jobtracker/entity/GoogleDriveBaseInformation.java
@@ -27,7 +27,7 @@ public class GoogleDriveBaseInformation {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java
index eb6dfa0b..058e39df 100644
--- a/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java
+++ b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java
@@ -18,7 +18,7 @@ public class GoogleDriveBaseResume {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java b/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java
index 8db98565..995942d4 100644
--- a/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java
+++ b/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java
@@ -19,7 +19,7 @@ public class GoogleDriveConnection {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
/** Mirrors the ON DELETE CASCADE the migration declares, so deleting a user drops the connection. */
diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java b/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java
index 0013852c..410898e9 100644
--- a/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java
+++ b/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java
@@ -16,7 +16,7 @@ public class GoogleDriveOAuthState {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/InterviewEvent.java b/src/main/java/com/jobtracker/entity/InterviewEvent.java
index 5e6008f3..a98d3dcf 100644
--- a/src/main/java/com/jobtracker/entity/InterviewEvent.java
+++ b/src/main/java/com/jobtracker/entity/InterviewEvent.java
@@ -23,7 +23,7 @@ public class InterviewEvent {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/JobApplication.java b/src/main/java/com/jobtracker/entity/JobApplication.java
index 3dfc38d6..517ee241 100644
--- a/src/main/java/com/jobtracker/entity/JobApplication.java
+++ b/src/main/java/com/jobtracker/entity/JobApplication.java
@@ -18,7 +18,7 @@ public class JobApplication {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@Column(name = "vacancy_name", length = 255)
@@ -105,7 +105,7 @@ public class JobApplication {
@Column(name = "drive_resume_generated_at")
private LocalDateTime driveResumeGeneratedAt;
- @Column(name = "drive_resume_template_id", columnDefinition = "BINARY(16)")
+ @Column(name = "drive_resume_template_id")
private UUID driveResumeTemplateId;
@Column(name = "drive_resume_pdf_file_id", length = 255)
diff --git a/src/main/java/com/jobtracker/entity/PasswordResetToken.java b/src/main/java/com/jobtracker/entity/PasswordResetToken.java
index 3464f6b4..fbc129e4 100644
--- a/src/main/java/com/jobtracker/entity/PasswordResetToken.java
+++ b/src/main/java/com/jobtracker/entity/PasswordResetToken.java
@@ -15,7 +15,7 @@ public class PasswordResetToken {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@Column(nullable = false, unique = true, length = 512)
diff --git a/src/main/java/com/jobtracker/entity/RefreshToken.java b/src/main/java/com/jobtracker/entity/RefreshToken.java
index 19cf4e98..a1cfd1dd 100644
--- a/src/main/java/com/jobtracker/entity/RefreshToken.java
+++ b/src/main/java/com/jobtracker/entity/RefreshToken.java
@@ -15,7 +15,7 @@ public class RefreshToken {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@Column(nullable = false, unique = true, length = 512)
diff --git a/src/main/java/com/jobtracker/entity/User.java b/src/main/java/com/jobtracker/entity/User.java
index 768d1c34..1b057216 100644
--- a/src/main/java/com/jobtracker/entity/User.java
+++ b/src/main/java/com/jobtracker/entity/User.java
@@ -17,7 +17,7 @@ public class User {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@Column(nullable = false, length = 150)
@@ -32,7 +32,7 @@ public class User {
@Column(name = "reminder_time", nullable = false)
private LocalTime reminderTime;
- @Column(name = "privacy_policy_accepted", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
+ @Column(name = "privacy_policy_accepted", nullable = false)
private boolean privacyPolicyAccepted;
@Column(name = "privacy_policy_accepted_at")
diff --git a/src/main/java/com/jobtracker/entity/UserAchievement.java b/src/main/java/com/jobtracker/entity/UserAchievement.java
index d193fb2b..471b64e5 100644
--- a/src/main/java/com/jobtracker/entity/UserAchievement.java
+++ b/src/main/java/com/jobtracker/entity/UserAchievement.java
@@ -21,7 +21,7 @@ public class UserAchievement {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/UserGamification.java b/src/main/java/com/jobtracker/entity/UserGamification.java
index 57e593e3..57c75fb7 100644
--- a/src/main/java/com/jobtracker/entity/UserGamification.java
+++ b/src/main/java/com/jobtracker/entity/UserGamification.java
@@ -19,7 +19,7 @@
public class UserGamification {
@Id
- @Column(name = "user_id", columnDefinition = "BINARY(16)", nullable = false)
+ @Column(name = "user_id", nullable = false)
private UUID userId;
@OneToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/UserInterviewMetrics.java b/src/main/java/com/jobtracker/entity/UserInterviewMetrics.java
index b73c9a54..2c413907 100644
--- a/src/main/java/com/jobtracker/entity/UserInterviewMetrics.java
+++ b/src/main/java/com/jobtracker/entity/UserInterviewMetrics.java
@@ -19,7 +19,7 @@
public class UserInterviewMetrics {
@Id
- @Column(name = "user_id", columnDefinition = "BINARY(16)", nullable = false)
+ @Column(name = "user_id", nullable = false)
private UUID userId;
@OneToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java b/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
index c948f300..4df59e2c 100644
--- a/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
+++ b/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
@@ -16,7 +16,7 @@ public class WebAuthnChallenge {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
diff --git a/src/main/java/com/jobtracker/entity/WebAuthnCredential.java b/src/main/java/com/jobtracker/entity/WebAuthnCredential.java
index 019ea4d3..91bc8584 100644
--- a/src/main/java/com/jobtracker/entity/WebAuthnCredential.java
+++ b/src/main/java/com/jobtracker/entity/WebAuthnCredential.java
@@ -15,7 +15,7 @@ public class WebAuthnCredential {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
- @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ @Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
diff --git a/src/main/java/com/jobtracker/repository/assistant/AssistantApplicationQueryRepository.java b/src/main/java/com/jobtracker/repository/assistant/AssistantApplicationQueryRepository.java
index 686e3598..74cf420d 100644
--- a/src/main/java/com/jobtracker/repository/assistant/AssistantApplicationQueryRepository.java
+++ b/src/main/java/com/jobtracker/repository/assistant/AssistantApplicationQueryRepository.java
@@ -5,7 +5,6 @@
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
-import java.nio.ByteBuffer;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@@ -17,7 +16,8 @@ public class AssistantApplicationQueryRepository {
platform, interview_count, next_step_date_time,
LEFT(note, 500) AS note_excerpt, archived
""";
- private static final String MATCH = "MATCH(vacancy_name, organization, recruiter_name, note) AGAINST (:query IN NATURAL LANGUAGE MODE)";
+ private static final String MATCH = "assistant_search_vector @@ websearch_to_tsquery('simple', :query)";
+ private static final String RANK = "ts_rank(assistant_search_vector, websearch_to_tsquery('simple', :query))";
private final NamedParameterJdbcTemplate jdbc;
@@ -34,7 +34,7 @@ public List search(UUID userId, String query, String o
if (!hasQuery || shortTokenFallback) {
sql.append(", NULL AS relevance FROM job_applications WHERE user_id = :userId ");
} else {
- sql.append(", ").append(MATCH).append(" AS relevance FROM job_applications WHERE user_id = :userId AND ")
+ sql.append(", ").append(RANK).append(" AS relevance FROM job_applications WHERE user_id = :userId AND ")
.append(MATCH).append(" ");
}
MapSqlParameterSource p = baseParameters(userId, organization, status, platform, from, to, archived)
@@ -43,14 +43,14 @@ public List search(UUID userId, String query, String o
.addValue("limit", limit);
appendFilters(sql, organization, status, platform, from, to);
if (hasQuery && shortTokenFallback) {
- sql.append(" AND (vacancy_name LIKE :likeQuery ESCAPE '\\\\' OR organization LIKE :likeQuery ESCAPE '\\\\' ")
- .append("OR recruiter_name LIKE :likeQuery ESCAPE '\\\\' OR note LIKE :likeQuery ESCAPE '\\\\') ");
+ sql.append(" AND (vacancy_name ILIKE :likeQuery ESCAPE E'\\\\' OR organization ILIKE :likeQuery ESCAPE E'\\\\' ")
+ .append("OR recruiter_name ILIKE :likeQuery ESCAPE E'\\\\' OR note ILIKE :likeQuery ESCAPE E'\\\\') ");
}
sql.append(!hasQuery || shortTokenFallback
? " ORDER BY application_date DESC, created_at DESC, id ASC LIMIT :limit"
: " ORDER BY relevance DESC, application_date DESC, id ASC LIMIT :limit");
return jdbc.query(sql.toString(), p, (rs, row) -> new AssistantApplicationView(
- fromBytes(rs.getBytes("id")), rs.getString("vacancy_name"), rs.getString("organization"),
+ rs.getObject("id", UUID.class), rs.getString("vacancy_name"), rs.getString("organization"),
rs.getString("recruiter_name"), rs.getString("status"),
rs.getObject("application_date", LocalDate.class), rs.getString("platform"),
rs.getInt("interview_count"), rs.getTimestamp("next_step_date_time") == null ? null :
@@ -68,8 +68,8 @@ public ApplicationStats stats(UUID userId, String query, String organization, St
appendFilters(sql, organization, status, platform, from, to);
if (query != null && !query.isBlank()) {
if (shortTokenFallback) {
- sql.append(" AND (vacancy_name LIKE :likeQuery ESCAPE '\\\\' OR organization LIKE :likeQuery ESCAPE '\\\\' ")
- .append("OR recruiter_name LIKE :likeQuery ESCAPE '\\\\' OR note LIKE :likeQuery ESCAPE '\\\\') ");
+ sql.append(" AND (vacancy_name ILIKE :likeQuery ESCAPE E'\\\\' OR organization ILIKE :likeQuery ESCAPE E'\\\\' ")
+ .append("OR recruiter_name ILIKE :likeQuery ESCAPE E'\\\\' OR note ILIKE :likeQuery ESCAPE E'\\\\') ");
} else {
sql.append(" AND ").append(MATCH);
}
@@ -88,7 +88,7 @@ public List timeline(UUID userId, String organization,
sql.append(oldestFirst ? " ORDER BY application_date ASC, created_at ASC, id ASC" :
" ORDER BY application_date DESC, created_at DESC, id ASC").append(" LIMIT :limit");
return jdbc.query(sql.toString(), p, (rs, row) -> new AssistantApplicationView(
- fromBytes(rs.getBytes("id")), rs.getString("vacancy_name"), rs.getString("organization"),
+ rs.getObject("id", UUID.class), rs.getString("vacancy_name"), rs.getString("organization"),
rs.getString("recruiter_name"), rs.getString("status"),
rs.getObject("application_date", LocalDate.class), rs.getString("platform"),
rs.getInt("interview_count"), rs.getTimestamp("next_step_date_time") == null ? null :
@@ -99,7 +99,7 @@ public List timeline(UUID userId, String organization,
private MapSqlParameterSource baseParameters(UUID userId, String organization, String status,
String platform, LocalDate from, LocalDate to, boolean archived) {
return new MapSqlParameterSource()
- .addValue("userId", toBytes(userId)).addValue("archived", archived)
+ .addValue("userId", userId).addValue("archived", archived)
.addValue("organization", organization).addValue("status", status)
.addValue("platform", platform).addValue("from", from).addValue("to", to);
}
@@ -118,14 +118,5 @@ private String escapeLike(String value) {
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
- private byte[] toBytes(UUID id) {
- return ByteBuffer.allocate(16).putLong(id.getMostSignificantBits()).putLong(id.getLeastSignificantBits()).array();
- }
-
- private UUID fromBytes(byte[] bytes) {
- ByteBuffer b = ByteBuffer.wrap(bytes);
- return new UUID(b.getLong(), b.getLong());
- }
-
public record ApplicationStats(long applications, long interviews) {}
}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
new file mode 100644
index 00000000..03b48d64
--- /dev/null
+++ b/src/main/resources/application-dev.yml
@@ -0,0 +1,18 @@
+spring:
+ datasource:
+ url: ${DB_URL:jdbc:postgresql://localhost:5432/jobtracker}
+ username: ${DB_USERNAME:jobtracker}
+ password: ${DB_PASSWORD:jobtracker}
+ driver-class-name: org.postgresql.Driver
+ jpa:
+ open-in-view: false
+ hibernate:
+ ddl-auto: validate
+ properties:
+ hibernate:
+ type.preferred_uuid_jdbc_type: UUID
+ flyway:
+ enabled: true
+ locations: classpath:db/migration/postgresql
+ baseline-on-migrate: true
+ baseline-version: 35
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5c3d0b77..050e3cfd 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -60,10 +60,10 @@ spring:
application:
name: job-tracker
datasource:
- url: ${DB_URL:jdbc:mariadb://localhost:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true}
+ url: ${DB_URL:jdbc:postgresql://localhost:5432/jobtracker}
username: ${DB_USERNAME:jobtracker}
password: ${DB_PASSWORD:jobtracker}
- driver-class-name: org.mariadb.jdbc.Driver
+ driver-class-name: org.postgresql.Driver
jpa:
open-in-view: false
hibernate:
@@ -71,13 +71,13 @@ spring:
show-sql: false
properties:
hibernate:
- dialect: org.hibernate.dialect.MariaDBDialect
- type.preferred_uuid_jdbc_type: BINARY
+ type.preferred_uuid_jdbc_type: UUID
format_sql: false
flyway:
enabled: true
- locations: classpath:db/migration
baseline-on-migrate: true
+ locations: classpath:db/migration/postgresql
+ baseline-version: 35
mail:
host: ${MAIL_HOST:localhost}
port: ${MAIL_PORT:1025}
diff --git a/src/main/resources/db/migration/postgresql/V10__track_application_gamification_awards.sql b/src/main/resources/db/migration/postgresql/V10__track_application_gamification_awards.sql
new file mode 100644
index 00000000..a1ab3609
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V10__track_application_gamification_awards.sql
@@ -0,0 +1,7 @@
+ALTER TABLE job_applications
+ ADD COLUMN application_created_xp_awarded BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN recruiter_dm_xp_awarded BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN interview_progress_xp_awarded BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN note_added_xp_awarded BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN offer_won_xp_awarded BOOLEAN NOT NULL DEFAULT FALSE;
+
diff --git a/src/main/resources/db/migration/postgresql/V11__add_google_drive_integration.sql b/src/main/resources/db/migration/postgresql/V11__add_google_drive_integration.sql
new file mode 100644
index 00000000..72c260c4
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V11__add_google_drive_integration.sql
@@ -0,0 +1,51 @@
+CREATE TABLE google_drive_connections (
+ id UUID NOT NULL,
+ user_id UUID NOT NULL,
+ google_account_id VARCHAR(255) NOT NULL,
+ google_email VARCHAR(255) NOT NULL,
+ google_display_name VARCHAR(255) NULL,
+ access_token VARCHAR(4096) NOT NULL,
+ refresh_token VARCHAR(4096) NOT NULL,
+ access_token_expires_at TIMESTAMP NOT NULL,
+ granted_scopes VARCHAR(2048) NOT NULL,
+ root_folder_id VARCHAR(255) NULL,
+ root_folder_name VARCHAR(255) NULL,
+ connected_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_google_drive_connections PRIMARY KEY (id),
+ CONSTRAINT uk_google_drive_connections_user UNIQUE (user_id),
+ CONSTRAINT fk_google_drive_connections_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_gdrive_connection_google_account ON google_drive_connections (google_account_id);
+
+CREATE TABLE google_drive_base_resumes (
+ id UUID NOT NULL,
+ connection_id UUID NOT NULL,
+ google_file_id VARCHAR(255) NOT NULL,
+ document_name VARCHAR(255) NOT NULL,
+ web_view_link VARCHAR(2048) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_google_drive_base_resumes PRIMARY KEY (id),
+ CONSTRAINT uk_google_drive_base_resumes_connection_file UNIQUE (connection_id, google_file_id),
+ CONSTRAINT fk_google_drive_base_resumes_connection FOREIGN KEY (connection_id) REFERENCES google_drive_connections (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_gdrive_base_resume_connection ON google_drive_base_resumes (connection_id);
+
+CREATE TABLE google_drive_oauth_states (
+ id UUID NOT NULL,
+ user_id UUID NOT NULL,
+ state_token VARCHAR(255) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_google_drive_oauth_states PRIMARY KEY (id),
+ CONSTRAINT uk_google_drive_oauth_states_state UNIQUE (state_token),
+ CONSTRAINT fk_google_drive_oauth_states_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_gdrive_oauth_states_user ON google_drive_oauth_states (user_id);
+CREATE INDEX idx_gdrive_oauth_states_expires ON google_drive_oauth_states (expires_at);
+
diff --git a/src/main/resources/db/migration/postgresql/V12__add_drive_vacancy_folder_id_to_applications.sql b/src/main/resources/db/migration/postgresql/V12__add_drive_vacancy_folder_id_to_applications.sql
new file mode 100644
index 00000000..f0727791
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V12__add_drive_vacancy_folder_id_to_applications.sql
@@ -0,0 +1,3 @@
+ALTER TABLE job_applications
+ ADD COLUMN drive_vacancy_folder_id VARCHAR(255) NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V13__add_roles_and_user_authorization.sql b/src/main/resources/db/migration/postgresql/V13__add_roles_and_user_authorization.sql
new file mode 100644
index 00000000..288799de
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V13__add_roles_and_user_authorization.sql
@@ -0,0 +1,26 @@
+CREATE TABLE IF NOT EXISTS roles (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ name VARCHAR(20) NOT NULL,
+ CONSTRAINT uk_roles_name UNIQUE (name)
+);
+
+CREATE TABLE IF NOT EXISTS user_roles (
+ user_id UUID NOT NULL,
+ role_id BIGINT NOT NULL,
+ PRIMARY KEY (user_id, role_id),
+ CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE
+);
+
+INSERT INTO roles (name) VALUES ('USER') ON CONFLICT (name) DO NOTHING;
+INSERT INTO roles (name) VALUES ('BETA') ON CONFLICT (name) DO NOTHING;
+INSERT INTO roles (name) VALUES ('ADMIN') ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO user_roles (user_id, role_id)
+SELECT u.id, r.id
+FROM users u
+ JOIN roles r ON r.name = 'USER'
+WHERE NOT EXISTS (
+ SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role_id = r.id
+);
+
diff --git a/src/main/resources/db/migration/postgresql/V14__track_generated_drive_resume_on_applications.sql b/src/main/resources/db/migration/postgresql/V14__track_generated_drive_resume_on_applications.sql
new file mode 100644
index 00000000..928d6402
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V14__track_generated_drive_resume_on_applications.sql
@@ -0,0 +1,6 @@
+ALTER TABLE job_applications
+ ADD COLUMN drive_resume_file_id VARCHAR(255) NULL,
+ ADD COLUMN drive_resume_file_name VARCHAR(255) NULL,
+ ADD COLUMN drive_resume_document_url VARCHAR(2048) NULL,
+ ADD COLUMN drive_resume_generated_at TIMESTAMP NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V15__add_interview_metrics.sql b/src/main/resources/db/migration/postgresql/V15__add_interview_metrics.sql
new file mode 100644
index 00000000..6478a62f
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V15__add_interview_metrics.sql
@@ -0,0 +1,24 @@
+CREATE TABLE user_interview_metrics (
+ user_id UUID NOT NULL,
+ interview_count BIGINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_user_interview_metrics PRIMARY KEY (user_id),
+ CONSTRAINT fk_user_interview_metrics_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE TABLE interview_events (
+ id UUID NOT NULL,
+ user_id UUID NOT NULL,
+ application_id UUID NOT NULL,
+ old_status VARCHAR(100) NULL,
+ new_status VARCHAR(100) NOT NULL,
+ occurred_at TIMESTAMP NOT NULL,
+ CONSTRAINT pk_interview_events PRIMARY KEY (id),
+ CONSTRAINT fk_interview_events_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ CONSTRAINT fk_interview_events_application FOREIGN KEY (application_id) REFERENCES job_applications (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_interview_events_user_id ON interview_events (user_id);
+CREATE INDEX idx_interview_events_application_id ON interview_events (application_id);
+
diff --git a/src/main/resources/db/migration/postgresql/V16__add_webauthn_passkeys.sql b/src/main/resources/db/migration/postgresql/V16__add_webauthn_passkeys.sql
new file mode 100644
index 00000000..7a1930fe
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V16__add_webauthn_passkeys.sql
@@ -0,0 +1,36 @@
+CREATE TABLE IF NOT EXISTS webauthn_credentials (
+ id UUID NOT NULL PRIMARY KEY,
+ user_id UUID NOT NULL,
+ credential_id VARCHAR(512) NOT NULL,
+ public_key_cose TEXT NOT NULL,
+ sign_count BIGINT NOT NULL DEFAULT 0,
+ transports VARCHAR(255),
+ user_handle VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ CONSTRAINT uk_webauthn_credential_id UNIQUE (credential_id),
+
+
+ CONSTRAINT fk_webauthn_credentials_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS webauthn_challenges (
+ id UUID NOT NULL PRIMARY KEY,
+ user_id UUID,
+ type VARCHAR(32) NOT NULL,
+ request_json TEXT NOT NULL,
+ challenge VARCHAR(512) NOT NULL,
+ used BOOLEAN NOT NULL DEFAULT FALSE,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+
+
+ CONSTRAINT fk_webauthn_challenges_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_webauthn_credentials_user_id ON webauthn_credentials (user_id);
+CREATE INDEX idx_webauthn_credentials_credential_id ON webauthn_credentials (credential_id);
+CREATE INDEX idx_webauthn_challenges_user_id ON webauthn_challenges (user_id);
+CREATE INDEX idx_webauthn_challenges_expires_at ON webauthn_challenges (expires_at);
+
diff --git a/src/main/resources/db/migration/postgresql/V17__add_language_and_template_to_base_resumes.sql b/src/main/resources/db/migration/postgresql/V17__add_language_and_template_to_base_resumes.sql
new file mode 100644
index 00000000..f71505b7
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V17__add_language_and_template_to_base_resumes.sql
@@ -0,0 +1,5 @@
+ALTER TABLE google_drive_base_resumes
+ -- ISO 639-1 (e.g. EN, PT) or locale variant (e.g. en-US); max 10 chars
+ ADD COLUMN language VARCHAR(10) NULL,
+ ADD COLUMN is_template BOOLEAN NOT NULL DEFAULT FALSE;
+
diff --git a/src/main/resources/db/migration/postgresql/V18__add_gpt_oauth_authorization_codes.sql b/src/main/resources/db/migration/postgresql/V18__add_gpt_oauth_authorization_codes.sql
new file mode 100644
index 00000000..1697337f
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V18__add_gpt_oauth_authorization_codes.sql
@@ -0,0 +1,21 @@
+CREATE TABLE IF NOT EXISTS gpt_oauth_authorization_codes (
+ id UUID NOT NULL PRIMARY KEY,
+ user_id UUID NOT NULL,
+ client_id VARCHAR(255) NOT NULL,
+ redirect_uri VARCHAR(500) NOT NULL,
+ scope_value VARCHAR(1000) NOT NULL,
+ code_hash VARCHAR(128) NOT NULL,
+ code_challenge VARCHAR(255) NOT NULL,
+ code_challenge_method VARCHAR(20) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP NULL,
+ created_at TIMESTAMP NOT NULL,
+ CONSTRAINT uk_gpt_oauth_code_hash UNIQUE (code_hash),
+
+
+ CONSTRAINT fk_gpt_oauth_code_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_gpt_oauth_code_user ON gpt_oauth_authorization_codes (user_id);
+CREATE INDEX idx_gpt_oauth_code_expires ON gpt_oauth_authorization_codes (expires_at);
+
diff --git a/src/main/resources/db/migration/postgresql/V19__allow_nullable_pkce_for_gpt_oauth_codes.sql b/src/main/resources/db/migration/postgresql/V19__allow_nullable_pkce_for_gpt_oauth_codes.sql
new file mode 100644
index 00000000..c28cf9ce
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V19__allow_nullable_pkce_for_gpt_oauth_codes.sql
@@ -0,0 +1,2 @@
+ALTER TABLE gpt_oauth_authorization_codes ALTER COLUMN code_challenge DROP NOT NULL;
+ALTER TABLE gpt_oauth_authorization_codes ALTER COLUMN code_challenge_method DROP NOT NULL;
diff --git a/src/main/resources/db/migration/postgresql/V1__initial_schema.sql b/src/main/resources/db/migration/postgresql/V1__initial_schema.sql
new file mode 100644
index 00000000..64c4d784
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V1__initial_schema.sql
@@ -0,0 +1,72 @@
+-- V1: Initial schema
+
+CREATE TABLE IF NOT EXISTS users (
+ id UUID NOT NULL PRIMARY KEY,
+ name VARCHAR(150) NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ CONSTRAINT uk_users_email UNIQUE (email)
+);
+
+CREATE TABLE IF NOT EXISTS refresh_tokens (
+ id UUID NOT NULL PRIMARY KEY,
+ token VARCHAR(512) NOT NULL,
+ expiry_date TIMESTAMP NOT NULL,
+ revoked BOOLEAN NOT NULL DEFAULT FALSE,
+ user_id UUID NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ CONSTRAINT uk_refresh_tokens_token UNIQUE (token),
+
+
+ CONSTRAINT fk_refresh_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id UUID NOT NULL PRIMARY KEY,
+ token VARCHAR(512) NOT NULL,
+ expiry_date TIMESTAMP NOT NULL,
+ used BOOLEAN NOT NULL DEFAULT FALSE,
+ user_id UUID NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ CONSTRAINT uk_prt_token UNIQUE (token),
+
+
+ CONSTRAINT fk_prt_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS job_applications (
+ id UUID NOT NULL PRIMARY KEY,
+ vacancy_name VARCHAR(255) NOT NULL,
+ recruiter_name VARCHAR(255),
+ vacancy_opened_by VARCHAR(255) NOT NULL,
+ vacancy_link VARCHAR(2048),
+ application_date DATE,
+ rh_accepted_connection BOOLEAN NOT NULL DEFAULT FALSE,
+ interview_scheduled BOOLEAN NOT NULL DEFAULT FALSE,
+ next_step_date_time TIMESTAMP,
+ status VARCHAR(100) NOT NULL,
+ recruiter_dm_reminder_enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ user_id UUID NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+
+
+
+
+ CONSTRAINT fk_job_applications_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_user_email ON users (email);
+CREATE INDEX idx_refresh_token_value ON refresh_tokens (token);
+CREATE INDEX idx_refresh_token_user_id ON refresh_tokens (user_id);
+CREATE INDEX idx_prt_token ON password_reset_tokens (token);
+CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id);
+CREATE INDEX idx_app_user_id ON job_applications (user_id);
+CREATE INDEX idx_app_status ON job_applications (status);
+CREATE INDEX idx_app_next_step ON job_applications (next_step_date_time);
+CREATE INDEX idx_app_application_date ON job_applications (application_date);
+
diff --git a/src/main/resources/db/migration/postgresql/V20__migrate_to_spring_authorization_server.sql b/src/main/resources/db/migration/postgresql/V20__migrate_to_spring_authorization_server.sql
new file mode 100644
index 00000000..d055384e
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V20__migrate_to_spring_authorization_server.sql
@@ -0,0 +1,68 @@
+DROP TABLE IF EXISTS gpt_oauth_authorization_codes;
+
+CREATE TABLE IF NOT EXISTS oauth2_registered_client (
+ id VARCHAR(100) NOT NULL,
+ client_id VARCHAR(100) NOT NULL,
+ client_id_issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ client_secret VARCHAR(200) DEFAULT NULL,
+ client_secret_expires_at TIMESTAMP NULL DEFAULT NULL,
+ client_name VARCHAR(200) NOT NULL,
+ client_authentication_methods VARCHAR(1000) NOT NULL,
+ authorization_grant_types VARCHAR(1000) NOT NULL,
+ redirect_uris VARCHAR(1000) DEFAULT NULL,
+ post_logout_redirect_uris VARCHAR(1000) DEFAULT NULL,
+ scopes VARCHAR(1000) NOT NULL,
+ client_settings VARCHAR(2000) NOT NULL,
+ token_settings VARCHAR(2000) NOT NULL,
+ CONSTRAINT pk_oauth2_registered_client PRIMARY KEY (id),
+ CONSTRAINT uk_oauth2_registered_client_client_id UNIQUE (client_id)
+);
+
+CREATE TABLE IF NOT EXISTS oauth2_authorization (
+ id VARCHAR(100) NOT NULL,
+ registered_client_id VARCHAR(100) NOT NULL,
+ principal_name VARCHAR(200) NOT NULL,
+ authorization_grant_type VARCHAR(100) NOT NULL,
+ authorized_scopes VARCHAR(1000) DEFAULT NULL,
+ attributes TEXT DEFAULT NULL,
+ state VARCHAR(500) DEFAULT NULL,
+ authorization_code_value TEXT DEFAULT NULL,
+ authorization_code_issued_at TIMESTAMP NULL DEFAULT NULL,
+ authorization_code_expires_at TIMESTAMP NULL DEFAULT NULL,
+ authorization_code_metadata TEXT DEFAULT NULL,
+ access_token_value TEXT DEFAULT NULL,
+ access_token_issued_at TIMESTAMP NULL DEFAULT NULL,
+ access_token_expires_at TIMESTAMP NULL DEFAULT NULL,
+ access_token_metadata TEXT DEFAULT NULL,
+ access_token_type VARCHAR(100) DEFAULT NULL,
+ access_token_scopes VARCHAR(1000) DEFAULT NULL,
+ oidc_id_token_value TEXT DEFAULT NULL,
+ oidc_id_token_issued_at TIMESTAMP NULL DEFAULT NULL,
+ oidc_id_token_expires_at TIMESTAMP NULL DEFAULT NULL,
+ oidc_id_token_metadata TEXT DEFAULT NULL,
+ refresh_token_value TEXT DEFAULT NULL,
+ refresh_token_issued_at TIMESTAMP NULL DEFAULT NULL,
+ refresh_token_expires_at TIMESTAMP NULL DEFAULT NULL,
+ refresh_token_metadata TEXT DEFAULT NULL,
+ user_code_value TEXT DEFAULT NULL,
+ user_code_issued_at TIMESTAMP NULL DEFAULT NULL,
+ user_code_expires_at TIMESTAMP NULL DEFAULT NULL,
+ user_code_metadata TEXT DEFAULT NULL,
+ device_code_value TEXT DEFAULT NULL,
+ device_code_issued_at TIMESTAMP NULL DEFAULT NULL,
+ device_code_expires_at TIMESTAMP NULL DEFAULT NULL,
+ device_code_metadata TEXT DEFAULT NULL,
+ CONSTRAINT pk_oauth2_authorization PRIMARY KEY (id)
+);
+
+CREATE INDEX idx_oauth2_authorization_registered_client_id ON oauth2_authorization (registered_client_id);
+CREATE INDEX idx_oauth2_authorization_principal_name ON oauth2_authorization (principal_name);
+CREATE INDEX idx_oauth2_authorization_state ON oauth2_authorization (state);
+
+CREATE TABLE IF NOT EXISTS oauth2_authorization_consent (
+ registered_client_id VARCHAR(100) NOT NULL,
+ principal_name VARCHAR(200) NOT NULL,
+ authorities VARCHAR(1000) NOT NULL,
+ CONSTRAINT pk_oauth2_authorization_consent PRIMARY KEY (registered_client_id, principal_name)
+);
+
diff --git a/src/main/resources/db/migration/postgresql/V21__oauth2_authorization_lob_columns.sql b/src/main/resources/db/migration/postgresql/V21__oauth2_authorization_lob_columns.sql
new file mode 100644
index 00000000..44d8d71c
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V21__oauth2_authorization_lob_columns.sql
@@ -0,0 +1 @@
+-- PostgreSQL stores authorization payloads as TEXT; no LOB alteration is needed.
diff --git a/src/main/resources/db/migration/postgresql/V22__fix_columns_oauth2_to_mariadb.sql b/src/main/resources/db/migration/postgresql/V22__fix_columns_oauth2_to_mariadb.sql
new file mode 100644
index 00000000..3cce0360
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V22__fix_columns_oauth2_to_mariadb.sql
@@ -0,0 +1 @@
+-- MariaDB compatibility transition; PostgreSQL stores authorization payloads as TEXT.
diff --git a/src/main/resources/db/migration/postgresql/V23__oauth2_authorization_blob_to_text.sql b/src/main/resources/db/migration/postgresql/V23__oauth2_authorization_blob_to_text.sql
new file mode 100644
index 00000000..4b23549e
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V23__oauth2_authorization_blob_to_text.sql
@@ -0,0 +1 @@
+-- PostgreSQL authorization payloads are TEXT from V20 onward.
diff --git a/src/main/resources/db/migration/postgresql/V24__add_platform_to_job_applications.sql b/src/main/resources/db/migration/postgresql/V24__add_platform_to_job_applications.sql
new file mode 100644
index 00000000..e69127ad
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V24__add_platform_to_job_applications.sql
@@ -0,0 +1,3 @@
+ALTER TABLE job_applications
+ ADD COLUMN platform VARCHAR(100) NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V25__add_interview_count_to_job_applications.sql b/src/main/resources/db/migration/postgresql/V25__add_interview_count_to_job_applications.sql
new file mode 100644
index 00000000..de61ec2b
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V25__add_interview_count_to_job_applications.sql
@@ -0,0 +1,3 @@
+ALTER TABLE job_applications
+ ADD COLUMN interview_count INT NOT NULL DEFAULT 0;
+
diff --git a/src/main/resources/db/migration/postgresql/V26__create_tool_execution_metrics.sql b/src/main/resources/db/migration/postgresql/V26__create_tool_execution_metrics.sql
new file mode 100644
index 00000000..60be375e
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V26__create_tool_execution_metrics.sql
@@ -0,0 +1,18 @@
+CREATE TABLE tool_execution_metrics (
+ id UUID NOT NULL,
+ tool_name VARCHAR(255) NOT NULL,
+ execution_time_ms BIGINT NOT NULL,
+ request_bytes INT NOT NULL,
+ response_bytes INT NOT NULL,
+ request_tokens INT NOT NULL,
+ response_tokens INT NOT NULL,
+ total_tokens INT NOT NULL,
+ expensive BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP(6) NOT NULL,
+ CONSTRAINT pk_tool_execution_metrics PRIMARY KEY (id)
+);
+
+CREATE INDEX idx_tool_metrics_tool_name ON tool_execution_metrics (tool_name);
+CREATE INDEX idx_tool_metrics_created_at ON tool_execution_metrics (created_at);
+CREATE INDEX idx_tool_metrics_expensive ON tool_execution_metrics (expensive);
+
diff --git a/src/main/resources/db/migration/postgresql/V27__add_read_only_to_base_resumes.sql b/src/main/resources/db/migration/postgresql/V27__add_read_only_to_base_resumes.sql
new file mode 100644
index 00000000..60c4f745
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V27__add_read_only_to_base_resumes.sql
@@ -0,0 +1,3 @@
+ALTER TABLE google_drive_base_resumes
+ ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT FALSE;
+
diff --git a/src/main/resources/db/migration/postgresql/V28__add_privacy_policy_accepted_to_users.sql b/src/main/resources/db/migration/postgresql/V28__add_privacy_policy_accepted_to_users.sql
new file mode 100644
index 00000000..e56c12e0
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V28__add_privacy_policy_accepted_to_users.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users
+ ADD COLUMN privacy_policy_accepted BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN privacy_policy_accepted_at TIMESTAMP NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V29__add_application_status_table.sql b/src/main/resources/db/migration/postgresql/V29__add_application_status_table.sql
new file mode 100644
index 00000000..6ae739b5
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V29__add_application_status_table.sql
@@ -0,0 +1,24 @@
+CREATE TABLE application_statuses (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ display_order INT NOT NULL,
+ CONSTRAINT uq_application_statuses_name UNIQUE (name)
+);
+
+INSERT INTO application_statuses (name, display_order) VALUES
+('RH', 1),
+('Pending HR Response', 2),
+('Pending Hiring Manager Response', 3),
+('Technical Test', 4),
+('Pending Technical Test Response', 5),
+('Offer Negotiation', 6),
+('Ghosting', 7),
+('Rejected', 8),
+('Approved', 9);
+
+ALTER TABLE job_applications
+ ADD COLUMN to_send_later BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Migrate existing "to send later" records: null status means draft/to-send-later
+UPDATE job_applications SET to_send_later = TRUE WHERE status IS NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V2__make_vacancy_name_optional.sql b/src/main/resources/db/migration/postgresql/V2__make_vacancy_name_optional.sql
new file mode 100644
index 00000000..96e202d2
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V2__make_vacancy_name_optional.sql
@@ -0,0 +1 @@
+ALTER TABLE job_applications ALTER COLUMN vacancy_name DROP NOT NULL;
diff --git a/src/main/resources/db/migration/postgresql/V30__add_google_drive_base_information.sql b/src/main/resources/db/migration/postgresql/V30__add_google_drive_base_information.sql
new file mode 100644
index 00000000..c6dcd0e3
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V30__add_google_drive_base_information.sql
@@ -0,0 +1,17 @@
+CREATE TABLE google_drive_base_information (
+ id UUID NOT NULL,
+ connection_id UUID NOT NULL,
+ google_file_id VARCHAR(255) NOT NULL,
+ document_name VARCHAR(255) NOT NULL,
+ doc_type VARCHAR(20) NOT NULL,
+ web_view_link VARCHAR(2048) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_google_drive_base_information PRIMARY KEY (id),
+ CONSTRAINT uk_google_drive_base_information_connection_file UNIQUE (connection_id, google_file_id),
+ CONSTRAINT fk_google_drive_base_information_connection FOREIGN KEY (connection_id) REFERENCES google_drive_connections (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_gdrive_base_info_connection ON google_drive_base_information (connection_id);
+CREATE INDEX idx_gdrive_base_info_file ON google_drive_base_information (google_file_id);
+
diff --git a/src/main/resources/db/migration/postgresql/V31__drop_tool_execution_metrics.sql b/src/main/resources/db/migration/postgresql/V31__drop_tool_execution_metrics.sql
new file mode 100644
index 00000000..87c08ce8
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V31__drop_tool_execution_metrics.sql
@@ -0,0 +1,6 @@
+-- MCP operation auditing moved from DB persistence to Micrometer/Prometheus metrics
+-- plus structured DEBUG logs (see McpAuditAspect / McpAuditService). The DB-backed
+-- tool_execution_metrics table (created in V26) and its REST analytics are no longer used.
+-- Indexes are dropped with the table.
+DROP TABLE IF EXISTS tool_execution_metrics;
+
diff --git a/src/main/resources/db/migration/postgresql/V32__track_resume_template_and_pdf_on_applications.sql b/src/main/resources/db/migration/postgresql/V32__track_resume_template_and_pdf_on_applications.sql
new file mode 100644
index 00000000..3b847a6a
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V32__track_resume_template_and_pdf_on_applications.sql
@@ -0,0 +1,5 @@
+ALTER TABLE job_applications
+ ADD COLUMN drive_resume_template_id UUID NULL,
+ ADD COLUMN drive_resume_pdf_file_id VARCHAR(255) NULL,
+ ADD COLUMN drive_resume_pdf_url VARCHAR(2048) NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V33__add_export_module.sql b/src/main/resources/db/migration/postgresql/V33__add_export_module.sql
new file mode 100644
index 00000000..023e5fbc
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V33__add_export_module.sql
@@ -0,0 +1,54 @@
+CREATE TABLE export_schedules (
+ id UUID NOT NULL,
+ user_id UUID NOT NULL,
+ name VARCHAR(120) NOT NULL,
+ format VARCHAR(20) NOT NULL,
+ frequency VARCHAR(20) NOT NULL,
+ time_of_day TIME NOT NULL,
+ day_of_week INT NULL,
+ day_of_month INT NULL,
+ timezone VARCHAR(64) NOT NULL,
+ destination VARCHAR(30) NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ filters_json TEXT NULL,
+ columns_json TEXT NULL,
+ next_run_at TIMESTAMP NULL,
+ last_run_at TIMESTAMP NULL,
+ running BOOLEAN NOT NULL DEFAULT FALSE,
+ running_since TIMESTAMP NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_export_schedules PRIMARY KEY (id),
+ CONSTRAINT fk_export_schedules_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_export_schedules_user ON export_schedules (user_id);
+-- Drives the scheduler poll: enabled schedules ordered by when they are next due.
+CREATE INDEX idx_export_schedules_next_run ON export_schedules (enabled, next_run_at);
+
+CREATE TABLE export_executions (
+ id UUID NOT NULL,
+ user_id UUID NOT NULL,
+ schedule_id UUID NULL,
+ schedule_name VARCHAR(120) NULL,
+ trigger_type VARCHAR(20) NOT NULL,
+ format VARCHAR(20) NOT NULL,
+ destination VARCHAR(30) NULL,
+ status VARCHAR(20) NOT NULL,
+ started_at TIMESTAMP NOT NULL,
+ finished_at TIMESTAMP NULL,
+ record_count INT NULL,
+ truncated BOOLEAN NOT NULL DEFAULT FALSE,
+ file_name VARCHAR(255) NULL,
+ file_id VARCHAR(255) NULL,
+ file_url VARCHAR(2048) NULL,
+ error_message VARCHAR(500) NULL,
+ CONSTRAINT pk_export_executions PRIMARY KEY (id),
+ CONSTRAINT fk_export_executions_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ -- History outlives the schedule it came from: the run keeps the schedule name it was created with.
+ CONSTRAINT fk_export_executions_schedule FOREIGN KEY (schedule_id) REFERENCES export_schedules (id) ON DELETE SET NULL
+);
+
+CREATE INDEX idx_export_executions_user ON export_executions (user_id, started_at);
+CREATE INDEX idx_export_executions_schedule ON export_executions (schedule_id);
+
diff --git a/src/main/resources/db/migration/postgresql/V34__add_google_drive_connection_resilience.sql b/src/main/resources/db/migration/postgresql/V34__add_google_drive_connection_resilience.sql
new file mode 100644
index 00000000..e81c17e7
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V34__add_google_drive_connection_resilience.sql
@@ -0,0 +1,4 @@
+ALTER TABLE google_drive_connections
+ ADD COLUMN reauthorization_required BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN reauthorization_reason VARCHAR(255) NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V35__add_assistant_fulltext_index.sql b/src/main/resources/db/migration/postgresql/V35__add_assistant_fulltext_index.sql
new file mode 100644
index 00000000..88fe9e4d
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V35__add_assistant_fulltext_index.sql
@@ -0,0 +1,2 @@
+-- V35 is retained to match the final version of the historical MariaDB schema.
+-- PostgreSQL full-text structures are added in V36 after schema/data import.
diff --git a/src/main/resources/db/migration/postgresql/V36__add_assistant_fulltext_index.sql b/src/main/resources/db/migration/postgresql/V36__add_assistant_fulltext_index.sql
new file mode 100644
index 00000000..1473f1c4
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V36__add_assistant_fulltext_index.sql
@@ -0,0 +1,8 @@
+-- Weighted simple-config vector keeps short and symbolic terms searchable (Go, AI, C#).
+ALTER TABLE job_applications ADD COLUMN assistant_search_vector tsvector GENERATED ALWAYS AS (
+ setweight(to_tsvector('simple', coalesce(vacancy_name, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(organization, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(recruiter_name, '')), 'B') ||
+ setweight(to_tsvector('simple', coalesce(note, '')), 'C')
+) STORED;
+CREATE INDEX ft_job_applications_assistant ON job_applications USING GIN (assistant_search_vector);
diff --git a/src/main/resources/db/migration/postgresql/V3__rename_vacancy_opened_by_to_organization_safe.sql b/src/main/resources/db/migration/postgresql/V3__rename_vacancy_opened_by_to_organization_safe.sql
new file mode 100644
index 00000000..4752a7b3
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V3__rename_vacancy_opened_by_to_organization_safe.sql
@@ -0,0 +1,13 @@
+-- Safe migration to rename column `vacancy_opened_by` to `organization` in MariaDB
+-- Step 1: Create the new column
+ALTER TABLE job_applications
+ ADD COLUMN organization VARCHAR(255) NOT NULL DEFAULT '';
+
+-- Step 2: Copy data from old column to new column
+UPDATE job_applications
+ SET organization = vacancy_opened_by;
+
+-- Step 3: Drop the old column
+ALTER TABLE job_applications
+ DROP COLUMN vacancy_opened_by;
+
diff --git a/src/main/resources/db/migration/postgresql/V4__allow_null_status_for_to_send_later.sql b/src/main/resources/db/migration/postgresql/V4__allow_null_status_for_to_send_later.sql
new file mode 100644
index 00000000..6a4ac36f
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V4__allow_null_status_for_to_send_later.sql
@@ -0,0 +1 @@
+ALTER TABLE job_applications ALTER COLUMN status DROP NOT NULL;
diff --git a/src/main/resources/db/migration/postgresql/V5__add_previous_status_and_user_reminder_time.sql b/src/main/resources/db/migration/postgresql/V5__add_previous_status_and_user_reminder_time.sql
new file mode 100644
index 00000000..5155f34f
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V5__add_previous_status_and_user_reminder_time.sql
@@ -0,0 +1,6 @@
+ALTER TABLE job_applications
+ ADD COLUMN previous_status VARCHAR(100) NULL;
+
+ALTER TABLE users
+ ADD COLUMN reminder_time TIME NOT NULL DEFAULT '19:00:00';
+
diff --git a/src/main/resources/db/migration/postgresql/V6__make_application_date_nullable.sql b/src/main/resources/db/migration/postgresql/V6__make_application_date_nullable.sql
new file mode 100644
index 00000000..fe852ee3
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V6__make_application_date_nullable.sql
@@ -0,0 +1 @@
+-- application_date was nullable from the initial PostgreSQL schema.
diff --git a/src/main/resources/db/migration/postgresql/V7__add_recruiter_dm_sent_at.sql b/src/main/resources/db/migration/postgresql/V7__add_recruiter_dm_sent_at.sql
new file mode 100644
index 00000000..12ad025d
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V7__add_recruiter_dm_sent_at.sql
@@ -0,0 +1,3 @@
+ALTER TABLE job_applications
+ ADD COLUMN recruiter_dm_sent_at TIMESTAMP NULL;
+
diff --git a/src/main/resources/db/migration/postgresql/V8__add_note_and_archiving_to_job_applications.sql b/src/main/resources/db/migration/postgresql/V8__add_note_and_archiving_to_job_applications.sql
new file mode 100644
index 00000000..f5816a58
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V8__add_note_and_archiving_to_job_applications.sql
@@ -0,0 +1,9 @@
+-- V8: Add note and archiving fields to job applications
+
+ALTER TABLE job_applications
+ ADD COLUMN note TEXT NULL,
+ ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN archived_at TIMESTAMP NULL;
+
+CREATE INDEX idx_app_archived ON job_applications (archived);
+
diff --git a/src/main/resources/db/migration/postgresql/V9__add_gamification_tables.sql b/src/main/resources/db/migration/postgresql/V9__add_gamification_tables.sql
new file mode 100644
index 00000000..1d6dcd56
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V9__add_gamification_tables.sql
@@ -0,0 +1,43 @@
+CREATE TABLE user_gamification (
+ user_id UUID NOT NULL,
+ current_xp BIGINT NOT NULL DEFAULT 0,
+ level INT NOT NULL DEFAULT 1,
+ streak_days INT NOT NULL DEFAULT 0,
+ last_activity_at TIMESTAMP NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_user_gamification PRIMARY KEY (user_id),
+ CONSTRAINT fk_user_gamification_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_user_gamification_level ON user_gamification (level);
+
+CREATE TABLE achievements (
+ id UUID NOT NULL,
+ code VARCHAR(100) NOT NULL,
+ name VARCHAR(150) NOT NULL,
+ description VARCHAR(255) NOT NULL,
+ icon VARCHAR(50) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_achievements PRIMARY KEY (id),
+ CONSTRAINT uk_achievements_code UNIQUE (code)
+);
+
+CREATE TABLE user_achievements (
+ id UUID NOT NULL,
+ user_id UUID NOT NULL,
+ achievement_id UUID NOT NULL,
+ achieved_at TIMESTAMP NOT NULL,
+ metadata_json TEXT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT pk_user_achievements PRIMARY KEY (id),
+ CONSTRAINT uk_user_achievements_user_achievement UNIQUE (user_id, achievement_id),
+ CONSTRAINT fk_user_achievements_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ CONSTRAINT fk_user_achievements_achievement FOREIGN KEY (achievement_id) REFERENCES achievements (id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_user_achievements_user_id ON user_achievements (user_id);
+CREATE INDEX idx_user_achievements_achievement_id ON user_achievements (achievement_id);
+
diff --git a/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java b/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java
index cf1b1a3a..9228a514 100644
--- a/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java
+++ b/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java
@@ -6,11 +6,19 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import com.jobtracker.support.PostgresTestDatabase;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public abstract class AbstractE2ETest {
+ @DynamicPropertySource
+ static void configureDatabase(DynamicPropertyRegistry registry) {
+ PostgresTestDatabase.registerProperties(registry);
+ }
+
@LocalServerPort
protected int port;
diff --git a/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java b/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java
index d7f9b1cf..46ffe22b 100644
--- a/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java
+++ b/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java
@@ -3,17 +3,16 @@
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import com.jobtracker.support.PostgresTestDatabase;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.MOCK,
properties = {
- "spring.datasource.url=jdbc:h2:mem:jobtracker_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
- "spring.datasource.driver-class-name=org.h2.Driver",
- "spring.datasource.username=sa",
- "spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.jpa.show-sql=false",
- "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect",
"spring.flyway.enabled=false",
"spring.sql.init.mode=always"
}
@@ -21,4 +20,8 @@
@AutoConfigureMockMvc
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
+ @DynamicPropertySource
+ static void configureDatabase(DynamicPropertyRegistry registry) {
+ PostgresTestDatabase.registerProperties(registry);
+ }
}
diff --git a/src/test/java/com/jobtracker/support/PostgresTestDatabase.java b/src/test/java/com/jobtracker/support/PostgresTestDatabase.java
new file mode 100644
index 00000000..25a07bc4
--- /dev/null
+++ b/src/test/java/com/jobtracker/support/PostgresTestDatabase.java
@@ -0,0 +1,23 @@
+package com.jobtracker.support;
+
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.testcontainers.containers.PostgreSQLContainer;
+
+/** Shared PostgreSQL instance so cached Spring contexts keep a live datasource between test classes. */
+public final class PostgresTestDatabase {
+ private static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
+
+ static {
+ POSTGRES.start();
+ }
+
+ private PostgresTestDatabase() {}
+
+ public static void registerProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
+ registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect");
+ }
+}
diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql
index 59cfd746..9505a823 100644
--- a/src/test/resources/schema.sql
+++ b/src/test/resources/schema.sql
@@ -20,34 +20,34 @@ CREATE TABLE IF NOT EXISTS oauth2_authorization (
principal_name VARCHAR(200) NOT NULL,
authorization_grant_type VARCHAR(100) NOT NULL,
authorized_scopes VARCHAR(1000),
- attributes BLOB,
+ attributes TEXT,
state VARCHAR(500),
- authorization_code_value BLOB,
+ authorization_code_value TEXT,
authorization_code_issued_at TIMESTAMP,
authorization_code_expires_at TIMESTAMP,
- authorization_code_metadata BLOB,
- access_token_value BLOB,
+ authorization_code_metadata TEXT,
+ access_token_value TEXT,
access_token_issued_at TIMESTAMP,
access_token_expires_at TIMESTAMP,
- access_token_metadata BLOB,
+ access_token_metadata TEXT,
access_token_type VARCHAR(100),
access_token_scopes VARCHAR(1000),
- oidc_id_token_value BLOB,
+ oidc_id_token_value TEXT,
oidc_id_token_issued_at TIMESTAMP,
oidc_id_token_expires_at TIMESTAMP,
- oidc_id_token_metadata BLOB,
- refresh_token_value BLOB,
+ oidc_id_token_metadata TEXT,
+ refresh_token_value TEXT,
refresh_token_issued_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
- refresh_token_metadata BLOB,
- user_code_value BLOB,
+ refresh_token_metadata TEXT,
+ user_code_value TEXT,
user_code_issued_at TIMESTAMP,
user_code_expires_at TIMESTAMP,
- user_code_metadata BLOB,
- device_code_value BLOB,
+ user_code_metadata TEXT,
+ device_code_value TEXT,
device_code_issued_at TIMESTAMP,
device_code_expires_at TIMESTAMP,
- device_code_metadata BLOB
+ device_code_metadata TEXT
);
CREATE TABLE IF NOT EXISTS oauth2_authorization_consent (