From e214a9b48c3ff3ba235d2d6ff961c553ab97193d Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Mon, 9 Nov 2026 02:25:13 +0000 Subject: [PATCH 1/6] feat(database): migrate backend to PostgreSQL --- .env.example | 2 +- .github/workflows/load-test.yml | 23 +++--- README.md | 27 +++++-- docker-compose.prod.yml | 2 +- docker-compose.yml | 20 +++++- pom.xml | 19 ++--- .../AssistantApplicationQueryRepository.java | 29 +++----- src/main/resources/application-dev.yml | 18 +++++ src/main/resources/application.yml | 10 +-- ..._track_application_gamification_awards.sql | 7 ++ .../V11__add_google_drive_integration.sql | 51 +++++++++++++ ...rive_vacancy_folder_id_to_applications.sql | 3 + .../V13__add_roles_and_user_authorization.sql | 26 +++++++ ...generated_drive_resume_on_applications.sql | 6 ++ .../postgresql/V15__add_interview_metrics.sql | 24 +++++++ .../postgresql/V16__add_webauthn_passkeys.sql | 36 ++++++++++ ..._language_and_template_to_base_resumes.sql | 5 ++ ...V18__add_gpt_oauth_authorization_codes.sql | 21 ++++++ ...llow_nullable_pkce_for_gpt_oauth_codes.sql | 2 + .../postgresql/V1__initial_schema.sql | 72 +++++++++++++++++++ ...migrate_to_spring_authorization_server.sql | 68 ++++++++++++++++++ .../V21__oauth2_authorization_lob_columns.sql | 1 + .../V22__fix_columns_oauth2_to_mariadb.sql | 1 + ...V23__oauth2_authorization_blob_to_text.sql | 1 + .../V24__add_platform_to_job_applications.sql | 3 + ...dd_interview_count_to_job_applications.sql | 3 + .../V26__create_tool_execution_metrics.sql | 18 +++++ .../V27__add_read_only_to_base_resumes.sql | 3 + ...__add_privacy_policy_accepted_to_users.sql | 4 ++ .../V29__add_application_status_table.sql | 24 +++++++ .../V2__make_vacancy_name_optional.sql | 1 + ...V30__add_google_drive_base_information.sql | 17 +++++ .../V31__drop_tool_execution_metrics.sql | 6 ++ ...esume_template_and_pdf_on_applications.sql | 5 ++ .../postgresql/V33__add_export_module.sql | 54 ++++++++++++++ ...add_google_drive_connection_resilience.sql | 4 ++ .../V35__add_assistant_fulltext_index.sql | 2 + .../V36__add_assistant_fulltext_index.sql | 8 +++ ...vacancy_opened_by_to_organization_safe.sql | 13 ++++ ...4__allow_null_status_for_to_send_later.sql | 1 + ...previous_status_and_user_reminder_time.sql | 6 ++ .../V6__make_application_date_nullable.sql | 1 + .../V7__add_recruiter_dm_sent_at.sql | 3 + ...note_and_archiving_to_job_applications.sql | 9 +++ .../V9__add_gamification_tables.sql | 43 +++++++++++ .../com/jobtracker/e2e/AbstractE2ETest.java | 17 +++++ .../integration/AbstractIntegrationTest.java | 28 +++++--- .../jobtracker/integration/DcrDisabledIT.java | 2 +- src/test/resources/application-test.yml | 5 +- src/test/resources/schema.sql | 26 +++---- 50 files changed, 700 insertions(+), 80 deletions(-) create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/db/migration/postgresql/V10__track_application_gamification_awards.sql create mode 100644 src/main/resources/db/migration/postgresql/V11__add_google_drive_integration.sql create mode 100644 src/main/resources/db/migration/postgresql/V12__add_drive_vacancy_folder_id_to_applications.sql create mode 100644 src/main/resources/db/migration/postgresql/V13__add_roles_and_user_authorization.sql create mode 100644 src/main/resources/db/migration/postgresql/V14__track_generated_drive_resume_on_applications.sql create mode 100644 src/main/resources/db/migration/postgresql/V15__add_interview_metrics.sql create mode 100644 src/main/resources/db/migration/postgresql/V16__add_webauthn_passkeys.sql create mode 100644 src/main/resources/db/migration/postgresql/V17__add_language_and_template_to_base_resumes.sql create mode 100644 src/main/resources/db/migration/postgresql/V18__add_gpt_oauth_authorization_codes.sql create mode 100644 src/main/resources/db/migration/postgresql/V19__allow_nullable_pkce_for_gpt_oauth_codes.sql create mode 100644 src/main/resources/db/migration/postgresql/V1__initial_schema.sql create mode 100644 src/main/resources/db/migration/postgresql/V20__migrate_to_spring_authorization_server.sql create mode 100644 src/main/resources/db/migration/postgresql/V21__oauth2_authorization_lob_columns.sql create mode 100644 src/main/resources/db/migration/postgresql/V22__fix_columns_oauth2_to_mariadb.sql create mode 100644 src/main/resources/db/migration/postgresql/V23__oauth2_authorization_blob_to_text.sql create mode 100644 src/main/resources/db/migration/postgresql/V24__add_platform_to_job_applications.sql create mode 100644 src/main/resources/db/migration/postgresql/V25__add_interview_count_to_job_applications.sql create mode 100644 src/main/resources/db/migration/postgresql/V26__create_tool_execution_metrics.sql create mode 100644 src/main/resources/db/migration/postgresql/V27__add_read_only_to_base_resumes.sql create mode 100644 src/main/resources/db/migration/postgresql/V28__add_privacy_policy_accepted_to_users.sql create mode 100644 src/main/resources/db/migration/postgresql/V29__add_application_status_table.sql create mode 100644 src/main/resources/db/migration/postgresql/V2__make_vacancy_name_optional.sql create mode 100644 src/main/resources/db/migration/postgresql/V30__add_google_drive_base_information.sql create mode 100644 src/main/resources/db/migration/postgresql/V31__drop_tool_execution_metrics.sql create mode 100644 src/main/resources/db/migration/postgresql/V32__track_resume_template_and_pdf_on_applications.sql create mode 100644 src/main/resources/db/migration/postgresql/V33__add_export_module.sql create mode 100644 src/main/resources/db/migration/postgresql/V34__add_google_drive_connection_resilience.sql create mode 100644 src/main/resources/db/migration/postgresql/V35__add_assistant_fulltext_index.sql create mode 100644 src/main/resources/db/migration/postgresql/V36__add_assistant_fulltext_index.sql create mode 100644 src/main/resources/db/migration/postgresql/V3__rename_vacancy_opened_by_to_organization_safe.sql create mode 100644 src/main/resources/db/migration/postgresql/V4__allow_null_status_for_to_send_later.sql create mode 100644 src/main/resources/db/migration/postgresql/V5__add_previous_status_and_user_reminder_time.sql create mode 100644 src/main/resources/db/migration/postgresql/V6__make_application_date_nullable.sql create mode 100644 src/main/resources/db/migration/postgresql/V7__add_recruiter_dm_sent_at.sql create mode 100644 src/main/resources/db/migration/postgresql/V8__add_note_and_archiving_to_job_applications.sql create mode 100644 src/main/resources/db/migration/postgresql/V9__add_gamification_tables.sql 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..5a8259ed 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,13 @@ org.testcontainers - mariadb + postgresql + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter ${testcontainers.version} test 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..bf1a508d 100644 --- a/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java +++ b/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java @@ -6,11 +6,28 @@ 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 org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") +@Testcontainers public abstract class AbstractE2ETest { + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine"); + + @DynamicPropertySource + static void configureDatabase(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"); + } + @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..e5b6e849 100644 --- a/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java +++ b/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java @@ -3,22 +3,34 @@ 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 org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +@Testcontainers @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.hibernate.ddl-auto=validate", "spring.jpa.show-sql=false", - "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect", - "spring.flyway.enabled=false", - "spring.sql.init.mode=always" + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/postgresql", + "spring.sql.init.mode=never" } ) @AutoConfigureMockMvc @ActiveProfiles("test") public abstract class AbstractIntegrationTest { + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine"); + + @DynamicPropertySource + static void configureDatabase(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"); + } } diff --git a/src/test/java/com/jobtracker/integration/DcrDisabledIT.java b/src/test/java/com/jobtracker/integration/DcrDisabledIT.java index bfd8c9f2..8e7dbbe7 100644 --- a/src/test/java/com/jobtracker/integration/DcrDisabledIT.java +++ b/src/test/java/com/jobtracker/integration/DcrDisabledIT.java @@ -28,7 +28,7 @@ @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.MOCK, properties = { - "spring.datasource.url=jdbc:h2:mem:jobtracker_dcr_disabled_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.datasource.url=jdbc:h2:mem:jobtracker_dcr_disabled_test;MODE=PostgreSQL;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=", diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ee1b97da..43b88836 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -6,7 +6,7 @@ spring: virtual: enabled: true datasource: - url: jdbc:h2:mem:jobtracker_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:jobtracker_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: @@ -15,9 +15,6 @@ spring: ddl-auto: create-drop defer-datasource-initialization: true show-sql: false - properties: - hibernate: - dialect: org.hibernate.dialect.H2Dialect flyway: enabled: false locations: classpath:db/migration diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 59cfd746..0c537dc2 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 BYTEA, state VARCHAR(500), - authorization_code_value BLOB, + authorization_code_value BYTEA, authorization_code_issued_at TIMESTAMP, authorization_code_expires_at TIMESTAMP, - authorization_code_metadata BLOB, - access_token_value BLOB, + authorization_code_metadata BYTEA, + access_token_value BYTEA, access_token_issued_at TIMESTAMP, access_token_expires_at TIMESTAMP, - access_token_metadata BLOB, + access_token_metadata BYTEA, access_token_type VARCHAR(100), access_token_scopes VARCHAR(1000), - oidc_id_token_value BLOB, + oidc_id_token_value BYTEA, 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 BYTEA, + refresh_token_value BYTEA, refresh_token_issued_at TIMESTAMP, refresh_token_expires_at TIMESTAMP, - refresh_token_metadata BLOB, - user_code_value BLOB, + refresh_token_metadata BYTEA, + user_code_value BYTEA, user_code_issued_at TIMESTAMP, user_code_expires_at TIMESTAMP, - user_code_metadata BLOB, - device_code_value BLOB, + user_code_metadata BYTEA, + device_code_value BYTEA, device_code_issued_at TIMESTAMP, device_code_expires_at TIMESTAMP, - device_code_metadata BLOB + device_code_metadata BYTEA ); CREATE TABLE IF NOT EXISTS oauth2_authorization_consent ( From 1e094e1e1d289f17cff278e95cc47350ae2fe813 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Mon, 9 Nov 2026 03:29:32 +0000 Subject: [PATCH 2/6] fix(test): stabilize PostgreSQL CI database setup --- .../com/jobtracker/e2e/AbstractE2ETest.java | 13 ++--------- .../integration/AbstractIntegrationTest.java | 21 +++++------------ .../jobtracker/integration/DcrDisabledIT.java | 2 +- .../support/PostgresTestDatabase.java | 23 +++++++++++++++++++ src/test/resources/application-test.yml | 5 +++- 5 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 src/test/java/com/jobtracker/support/PostgresTestDatabase.java diff --git a/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java b/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java index bf1a508d..9228a514 100644 --- a/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java +++ b/src/test/java/com/jobtracker/e2e/AbstractE2ETest.java @@ -8,24 +8,15 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; +import com.jobtracker.support.PostgresTestDatabase; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") -@Testcontainers public abstract class AbstractE2ETest { - @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine"); - @DynamicPropertySource static void configureDatabase(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"); + PostgresTestDatabase.registerProperties(registry); } @LocalServerPort diff --git a/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java b/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java index e5b6e849..46ffe22b 100644 --- a/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java +++ b/src/test/java/com/jobtracker/integration/AbstractIntegrationTest.java @@ -5,32 +5,23 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; +import com.jobtracker.support.PostgresTestDatabase; -@Testcontainers @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.MOCK, properties = { - "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.hibernate.ddl-auto=create-drop", "spring.jpa.show-sql=false", - "spring.flyway.enabled=true", - "spring.flyway.locations=classpath:db/migration/postgresql", - "spring.sql.init.mode=never" + "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect", + "spring.flyway.enabled=false", + "spring.sql.init.mode=always" } ) @AutoConfigureMockMvc @ActiveProfiles("test") public abstract class AbstractIntegrationTest { - @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine"); - @DynamicPropertySource static void configureDatabase(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"); + PostgresTestDatabase.registerProperties(registry); } } diff --git a/src/test/java/com/jobtracker/integration/DcrDisabledIT.java b/src/test/java/com/jobtracker/integration/DcrDisabledIT.java index 8e7dbbe7..bfd8c9f2 100644 --- a/src/test/java/com/jobtracker/integration/DcrDisabledIT.java +++ b/src/test/java/com/jobtracker/integration/DcrDisabledIT.java @@ -28,7 +28,7 @@ @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.MOCK, properties = { - "spring.datasource.url=jdbc:h2:mem:jobtracker_dcr_disabled_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.datasource.url=jdbc:h2:mem:jobtracker_dcr_disabled_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=", 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/application-test.yml b/src/test/resources/application-test.yml index 43b88836..ee1b97da 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -6,7 +6,7 @@ spring: virtual: enabled: true datasource: - url: jdbc:h2:mem:jobtracker_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:jobtracker_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: @@ -15,6 +15,9 @@ spring: ddl-auto: create-drop defer-datasource-initialization: true show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect flyway: enabled: false locations: classpath:db/migration From 54224468819eb0b7055122cf5a8ac39c6f78359a Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Mon, 9 Nov 2026 03:30:06 +0000 Subject: [PATCH 3/6] chore(test): remove unused Testcontainers JUnit extension --- pom.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pom.xml b/pom.xml index 5a8259ed..214becd7 100644 --- a/pom.xml +++ b/pom.xml @@ -292,12 +292,6 @@ ${testcontainers.version} test - - org.testcontainers - junit-jupiter - ${testcontainers.version} - test - From a75ea571fcb7ad454f5fd3eba93b9e6e96c68a4f Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Mon, 9 Nov 2026 03:34:24 +0000 Subject: [PATCH 4/6] fix(database): map UUIDs with native database type --- src/main/java/com/jobtracker/entity/Achievement.java | 2 +- src/main/java/com/jobtracker/entity/ExportExecution.java | 2 +- src/main/java/com/jobtracker/entity/ExportSchedule.java | 2 +- .../com/jobtracker/entity/GoogleDriveBaseInformation.java | 2 +- .../java/com/jobtracker/entity/GoogleDriveBaseResume.java | 2 +- .../java/com/jobtracker/entity/GoogleDriveConnection.java | 2 +- .../java/com/jobtracker/entity/GoogleDriveOAuthState.java | 2 +- src/main/java/com/jobtracker/entity/InterviewEvent.java | 2 +- src/main/java/com/jobtracker/entity/JobApplication.java | 4 ++-- src/main/java/com/jobtracker/entity/PasswordResetToken.java | 2 +- src/main/java/com/jobtracker/entity/RefreshToken.java | 2 +- src/main/java/com/jobtracker/entity/User.java | 2 +- src/main/java/com/jobtracker/entity/UserAchievement.java | 2 +- src/main/java/com/jobtracker/entity/UserGamification.java | 2 +- src/main/java/com/jobtracker/entity/UserInterviewMetrics.java | 2 +- src/main/java/com/jobtracker/entity/WebAuthnChallenge.java | 2 +- src/main/java/com/jobtracker/entity/WebAuthnCredential.java | 2 +- 17 files changed, 18 insertions(+), 18 deletions(-) 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..e64e73b3 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) 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) From d4464ecf0e06b777600576e03ed0233d77468639 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Mon, 9 Nov 2026 03:38:14 +0000 Subject: [PATCH 5/6] fix(database): remove MariaDB boolean column type --- src/main/java/com/jobtracker/entity/User.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/jobtracker/entity/User.java b/src/main/java/com/jobtracker/entity/User.java index e64e73b3..1b057216 100644 --- a/src/main/java/com/jobtracker/entity/User.java +++ b/src/main/java/com/jobtracker/entity/User.java @@ -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") From bf50d9812ac80106b853036f8e69cb1d2c8cd22c Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Mon, 9 Nov 2026 03:41:47 +0000 Subject: [PATCH 6/6] fix(test): match PostgreSQL OAuth text columns --- src/test/resources/schema.sql | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 0c537dc2..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 BYTEA, + attributes TEXT, state VARCHAR(500), - authorization_code_value BYTEA, + authorization_code_value TEXT, authorization_code_issued_at TIMESTAMP, authorization_code_expires_at TIMESTAMP, - authorization_code_metadata BYTEA, - access_token_value BYTEA, + authorization_code_metadata TEXT, + access_token_value TEXT, access_token_issued_at TIMESTAMP, access_token_expires_at TIMESTAMP, - access_token_metadata BYTEA, + access_token_metadata TEXT, access_token_type VARCHAR(100), access_token_scopes VARCHAR(1000), - oidc_id_token_value BYTEA, + oidc_id_token_value TEXT, oidc_id_token_issued_at TIMESTAMP, oidc_id_token_expires_at TIMESTAMP, - oidc_id_token_metadata BYTEA, - refresh_token_value BYTEA, + oidc_id_token_metadata TEXT, + refresh_token_value TEXT, refresh_token_issued_at TIMESTAMP, refresh_token_expires_at TIMESTAMP, - refresh_token_metadata BYTEA, - user_code_value BYTEA, + refresh_token_metadata TEXT, + user_code_value TEXT, user_code_issued_at TIMESTAMP, user_code_expires_at TIMESTAMP, - user_code_metadata BYTEA, - device_code_value BYTEA, + user_code_metadata TEXT, + device_code_value TEXT, device_code_issued_at TIMESTAMP, device_code_expires_at TIMESTAMP, - device_code_metadata BYTEA + device_code_metadata TEXT ); CREATE TABLE IF NOT EXISTS oauth2_authorization_consent (