Add production-ready Spring Boot REST API for Job Apply Tracker#1
Conversation
Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/d4ef3360-4360-4e5e-8c1d-ad3f70c38ed2 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com>
… logging, explain CSRF disablement Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/d4ef3360-4360-4e5e-8c1d-ad3f70c38ed2 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new Spring Boot (Java 21) backend for a Job Application Tracker PWA, including JWT-based auth (with refresh token rotation), password reset flow, job application CRUD + filtering, and a dashboard summary, backed by MariaDB/Flyway.
Changes:
- Introduces JWT auth endpoints (register/login/refresh/logout/me) plus password reset token flow.
- Implements job application persistence + CRUD, filtering/pagination/sorting, and upcoming/overdue queries.
- Adds Flyway V1 schema, Spring Security/CORS configuration, and OpenAPI (Swagger UI) setup.
Reviewed changes
Copilot reviewed 51 out of 52 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
.gitignore |
Adds Maven/target ignores. |
backend/pom.xml |
Defines Spring Boot/JPA/Security/Flyway/JJWT/OpenAPI dependencies. |
backend/src/main/java/com/jobtracker/JobTrackerApplication.java |
Spring Boot application entrypoint. |
backend/src/main/java/com/jobtracker/config/CorsConfig.java |
Configures CORS origins/methods/headers. |
backend/src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java |
Adds bearer JWT auth filter populating SecurityContext. |
backend/src/main/java/com/jobtracker/config/JwtService.java |
JWT signing/verification + claim extraction utilities. |
backend/src/main/java/com/jobtracker/config/OpenApiConfig.java |
Configures OpenAPI + bearer auth scheme. |
backend/src/main/java/com/jobtracker/config/SecurityConfig.java |
Configures stateless Spring Security + route authorization. |
backend/src/main/java/com/jobtracker/controller/ApplicationController.java |
Exposes application CRUD/filtering + upcoming/overdue endpoints. |
backend/src/main/java/com/jobtracker/controller/AuthController.java |
Exposes auth/password reset endpoints + /me. |
backend/src/main/java/com/jobtracker/controller/DashboardController.java |
Exposes dashboard summary endpoint. |
backend/src/main/java/com/jobtracker/dto/application/ApplicationPageResponse.java |
Paging response DTO. |
backend/src/main/java/com/jobtracker/dto/application/ApplicationRequest.java |
Application create/update request DTO + validations. |
backend/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java |
Application response DTO. |
backend/src/main/java/com/jobtracker/dto/application/UpdateReminderRequest.java |
Reminder toggle request DTO. |
backend/src/main/java/com/jobtracker/dto/application/UpdateStatusRequest.java |
Status update request DTO. |
backend/src/main/java/com/jobtracker/dto/auth/AuthResponse.java |
Auth response DTO (access/refresh/user). |
backend/src/main/java/com/jobtracker/dto/auth/ForgotPasswordRequest.java |
Forgot-password request DTO. |
backend/src/main/java/com/jobtracker/dto/auth/LoginRequest.java |
Login request DTO. |
backend/src/main/java/com/jobtracker/dto/auth/LogoutRequest.java |
Logout request DTO. |
backend/src/main/java/com/jobtracker/dto/auth/MessageResponse.java |
Generic message response DTO. |
backend/src/main/java/com/jobtracker/dto/auth/RefreshResponse.java |
Refresh response DTO. |
backend/src/main/java/com/jobtracker/dto/auth/RefreshTokenRequest.java |
Refresh request DTO. |
backend/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java |
Register request DTO. |
backend/src/main/java/com/jobtracker/dto/auth/ResetPasswordRequest.java |
Reset-password request DTO. |
backend/src/main/java/com/jobtracker/dto/auth/UserResponse.java |
User response DTO. |
backend/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java |
Dashboard summary DTO. |
backend/src/main/java/com/jobtracker/entity/JobApplication.java |
JPA entity for job applications. |
backend/src/main/java/com/jobtracker/entity/PasswordResetToken.java |
JPA entity for password reset tokens. |
backend/src/main/java/com/jobtracker/entity/RefreshToken.java |
JPA entity for refresh tokens. |
backend/src/main/java/com/jobtracker/entity/User.java |
JPA entity for users. |
backend/src/main/java/com/jobtracker/entity/enums/ApplicationStatus.java |
Status enum with PT display-name mapping. |
backend/src/main/java/com/jobtracker/exception/BadRequestException.java |
Custom 400 exception. |
backend/src/main/java/com/jobtracker/exception/ConflictException.java |
Custom 409 exception. |
backend/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java |
Global exception-to-JSON response mapping. |
backend/src/main/java/com/jobtracker/exception/ResourceNotFoundException.java |
Custom 404 exception. |
backend/src/main/java/com/jobtracker/exception/UnauthorizedException.java |
Custom 401 exception. |
backend/src/main/java/com/jobtracker/mapper/ApplicationMapper.java |
Maps application entity → response DTO. |
backend/src/main/java/com/jobtracker/mapper/AuthMapper.java |
Maps user entity → response DTO. |
backend/src/main/java/com/jobtracker/repository/ApplicationRepository.java |
JPA repository + custom queries for applications. |
backend/src/main/java/com/jobtracker/repository/PasswordResetTokenRepository.java |
JPA repository + invalidation query for reset tokens. |
backend/src/main/java/com/jobtracker/repository/RefreshTokenRepository.java |
JPA repository + bulk revoke query for refresh tokens. |
backend/src/main/java/com/jobtracker/repository/UserRepository.java |
JPA repository for users. |
backend/src/main/java/com/jobtracker/service/ApplicationService.java |
Application business logic + filtering/sort spec. |
backend/src/main/java/com/jobtracker/service/AuthService.java |
Auth, refresh rotation, forgot/reset password logic. |
backend/src/main/java/com/jobtracker/service/DashboardService.java |
Computes dashboard summary counts. |
backend/src/main/java/com/jobtracker/service/PasswordResetService.java |
Manages reset token creation/validation/use. |
backend/src/main/java/com/jobtracker/service/RefreshTokenService.java |
Manages refresh token issuance/rotation/revocation. |
backend/src/main/java/com/jobtracker/util/SecurityUtils.java |
Fetches current user/current user id from SecurityContext. |
backend/src/main/resources/application.yml |
App configuration for DB/JPA/Flyway/JWT/CORS/OpenAPI. |
backend/src/main/resources/db/migration/V1__initial_schema.sql |
Initial MariaDB schema for users/tokens/applications. |
backend/src/test/java/com/jobtracker/JobTrackerApplicationTests.java |
Spring context smoke test with H2 overrides. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public RefreshToken verifyAndRotate(String tokenValue) { | ||
| RefreshToken token = refreshTokenRepository.findByToken(tokenValue) | ||
| .orElseThrow(() -> new UnauthorizedException("Invalid refresh token")); | ||
|
|
||
| if (token.isRevoked()) { | ||
| throw new UnauthorizedException("Refresh token has been revoked"); | ||
| } | ||
|
|
||
| if (token.getExpiryDate().isBefore(LocalDateTime.now())) { | ||
| throw new UnauthorizedException("Refresh token has expired"); | ||
| } | ||
|
|
||
| // Revoke old token (rotation) | ||
| token.setRevoked(true); | ||
| refreshTokenRepository.save(token); | ||
|
|
||
| // Create new token | ||
| return createRefreshToken(token.getUser()); | ||
| } |
There was a problem hiding this comment.
verifyAndRotate revokes the old refresh token and then creates a new one, but there’s no locking/atomic update to prevent two concurrent refresh requests from rotating the same token twice (both can pass revoked=false and expiryDate checks). This enables refresh-token replay and can mint multiple valid tokens from a single refresh token. Consider adding optimistic locking (@Version), a SELECT ... FOR UPDATE/@Lock(PESSIMISTIC_WRITE) on lookup, or an atomic UPDATE ... WHERE token=:token AND revoked=false with affected-row check before issuing the new token.
| RefreshToken refreshToken = new RefreshToken(); | ||
| refreshToken.setToken(UUID.randomUUID().toString()); | ||
| refreshToken.setExpiryDate(LocalDateTime.now().plusNanos(refreshTokenExpirationMs * 1_000_000)); | ||
| refreshToken.setRevoked(false); | ||
| refreshToken.setUser(user); | ||
| return refreshTokenRepository.save(refreshToken); |
There was a problem hiding this comment.
Refresh token expiry is computed via plusNanos(refreshTokenExpirationMs * 1_000_000). This mixes units and can overflow for larger values; it’s also harder to read than a duration-based approach. Prefer LocalDateTime.now().plus(Duration.ofMillis(refreshTokenExpirationMs)) (or plusMillis) for correctness and clarity.
| int page, int size, String sort) { | ||
| Long userId = securityUtils.getCurrentUserId(); | ||
|
|
||
| Sort sortObj = buildSort(sort); | ||
| Pageable pageable = PageRequest.of(page, size, sortObj); | ||
|
|
There was a problem hiding this comment.
PageRequest.of(page, size, ...) will throw IllegalArgumentException for negative page or non-positive size (and very large size can be abused). Since these request params aren’t validated, this can currently surface as a 500 via the generic exception handler. Add bean validation (e.g., @Min(0) / @Min(1) / @Max(...) with @Validated), or catch/translate IllegalArgumentException to a 400.
| long interviewsScheduled = applicationRepository.countByUserIdAndInterviewScheduledTrue(userId); | ||
|
|
||
| long overdueFollowUps = applicationRepository.findOverdueByUserId(userId, LocalDateTime.now()).size(); | ||
|
|
||
| long dmRemindersEnabled = applicationRepository.countByUserIdAndRecruiterDmReminderEnabledTrue(userId); |
There was a problem hiding this comment.
overdueFollowUps is computed by loading the full overdue list and calling .size(), which can become expensive for users with many applications. Prefer a repository COUNT query (e.g., countOverdueByUserId(...)) so the DB counts without materializing rows.
| CorsConfiguration configuration = new CorsConfiguration(); | ||
| List<String> origins = Arrays.stream(allowedOrigins.split(",")) | ||
| .map(String::trim) | ||
| .toList(); | ||
| configuration.setAllowedOriginPatterns(origins); | ||
| configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); | ||
| configuration.setAllowedHeaders(List.of("*")); | ||
| configuration.setAllowCredentials(true); | ||
| configuration.setMaxAge(3600L); |
There was a problem hiding this comment.
Using setAllowedOriginPatterns together with allowCredentials(true) is risky because origin patterns can unintentionally broaden to wildcard-like matches, effectively allowing credentialed requests from more origins than intended. If you expect exact origins, prefer setAllowedOrigins (exact match) or validate/restrict patterns to avoid */overly broad patterns.
| userRepository.findByEmail(request.email()).ifPresent(user -> { | ||
| PasswordResetToken token = passwordResetService.createResetToken(user); | ||
| // In a real application, you would send an email with the reset token | ||
| // For now, we log it (do not expose token in response for security) | ||
| }); |
There was a problem hiding this comment.
The comment says the reset token is logged, but the code neither logs it nor uses the token variable. This is misleading and may trigger unused-variable warnings. Either remove the variable/comment, or add an explicit (ideally debug-level) log/event hook for delivering the token (email) in real deployments.
| public User getCurrentUser() { | ||
| String email = SecurityContextHolder.getContext().getAuthentication().getName(); | ||
| return userRepository.findByEmail(email) | ||
| .orElseThrow(() -> new UnauthorizedException("User not found")); | ||
| } |
There was a problem hiding this comment.
getCurrentUser() assumes SecurityContextHolder.getContext().getAuthentication() is non-null; if it’s called without an authenticated request context this will NPE rather than returning a controlled 401/Unauthorized exception. Consider explicitly checking for a null/unauthenticated Authentication and throwing UnauthorizedException with a clearer message.
| @Bean | ||
| public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { | ||
| http | ||
| .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource())) | ||
| .csrf(AbstractHttpConfigurer::disable) // CSRF protection disabled for stateless JWT REST API | ||
| .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
| .authorizeHttpRequests(auth -> auth | ||
| .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() | ||
| .requestMatchers("/api/auth/register", "/api/auth/login", | ||
| "/api/auth/refresh", "/api/auth/forgot-password", | ||
| "/api/auth/reset-password").permitAll() | ||
| .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() | ||
| .anyRequest().authenticated() | ||
| ) | ||
| .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); |
There was a problem hiding this comment.
Unauthenticated/forbidden responses generated by Spring Security (e.g., missing/invalid JWT) will use default handlers and may not match the structured error format used by GlobalExceptionHandler. If clients expect consistent JSON error bodies, configure exceptionHandling with a custom AuthenticationEntryPoint and AccessDeniedHandler that returns the same shape.
| @Column(nullable = false, unique = true, length = 512) | ||
| private String token; | ||
|
|
||
| @Column(name = "expiry_date", nullable = false) | ||
| private LocalDateTime expiryDate; | ||
|
|
||
| @Column(nullable = false) | ||
| private boolean revoked = false; |
There was a problem hiding this comment.
Refresh tokens are stored in plaintext (token column). If the database is ever leaked, these bearer tokens can be used to mint new access tokens until expiry. Consider storing only a hashed token (e.g., SHA-256 of the token value) and comparing hashes on lookup, similar to how passwords are handled, so the raw token is only ever shown once to the client.
| flyway: | ||
| enabled: true | ||
| locations: classpath:db/migration | ||
| baseline-on-migrate: true | ||
|
|
There was a problem hiding this comment.
spring.flyway.baseline-on-migrate: true can hide problems by baselining an unexpected pre-existing schema (e.g., pointing at the wrong DB) instead of failing fast. For a greenfield service, consider disabling this (or making it environment-specific) so migrations fail when the schema history table is missing but objects already exist.
Implements a full backend from scratch for a PWA job application tracker: JWT auth with refresh token rotation, job application CRUD with filtering/pagination, dashboard summary, and password reset flow — all against MariaDB via Flyway migrations.
Auth (
/api/auth/*)POSTregister/login → returns access + refresh token + userPOSTrefresh → token rotation (old token revoked on use)POSTforgot-password / reset-password → token-scoped, 30 min expiry, invalidates existing tokensPOSTlogout → revokes refresh tokenGETme → returns authenticated userApplications (
/api/applications/*)GET /api/applications— paginated, filterable bystatus,recruiterName,applicationDateFrom/To,interviewScheduled,recruiterDmReminderEnabled; sort validated against a whitelistPATCH /{id}/status,PATCH /{id}/reminder— targeted field updatesGET /upcoming/GET /overdue— server-side computed fromnextStepDateTimeDashboard (
/api/dashboard/summary)Aggregates total applications, waiting responses, scheduled interviews, overdue follow-ups, and active DM reminders for the current user.
Infrastructure
jwt.access-token-expiration-ms/jwt.refresh-token-expiration-msSessionCreationPolicy.STATELESS,BCryptPasswordEncoder, CSRF disabled (JWT REST API)V1__initial_schema.sqlwith indexed foreign keys;ddl-auto: validateGlobalExceptionHandlerreturns structured{ timestamp, status, error, message }with per-field validation errors on 400sStatus enum mapping
API surfaces human-readable Portuguese strings; internally stored as enum names:
Original prompt
Act as an expert Java 21 backend engineer and architect. Build a production-ready REST API for a Progressive Web App that tracks job applications, user authentication, recruiter reminders, and recruitment stages.
PROJECT GOAL
Create a robust backend for managing authentication, password recovery, job applications, status tracking, reminder flags, dashboard summaries, JWT authentication, refresh tokens, and secure access control.
TECH STACK (MANDATORY)
IMPORTANT RULE
Use ONLY the API contracts defined below. Do not invent endpoints, request fields, response fields, query parameters, or business rules outside these contracts. Do not omit any endpoint or payload defined here. Do not create any extra status values.
==================================================================
ARCHITECTURE AND FOLDER STRUCTURE
Use a clean package structure similar to this:
backend/
├── src/main/java/com/jobtracker/
│ ├── JobTrackerApplication.java
│ ├── config/
│ │ ├── CorsConfig.java
│ │ ├── SecurityConfig.java
│ │ ├── JwtAuthenticationFilter.java
│ │ ├── JwtService.java
│ │ └── OpenApiConfig.java
│ ├── controller/
│ │ ├── AuthController.java
│ │ ├── ApplicationController.java
│ │ └── DashboardController.java
│ ├── dto/
│ │ ├── auth/
│ │ │ ├── RegisterRequest.java
│ │ │ ├── LoginRequest.java
│ │ │ ├── RefreshTokenRequest.java
│ │ │ ├── ForgotPasswordRequest.java
│ │ │ ├── ResetPasswordRequest.java
│ │ │ ├── LogoutRequest.java
│ │ │ ├── AuthResponse.java
│ │ │ └── UserResponse.java
│ │ ├── application/
│ │ │ ├── ApplicationRequest.java
│ │ │ ├── ApplicationResponse.java
│ │ │ ├── ApplicationPageResponse.java
│ │ │ ├── UpdateStatusRequest.java
│ │ │ └── UpdateReminderRequest.java
│ │ └── dashboard/
│ │ └── DashboardSummaryResponse.java
│ ├── entity/
│ │ ├── User.java
│ │ ├── JobApplication.java
│ │ ├── RefreshToken.java
│ │ ├── PasswordResetToken.java
│ │ └── enums/
│ │ └── ApplicationStatus.java
│ ├── repository/
│ │ ├── UserRepository.java
│ │ ├── ApplicationRepository.java
│ │ ├── RefreshTokenRepository.java
│ │ └── PasswordResetTokenRepository.java
│ ├── service/
│ │ ├── AuthService.java
│ │ ├── ApplicationService.java
│ │ ├── DashboardService.java
│ │ ├── RefreshTokenService.java
│ │ ├── PasswordResetService.java
│ │ └── JwtService.java
│ ├── mapper/
│ │ ├── AuthMapper.java
│ │ └── ApplicationMapper.java
│ ├── exception/
│ │ ├── GlobalExceptionHandler.java
│ │ ├── ResourceNotFoundException.java
│ │ ├── BadRequestException.java
│ │ ├── UnauthorizedException.java
│ │ └── ConflictException.java
│ └── util/
│ └── SecurityUtils.java
├── src/main/resources/
│ ├── application.yml
│ └── db/migration/
└── pom.xml
DATABASE
AUTHENTICATION REQUIREMENTS
AUTH API CONTRACTS
Base endpoints:
Request:
{
"name": "string",
"email": "string",
"password": "string",
"confirmPassword": "string"
}
Response:
{
"accessToken": "string",
"refreshToken": "string",
"user": {
"id": "number",
"name": "string",
"email": "string"
}
}
Request:
{
"email": "string",
"password": "string"
}
Response:
{
"accessToken": "string",
"refreshToken": "string",
"user": {
"id": "number",
"name": "string",
"email": "string"
}
}
Request:
{
"refreshToken": "string"
}
Response:
{
"accessToken": "string",
"refreshToken": "string"
}
Request:
{
"email": "string"
}
Response:
{
"message": "string"
}
Request:
{
"token": "string",
"newPassword": "string",
"confirmPassword": "string"
}
Response:
{
"message": "string"
}
Request:
{
"...