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/data-migration-tests.yml b/.github/workflows/data-migration-tests.yml
new file mode 100644
index 00000000..31845f73
--- /dev/null
+++ b/.github/workflows/data-migration-tests.yml
@@ -0,0 +1,29 @@
+name: MariaDB to PostgreSQL migrator tests
+
+on:
+ pull_request:
+ paths:
+ - "scripts/migrate_mariadb_to_postgresql.py"
+ - "scripts/requirements-migrate-postgresql.txt"
+ - "tests/test_migrate_mariadb_to_postgresql.py"
+ - ".github/workflows/data-migration-tests.yml"
+ push:
+ branches: [main]
+ paths:
+ - "scripts/migrate_mariadb_to_postgresql.py"
+ - "tests/test_migrate_mariadb_to_postgresql.py"
+ - ".github/workflows/data-migration-tests.yml"
+ workflow_dispatch:
+
+jobs:
+ unit-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+ - name: Compile and run isolated unit tests
+ run: |
+ python -m compileall -q scripts/migrate_mariadb_to_postgresql.py
+ python -m unittest discover -s tests -p 'test_migrate_mariadb_to_postgresql.py' -v
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..26dae719 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,54 @@ 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 PostgreSQL backend uses native UUID columns, PostgreSQL Flyway migrations V1-V36 and a generated full-text index. **Flyway migrates the schema, not the existing MariaDB records.** For a one-time data cutover, use [scripts/migrate_mariadb_to_postgresql.py](scripts/migrate_mariadb_to_postgresql.py). It uses PyMySQL + psycopg 3 (streaming PostgreSQL COPY), without pgloader.
+
+### Prerequisites
+
+1. Verify the MariaDB backup can be restored, and restore it into an **isolated, read-only MariaDB source**. The Python tool connects to a running MariaDB instance; it does **not** parse a .sql/.gz dump directly.
+2. Provision a **new, dedicated, empty PostgreSQL database**. Start the new backend **once** with this database to let Flyway apply V1-V36, then **stop it**. Disable app seeding/scheduled jobs while preparing the target. Only the nine Flyway-seeded `application_statuses` rows are allowed before import.
+3. Keep the application stopped so nothing writes to PostgreSQL during the copy. Confirm the MariaDB backup and database version represent the latest application data. Maintain the old MariaDB backup and old backend image for rollback.
+4. Use least-privilege credentials: read-only MariaDB user and a PostgreSQL user allowed to COPY/DELETE and reset the identity sequence. Do not commit credentials to the repository.
+
+### Install and run
+
+From repository root on a host that can reach **both** database instances:
+
+```bash
+python3 -m venv .venv
+. .venv/bin/activate
+python -m pip install -r scripts/requirements-migrate-postgresql.txt
+
+export MIGRATE_MYSQL_HOST=127.0.0.1
+export MIGRATE_MYSQL_PORT=3306
+export MIGRATE_MYSQL_DATABASE=jobtracker
+export MIGRATE_MYSQL_USER=jobtracker
+read -rsp 'MariaDB backup password: ' MIGRATE_MYSQL_PASSWORD; echo; export MIGRATE_MYSQL_PASSWORD
+
+export MIGRATE_PG_HOST=127.0.0.1
+export MIGRATE_PG_PORT=5432
+export MIGRATE_PG_DATABASE=jobtracker
+export MIGRATE_PG_USER=jobtracker
+read -rsp 'PostgreSQL password: ' MIGRATE_PG_PASSWORD; echo; export MIGRATE_PG_PASSWORD
+
+# Default: validate schema, Flyway history and destination emptiness; no writes.
+python scripts/migrate_mariadb_to_postgresql.py
+
+# Only after preflight succeeds and backup restore is verified:
+python scripts/migrate_mariadb_to_postgresql.py --execute
+```
+
+The tool validates exact source/target table and writable-column sets, refuses unexpected destination records, excludes Flyway history and generated search vectors, copies data in FK dependency order, converts MariaDB BINARY(16) UUIDs to native UUIDs and TINYINT(1) booleans, and compares order-independent SHA-256 content fingerprints for all copied columns in every table. It restores PostgreSQL identity sequences after verification, then commits. Errors roll back data modifications; **sequence changes are not transactional**, so the empty destination should be discarded and rebuilt after a failed attempt. It will not overwrite an existing populated ApplyWell PostgreSQL database.
+
+**Important:** This is a one-time cutover tool. Its unit tests cover pure conversion and validation logic, but **it has not been tested against your production backup**. Test the entire procedure on isolated instances before production.
+
+### Cutover / rollback
+
+1. For the final cutover, stop writes to the old MariaDB and restore a **final** consistent backup into the isolated MariaDB source. Recreate an empty PostgreSQL target and allow Flyway V1-V36 to initialize it before running the Python migrator.
+2. Confirm counts, UUID samples, referential integrity, login, Google Drive, OAuth, MCP, assistant search and export using the new backend and migrated data.
+3. Switch traffic to the PostgreSQL-backed new backend only after validation; do not run both backends writing in parallel.
+4. For rollback, **stop the new backend, restore the old MariaDB-compatible backend image and its DB_URL**, and reconcile any writes accepted after cutover. Repointing the new PostgreSQL-only application at MariaDB is not supported. Keep MariaDB and the backups until acceptance.
+
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/scripts/migrate_mariadb_to_postgresql.py b/scripts/migrate_mariadb_to_postgresql.py
new file mode 100644
index 00000000..cd77fe7b
--- /dev/null
+++ b/scripts/migrate_mariadb_to_postgresql.py
@@ -0,0 +1,355 @@
+#!/usr/bin/env python3
+"""One-time ApplyWell MariaDB -> PostgreSQL data cutover.
+
+The PostgreSQL schema must already have been created by Flyway V1-V36.
+The MariaDB source should be restored from a verified backup and kept immutable.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass
+from datetime import date, datetime, time
+from decimal import Decimal
+from uuid import UUID
+
+# The dependencies are loaded only when connecting; pure helpers can be unit-tested
+# without a running MariaDB or PostgreSQL instance.
+
+EXCLUDED = {"flyway_schema_history"}
+SEED_STATUSES = (
+ (1, "RH", 1),
+ (2, "Pending HR Response", 2),
+ (3, "Pending Hiring Manager Response", 3),
+ (4, "Technical Test", 4),
+ (5, "Pending Technical Test Response", 5),
+ (6, "Offer Negotiation", 6),
+ (7, "Ghosting", 7),
+ (8, "Rejected", 8),
+ (9, "Approved", 9),
+)
+NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
+
+
+@dataclass(frozen=True)
+class Column:
+ name: str
+ pg_type: str
+
+
+def safe_name(name: str) -> str:
+ if not NAME.fullmatch(name):
+ raise ValueError(f"Unexpected identifier {name!r}")
+ return name
+
+
+def convert(value: object, pg_type: str) -> object:
+ """Convert values using the *target* type, never a BINARY(16) guess."""
+ if value is None:
+ return None
+ if isinstance(value, memoryview):
+ value = value.tobytes()
+ if pg_type == "uuid":
+ if isinstance(value, UUID):
+ return value
+ if isinstance(value, (bytes, bytearray)):
+ if len(value) != 16:
+ raise ValueError(f"Expected 16 bytes for UUID, got {len(value)}")
+ return UUID(bytes=bytes(value))
+ return UUID(str(value))
+ if pg_type == "bool":
+ if isinstance(value, (bytes, bytearray)):
+ value = int.from_bytes(value, "big")
+ if value not in (0, 1, False, True):
+ raise ValueError(f"Invalid boolean value {value!r}")
+ return bool(value)
+ if pg_type in {"text", "varchar", "bpchar"} and isinstance(value, (bytes, bytearray)):
+ # Never silently corrupt OAuth payloads (MariaDB BLOB -> PostgreSQL TEXT).
+ return bytes(value).decode("utf-8", errors="strict")
+ return value
+
+
+def canonical(value: object) -> object:
+ if value is None:
+ return None
+ if isinstance(value, UUID):
+ return ["uuid", str(value)]
+ if isinstance(value, (datetime, date, time)):
+ return ["temporal", value.isoformat(timespec="microseconds") if isinstance(value, (datetime, time)) else value.isoformat()]
+ if isinstance(value, Decimal):
+ return ["decimal", str(value)]
+ if isinstance(value, (bytes, bytearray, memoryview)):
+ return ["binary", bytes(value).hex()]
+ if isinstance(value, (int, bool, float, str)):
+ return value
+ raise TypeError(f"Cannot fingerprint value of type {type(value).__name__}")
+
+
+class Fingerprint:
+ """Order-independent content check, including every non-generated column."""
+
+ def __init__(self) -> None:
+ self.count = 0
+ self.total = 0
+ self.xor = 0
+
+ def add(self, row: tuple[object, ...]) -> None:
+ encoded = json.dumps([canonical(v) for v in row], ensure_ascii=False, separators=(",", ":"))
+ digest = int.from_bytes(hashlib.sha256(encoded.encode("utf-8")).digest(), "big")
+ self.total = (self.total + digest) % (1 << 256)
+ self.xor ^= digest
+ self.count += 1
+
+ def summary(self) -> tuple[int, int, int]:
+ return self.count, self.total, self.xor
+
+
+def dependency_order(tables: set[str], foreign_keys: list[tuple[str, str]]) -> list[str]:
+ parents = {name: set() for name in tables}
+ for child, parent in foreign_keys:
+ if child in tables and parent in tables and child != parent:
+ parents[child].add(parent)
+ result: list[str] = []
+ while parents:
+ ready = sorted(name for name, deps in parents.items() if not deps)
+ if not ready:
+ raise ValueError(f"Cyclic foreign keys: {sorted(parents)}")
+ for name in ready:
+ result.append(name)
+ del parents[name]
+ for deps in parents.values():
+ deps.difference_update(ready)
+ return result
+
+
+def query_rows(conn: object, statement: str, params: tuple = ()) -> list[tuple]:
+ with conn.cursor() as cursor:
+ cursor.execute(statement, params)
+ return list(cursor.fetchall())
+
+
+def source_schema(conn: object, db: str) -> tuple[set[str], dict[str, set[str]]]:
+ rows = query_rows(
+ conn,
+ "SELECT table_name FROM information_schema.tables "
+ "WHERE table_schema = %s AND table_type = 'BASE TABLE'",
+ (db,),
+ )
+ tables = {safe_name(row[0]) for row in rows} - EXCLUDED
+ rows = query_rows(
+ conn,
+ "SELECT table_name, column_name FROM information_schema.columns "
+ "WHERE table_schema = %s ORDER BY ordinal_position",
+ (db,),
+ )
+ columns: dict[str, set[str]] = {table: set() for table in tables}
+ for table, name in rows:
+ if table in tables:
+ columns[table].add(safe_name(name))
+ return tables, columns
+
+
+def target_schema(conn: object) -> tuple[set[str], dict[str, list[Column]]]:
+ rows = query_rows(
+ conn,
+ "SELECT table_name, column_name, udt_name, is_generated "
+ "FROM information_schema.columns WHERE table_schema = 'public' "
+ "ORDER BY table_name, ordinal_position",
+ )
+ columns: dict[str, list[Column]] = {}
+ for table, name, pg_type, generated in rows:
+ safe_name(table)
+ safe_name(name)
+ columns.setdefault(table, [])
+ if generated != "ALWAYS":
+ columns[table].append(Column(name, pg_type))
+ return set(columns) - EXCLUDED, columns
+
+
+def ensure_schema(source_tables: set[str], source_cols: dict[str, set[str]],
+ target_tables: set[str], target_cols: dict[str, list[Column]]) -> None:
+ if source_tables != target_tables:
+ raise ValueError(
+ f"Schema table mismatch: source only={sorted(source_tables - target_tables)}, "
+ f"target only={sorted(target_tables - source_tables)}"
+ )
+ for table in sorted(source_tables):
+ source_names = source_cols[table]
+ target_names = {col.name for col in target_cols[table]}
+ if source_names != target_names:
+ raise ValueError(
+ f"{table}: column mismatch: source only={sorted(source_names - target_names)}, "
+ f"target only={sorted(target_names - source_names)}"
+ )
+
+
+def inspect_target(conn: object, tables: set[str], sql: object) -> None:
+ """Allow only the 9 status rows inserted by Flyway V29."""
+ history = query_rows(
+ conn,
+ "SELECT version, success FROM public.flyway_schema_history "
+ "ORDER BY installed_rank DESC LIMIT 1",
+ )
+ if not history or history[0] != ("36", True):
+ raise ValueError("Target must be a fresh database already migrated by Flyway through V36")
+ for table in sorted(tables):
+ count_query = sql.SQL("SELECT COUNT(*) FROM public.{}").format(sql.Identifier(table))
+ with conn.cursor() as cursor:
+ cursor.execute(count_query)
+ count = cursor.fetchone()[0]
+ if table == "application_statuses":
+ if count != len(SEED_STATUSES):
+ raise ValueError("Target application_statuses differs from Flyway V29 seed")
+ actual = query_rows(conn, "SELECT id, name, display_order FROM public.application_statuses ORDER BY id")
+ if tuple(actual) != SEED_STATUSES:
+ raise ValueError("Target application_statuses has been modified; refusing overwrite")
+ elif count:
+ raise ValueError(f"Target {table} already contains {count} records; refusing to overwrite")
+
+
+def fix_sequences(conn: object, tables: list[str], sql: object) -> None:
+ """Repair identity/serial values after importing explicit IDs."""
+ rows = query_rows(
+ conn,
+ "SELECT table_name, column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND "
+ "(is_identity = 'YES' OR column_default LIKE 'nextval(%')",
+ )
+ with conn.cursor() as cursor:
+ for table, column in rows:
+ if table not in tables:
+ continue
+ cursor.execute("SELECT pg_get_serial_sequence(%s, %s)", (f"public.{table}", column))
+ seq = cursor.fetchone()[0]
+ if seq:
+ cursor.execute(
+ sql.SQL("SELECT MAX({}) FROM public.{}").format(sql.Identifier(column), sql.Identifier(table))
+ )
+ highest = cursor.fetchone()[0]
+ # setval is not rolled back. Called only after all data checks pass.
+ cursor.execute("SELECT setval(%s::regclass, %s, %s)", (seq, highest or 1, highest is not None))
+
+
+def migrate(source: object, target: object, source_db: str, execute: bool, batch_size: int) -> None:
+ from psycopg import sql
+ import pymysql
+
+ # MariaDB remains read-only, with one consistent InnoDB snapshot.
+ with source.cursor() as cursor:
+ cursor.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
+ cursor.execute("START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY")
+ try:
+ source_tables, source_cols = source_schema(source, source_db)
+ target_tables, target_cols = target_schema(target)
+ ensure_schema(source_tables, source_cols, target_tables, target_cols)
+ inspect_target(target, target_tables, sql)
+ foreign_keys = query_rows(
+ target,
+ "SELECT child.relname, parent.relname FROM pg_constraint fk "
+ "JOIN pg_class child ON child.oid = fk.conrelid "
+ "JOIN pg_class parent ON parent.oid = fk.confrelid "
+ "JOIN pg_namespace ns ON ns.oid = child.relnamespace "
+ "WHERE fk.contype = 'f' AND ns.nspname = 'public'",
+ )
+ order = dependency_order(target_tables, foreign_keys)
+ print(f"Source tables: {len(order)} | destination: Flyway V36 | mode: {'EXECUTE' if execute else 'CHECK ONLY'}")
+ if not execute:
+ for table in order:
+ print(f" {table}")
+ print("No data changed. Re-run with --execute after backup validation.")
+ return
+
+ # Statuses are the sole Flyway-seeded dataset and must be replaced atomically.
+ with target.cursor() as cursor:
+ cursor.execute("DELETE FROM public.application_statuses")
+ total_rows = 0
+ for table in order:
+ cols = target_cols[table]
+ names = [col.name for col in cols]
+ tick = chr(96) # MariaDB identifier quote
+ select = "SELECT " + ", ".join(f"{tick}{name}{tick}" for name in names) + f" FROM {tick}{table}{tick}"
+ copy_sql = sql.SQL("COPY public.{} ({}) FROM STDIN").format(
+ sql.Identifier(table), sql.SQL(", ").join(map(sql.Identifier, names))
+ )
+ before = Fingerprint()
+ with source.cursor(pymysql.cursors.SSCursor) as src_cursor:
+ src_cursor.execute(select)
+ with target.cursor() as dst_cursor, dst_cursor.copy(copy_sql) as stream:
+ while batch := src_cursor.fetchmany(batch_size):
+ for record in batch:
+ normalized = tuple(convert(value, col.pg_type) for value, col in zip(record, cols, strict=True))
+ stream.write_row(normalized)
+ before.add(normalized)
+ after = Fingerprint()
+ verify_sql = sql.SQL("SELECT {} FROM public.{}").format(
+ sql.SQL(", ").join(map(sql.Identifier, names)), sql.Identifier(table)
+ )
+ with target.cursor(name=f"verify_{table}") as verify_cursor:
+ verify_cursor.execute(verify_sql)
+ while batch := verify_cursor.fetchmany(batch_size):
+ for record in batch:
+ after.add(tuple(record))
+ if before.summary() != after.summary():
+ raise ValueError(f"Data verification failed for {table}: {before.count} vs {after.count} rows or contents")
+ total_rows += before.count
+ print(f" {table}: {before.count} rows, SHA-256 fingerprints match")
+
+ fix_sequences(target, order, sql)
+ target.commit()
+ print(f"COMMITTED: {total_rows} records copied and verified across {len(order)} tables")
+ print("Keep the MariaDB backup; validate login, OAuth, MCP and exports before switching traffic.")
+ except BaseException:
+ target.rollback()
+ raise
+ finally:
+ source.rollback()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--execute", action="store_true", help="Actually write to the empty Flyway-prepared PostgreSQL")
+ parser.add_argument("--batch-size", type=int, default=500)
+ args = parser.parse_args()
+ if args.batch_size <= 0:
+ parser.error("--batch-size must be positive")
+
+ import pymysql
+ import psycopg
+
+ mysql_db = os.environ.get("MIGRATE_MYSQL_DATABASE", "jobtracker")
+ mysql_password = os.environ.get("MIGRATE_MYSQL_PASSWORD")
+ pg_password = os.environ.get("MIGRATE_PG_PASSWORD")
+ if not mysql_password or not pg_password:
+ parser.error("MIGRATE_MYSQL_PASSWORD and MIGRATE_PG_PASSWORD environment variables are required")
+ # Connection details are never printed or embedded in the repository.
+ with pymysql.connect(
+ host=os.environ.get("MIGRATE_MYSQL_HOST", "127.0.0.1"),
+ port=int(os.environ.get("MIGRATE_MYSQL_PORT", "3306")),
+ user=os.environ.get("MIGRATE_MYSQL_USER", "jobtracker"),
+ password=mysql_password,
+ database=mysql_db,
+ charset="utf8mb4",
+ autocommit=False,
+ ) as source, psycopg.connect(
+ host=os.environ.get("MIGRATE_PG_HOST", "127.0.0.1"),
+ port=int(os.environ.get("MIGRATE_PG_PORT", "5432")),
+ user=os.environ.get("MIGRATE_PG_USER", "jobtracker"),
+ password=pg_password,
+ dbname=os.environ.get("MIGRATE_PG_DATABASE", "jobtracker"),
+ autocommit=False,
+ ) as target:
+ migrate(source, target, mysql_db, args.execute, args.batch_size)
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as exc:
+ print(f"Migration aborted: {exc}", file=sys.stderr)
+ sys.exit(1)
diff --git a/scripts/requirements-migrate-postgresql.txt b/scripts/requirements-migrate-postgresql.txt
new file mode 100644
index 00000000..d412bd65
--- /dev/null
+++ b/scripts/requirements-migrate-postgresql.txt
@@ -0,0 +1,2 @@
+PyMySQL>=1.1,<2
+psycopg[binary]>=3.2,<4
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 (
diff --git a/tests/test_migrate_mariadb_to_postgresql.py b/tests/test_migrate_mariadb_to_postgresql.py
new file mode 100644
index 00000000..afb93994
--- /dev/null
+++ b/tests/test_migrate_mariadb_to_postgresql.py
@@ -0,0 +1,64 @@
+"""Pure tests for the one-time data migration (no database required)."""
+
+import importlib.util
+import unittest
+from pathlib import Path
+from uuid import UUID
+
+PATH = Path(__file__).resolve().parents[1] / "scripts" / "migrate_mariadb_to_postgresql.py"
+spec = importlib.util.spec_from_file_location("applywell_migrator", PATH)
+m = importlib.util.module_from_spec(spec)
+import sys
+sys.modules[spec.name] = m
+spec.loader.exec_module(m)
+
+
+class MigratorTests(unittest.TestCase):
+ def test_uuid_binary_roundtrip_and_invalid_width(self):
+ original = UUID("12345678-1234-5678-9abc-def012345678")
+ self.assertEqual(m.convert(original.bytes, "uuid"), original)
+ self.assertEqual(m.convert(str(original), "uuid"), original)
+ with self.assertRaises(ValueError):
+ m.convert(b"1234", "uuid")
+
+ def test_boolean_strict(self):
+ for raw in (0, b"\x00", False):
+ self.assertIs(m.convert(raw, "bool"), False)
+ for raw in (1, b"\x01", True):
+ self.assertIs(m.convert(raw, "bool"), True)
+ with self.assertRaises(ValueError):
+ m.convert(2, "bool")
+
+ def test_oauth_bytes_never_silently_corrupted(self):
+ self.assertEqual(m.convert(b'{"a":1}', "text"), '{"a":1}')
+ with self.assertRaises(UnicodeDecodeError):
+ m.convert(b"\xff", "text")
+
+ def test_content_fingerprint_is_order_independent(self):
+ a, b = m.Fingerprint(), m.Fingerprint()
+ data = [("abc", True, 12), ("def", False, 13)]
+ for row in data:
+ a.add(row)
+ for row in reversed(data):
+ b.add(row)
+ self.assertEqual(a.summary(), b.summary())
+ b.add(data[0])
+ self.assertNotEqual(a.summary(), b.summary())
+
+ def test_foreign_key_order(self):
+ order = m.dependency_order({"users", "job_applications", "refresh_tokens"},
+ [("job_applications", "users"), ("refresh_tokens", "users")])
+ self.assertEqual(order[0], "users")
+ with self.assertRaises(ValueError):
+ m.dependency_order({"a", "b"}, [("a", "b"), ("b", "a")])
+
+ def test_schema_mismatch_fails_closed(self):
+ with self.assertRaises(ValueError):
+ m.ensure_schema({"a"}, {"a": {"id"}}, {"b"}, {"b": [m.Column("id", "uuid")]})
+ with self.assertRaises(ValueError):
+ m.ensure_schema({"a"}, {"a": {"id", "secret"}},
+ {"a"}, {"a": [m.Column("id", "uuid")]})
+
+
+if __name__ == "__main__":
+ unittest.main()