Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 11 additions & 12 deletions .github/workflows/load-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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" \
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
>
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 19 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -38,3 +53,6 @@ services:
timeout: 10s
retries: 5
start_period: 60s

volumes:
postgres_data:
13 changes: 4 additions & 9 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,8 @@

<!-- JDBC Driver -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

Expand All @@ -92,7 +87,7 @@
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>

<!-- JWT -->
Expand Down Expand Up @@ -293,7 +288,7 @@
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mariadb</artifactId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/Achievement.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/ExportExecution.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/ExportSchedule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/InterviewEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/jobtracker/entity/JobApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/RefreshToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/jobtracker/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/UserAchievement.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/UserGamification.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading