From e77da6c88cd2c7f8e91c40ec6bfc548abadd2325 Mon Sep 17 00:00:00 2001 From: vitorhugo-java Date: Mon, 4 May 2026 22:30:22 -0300 Subject: [PATCH 1/9] feat: add google drive resume integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 7 + README.md | 196 +++++++++++- pom.xml | 4 + .../config/GoogleDriveProperties.java | 83 ++++++ .../com/jobtracker/config/SecurityConfig.java | 1 + .../controller/GoogleDriveController.java | 98 ++++++ .../gdrive/GoogleDriveBaseResumeRequest.java | 11 + .../gdrive/GoogleDriveBaseResumeResponse.java | 15 + .../gdrive/GoogleDriveOAuthStartResponse.java | 17 ++ .../gdrive/GoogleDriveResumeCopyRequest.java | 13 + .../gdrive/GoogleDriveResumeCopyResponse.java | 17 ++ .../gdrive/GoogleDriveRootFolderRequest.java | 11 + .../dto/gdrive/GoogleDriveStatusResponse.java | 19 ++ .../entity/GoogleDriveBaseResume.java | 107 +++++++ .../entity/GoogleDriveConnection.java | 199 +++++++++++++ .../entity/GoogleDriveOAuthState.java | 79 +++++ .../GoogleDriveBaseResumeRepository.java | 15 + .../GoogleDriveConnectionRepository.java | 14 + .../GoogleDriveOAuthStateRepository.java | 15 + .../service/DefaultGoogleDriveApiClient.java | 235 +++++++++++++++ .../service/GoogleDriveApiClient.java | 32 ++ .../service/GoogleDriveOAuthService.java | 155 ++++++++++ .../service/GoogleDriveService.java | 281 ++++++++++++++++++ src/main/resources/application.yml | 10 + .../V11__add_google_drive_integration.sql | 50 ++++ .../integration/GoogleDriveControllerIT.java | 216 ++++++++++++++ .../unit/GoogleDriveServiceTest.java | 202 +++++++++++++ src/test/resources/application-test.yml | 5 + 28 files changed, 2091 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/jobtracker/config/GoogleDriveProperties.java create mode 100644 src/main/java/com/jobtracker/controller/GoogleDriveController.java create mode 100644 src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeRequest.java create mode 100644 src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java create mode 100644 src/main/java/com/jobtracker/dto/gdrive/GoogleDriveOAuthStartResponse.java create mode 100644 src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyRequest.java create mode 100644 src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyResponse.java create mode 100644 src/main/java/com/jobtracker/dto/gdrive/GoogleDriveRootFolderRequest.java create mode 100644 src/main/java/com/jobtracker/dto/gdrive/GoogleDriveStatusResponse.java create mode 100644 src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java create mode 100644 src/main/java/com/jobtracker/entity/GoogleDriveConnection.java create mode 100644 src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java create mode 100644 src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java create mode 100644 src/main/java/com/jobtracker/repository/GoogleDriveConnectionRepository.java create mode 100644 src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java create mode 100644 src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java create mode 100644 src/main/java/com/jobtracker/service/GoogleDriveApiClient.java create mode 100644 src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java create mode 100644 src/main/java/com/jobtracker/service/GoogleDriveService.java create mode 100644 src/main/resources/db/migration/V11__add_google_drive_integration.sql create mode 100644 src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java create mode 100644 src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java diff --git a/.env.example b/.env.example index 3278439..46734b1 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ MAIL_USERNAME=no-reply@example.dev MAIL_SMTP_AUTH=true MAIL_SMTP_SSL=true +# Google Drive OAuth +# Redirect URI must exactly match the OAuth client config in Google Cloud Console. +GOOGLE_DRIVE_CLIENT_ID= +GOOGLE_DRIVE_CLIENT_SECRET= +GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8080/api/v1/google-drive/oauth/callback +GOOGLE_DRIVE_OAUTH_COMPLETE_URL=http://localhost:5173/settings/google-drive/callback + # ============================================ # Synkra AIOX Environment Configuration # ============================================ diff --git a/README.md b/README.md index 56517ba..685c9e5 100644 --- a/README.md +++ b/README.md @@ -52,27 +52,27 @@ A production-ready Spring Boot REST API for tracking job applications, built wit | Method | Endpoint | Description | |--------|----------|-------------| -| POST | `/api/auth/register` | Register a new user | -| POST | `/api/auth/login` | Login and receive tokens | -| POST | `/api/auth/refresh` | Refresh access token | -| POST | `/api/auth/logout` | Logout and revoke refresh token | -| POST | `/api/auth/forgot-password` | Request password reset | -| POST | `/api/auth/reset-password` | Reset password with token | -| GET | `/api/auth/me` | Get current user info | +| POST | `/api/v1/auth/register` | Register a new user | +| POST | `/api/v1/auth/login` | Login and receive tokens | +| POST | `/api/v1/auth/refresh` | Refresh access token | +| POST | `/api/v1/auth/logout` | Logout and revoke refresh token | +| POST | `/api/v1/auth/forgot-password` | Request password reset | +| POST | `/api/v1/auth/reset-password` | Reset password with token | +| GET | `/api/v1/auth/me` | Get current user info | ### Applications | Method | Endpoint | Description | |--------|----------|-------------| -| POST | `/api/applications` | Create application | -| GET | `/api/applications` | List all (paginated + filterable) | -| GET | `/api/applications/{id}` | Get by ID | -| PUT | `/api/applications/{id}` | Full update | -| PATCH | `/api/applications/{id}/status` | Update status | -| PATCH | `/api/applications/{id}/reminder` | Toggle reminder | -| DELETE | `/api/applications/{id}` | Delete | -| GET | `/api/applications/upcoming` | Upcoming next steps | -| GET | `/api/applications/overdue` | Overdue next steps | +| POST | `/api/v1/applications` | Create application | +| GET | `/api/v1/applications` | List all (paginated + filterable) | +| GET | `/api/v1/applications/{id}` | Get by ID | +| PUT | `/api/v1/applications/{id}` | Full update | +| PATCH | `/api/v1/applications/{id}/status` | Update status | +| PATCH | `/api/v1/applications/{id}/reminder` | Toggle reminder | +| DELETE | `/api/v1/applications/{id}` | Delete | +| GET | `/api/v1/applications/upcoming` | Upcoming next steps | +| GET | `/api/v1/applications/overdue` | Overdue next steps | ### Gamification @@ -82,6 +82,19 @@ A production-ready Spring Boot REST API for tracking job applications, built wit | GET | `/api/v1/gamification/achievements` | List achievement catalog with unlocked state | | POST | `/api/v1/gamification/events` | Apply a tracked XP event and return updated profile | +### Google Drive + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/google-drive/oauth/start` | Generate the Google OAuth authorization URL for the authenticated user | +| GET | `/api/v1/google-drive/oauth/callback` | Google OAuth callback endpoint used by Google Cloud | +| GET | `/api/v1/google-drive/status` | Return current Google Drive connection status, configured root folder, and base resumes | +| DELETE | `/api/v1/google-drive/connection` | Disconnect the current user's Google account and remove stored Drive preferences | +| PUT | `/api/v1/google-drive/root-folder` | Validate and save the user's base Drive folder | +| POST | `/api/v1/google-drive/base-resumes` | Register a Google Docs base resume by Google Docs URL or file ID | +| DELETE | `/api/v1/google-drive/base-resumes/{baseResumeId}` | Remove a configured base resume | +| POST | `/api/v1/google-drive/applications/{applicationId}/resume-copies` | Copy a configured base resume into the application's Drive subfolder | + ## Application Status Values - `RH` @@ -192,9 +205,156 @@ export DB_URL=jdbc:mariadb://localhost:3306/jobtracker?createDatabaseIfNotExist= export DB_USERNAME=jobtracker export DB_PASSWORD=jobtracker export JWT_SECRET=your-secret-key-at-least-256-bits-long +export GOOGLE_DRIVE_CLIENT_ID=your-google-client-id +export GOOGLE_DRIVE_CLIENT_SECRET=your-google-client-secret +export GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8080/api/v1/google-drive/oauth/callback +export GOOGLE_DRIVE_OAUTH_COMPLETE_URL=http://localhost:5173/settings/google-drive/callback mvn spring-boot:run ``` +## Google Drive integration + +This backend supports per-user Google Drive OAuth2 for resume-copy automation. It does **not** replace the app's JWT auth flow; users stay authenticated with the existing bearer token, and Google is connected separately with `POST /api/v1/google-drive/oauth/start`. + +### Required Google Cloud setup + +1. Create a Google Cloud OAuth client for a web application. +2. Enable the **Google Drive API**. +3. Add the backend callback URL as an authorized redirect URI. Example local value: + - `http://localhost:8080/api/v1/google-drive/oauth/callback` +4. Configure these environment variables: + +| Variable | Required | Description | +|----------|----------|-------------| +| `GOOGLE_DRIVE_CLIENT_ID` | Yes | Google OAuth client ID | +| `GOOGLE_DRIVE_CLIENT_SECRET` | Yes | Google OAuth client secret | +| `GOOGLE_DRIVE_REDIRECT_URI` | Yes | Backend callback URL registered in Google Cloud | +| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | Yes | Frontend page that receives the final `status` and `message` query params after OAuth finishes | +| `GOOGLE_DRIVE_AUTHORIZATION_URI` | No | Override Google authorization endpoint | +| `GOOGLE_DRIVE_TOKEN_URI` | No | Override Google token endpoint | + +### OAuth flow expectations + +1. Frontend calls `POST /api/v1/google-drive/oauth/start` with the user's JWT bearer token. +2. Backend creates a short-lived OAuth state tied to that authenticated user and returns: + - `authorizationUrl` + - `state` + - `redirectUri` + - `scopes` +3. Frontend opens `authorizationUrl` in a new tab or popup. +4. Google redirects back to `GET /api/v1/google-drive/oauth/callback`. +5. Backend exchanges the authorization code for user-scoped Drive credentials, stores them, and redirects the browser to `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` with: + - `status=success|error` + - `message=` + +### Scope used + +- `https://www.googleapis.com/auth/drive` + +This scope is used so the backend can validate user-selected Drive folders, read chosen Google Docs metadata, create vacancy subfolders, and copy Google Docs files on behalf of the authenticated user. + +### Supported files + +- Base resumes must be **Google Docs** (`application/vnd.google-apps.document`). +- The root folder must be a **Google Drive folder**. +- The frontend Gemini button that opens `https://gemini.google.com/gem/f8ed7c14b062` is frontend-only and does not require a backend endpoint. + +### Resume copy behavior + +When the frontend later calls `POST /api/v1/google-drive/applications/{applicationId}/resume-copies`: + +1. Backend verifies the current user owns the application. +2. Backend refreshes the user's Google access token if needed. +3. Backend verifies the configured root folder still exists and is a folder. +4. Backend finds or creates a vacancy subfolder under that root folder using the application identity. +5. Backend copies the selected base Google Doc into that subfolder. +6. Backend renames the copy with an `APP-` prefix plus vacancy context. +7. Backend returns a Google Docs web URL for the copied file. + +### Google Drive request/response shapes + +`POST /api/v1/google-drive/oauth/start` + +```json +{} +``` + +Response: + +```json +{ + "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth?...", + "state": "generated-state", + "redirectUri": "http://localhost:8080/api/v1/google-drive/oauth/callback", + "scopes": [ + "https://www.googleapis.com/auth/drive" + ] +} +``` + +`GET /api/v1/google-drive/status` + +```json +{ + "configured": true, + "connected": true, + "googleEmail": "user@gmail.com", + "googleDisplayName": "User Name", + "googleAccountId": "permission-id", + "rootFolderId": "drive-folder-id", + "rootFolderName": "Job Tracker Root", + "connectedAt": "2026-05-05T12:00:00", + "baseResumes": [ + { + "id": "uuid", + "googleFileId": "google-doc-id", + "documentName": "Resume Base", + "webViewLink": "https://docs.google.com/document/d/google-doc-id/edit", + "createdAt": "2026-05-05T12:05:00" + } + ] +} +``` + +`PUT /api/v1/google-drive/root-folder` + +```json +{ + "folderIdOrUrl": "https://drive.google.com/drive/folders/drive-folder-id" +} +``` + +`POST /api/v1/google-drive/base-resumes` + +```json +{ + "documentIdOrUrl": "https://docs.google.com/document/d/google-doc-id/edit" +} +``` + +`POST /api/v1/google-drive/applications/{applicationId}/resume-copies` + +```json +{ + "baseResumeId": "base-resume-uuid" +} +``` + +Response: + +```json +{ + "applicationId": "application-uuid", + "baseResumeId": "base-resume-uuid", + "copiedFileId": "copied-google-doc-id", + "copiedFileName": "APP-application-uuid - Backend Engineer - Resume Base", + "documentWebViewLink": "https://docs.google.com/document/d/copied-google-doc-id/edit", + "vacancyFolderId": "vacancy-folder-id", + "vacancyFolderName": "Backend Engineer - APP-application-uuid", + "vacancyFolderWebViewLink": "https://drive.google.com/drive/folders/vacancy-folder-id" +} +``` + ## Running Tests ```bash @@ -256,6 +416,10 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use | `JWT_ACCESS_TOKEN_EXPIRATION_MS` | `900000` | Access token TTL (15 min) | | `JWT_REFRESH_TOKEN_EXPIRATION_MS` | `604800000` | Refresh token TTL (7 days) | | `CORS_ALLOWED_ORIGINS` | `http://localhost:3000,http://localhost:5173` | Allowed CORS origins | +| `GOOGLE_DRIVE_CLIENT_ID` | *(empty)* | Google OAuth client ID for Drive integration | +| `GOOGLE_DRIVE_CLIENT_SECRET` | *(empty)* | Google OAuth client secret for Drive integration | +| `GOOGLE_DRIVE_REDIRECT_URI` | `http://localhost:8080/api/v1/google-drive/oauth/callback` | OAuth callback URL registered in Google Cloud | +| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | `http://localhost:5173/settings/google-drive/callback` | Frontend URL that receives OAuth completion redirects | | `RATE_LIMIT_AUTH_LOGIN_LIMIT_FOR_PERIOD` | `10` | Max login requests allowed per refresh period | | `RATE_LIMIT_AUTH_LOGIN_REFRESH_PERIOD` | `1m` | Window used by the login rate limiter | | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | OTLP gRPC endpoint (Jaeger/OpenTelemetry collector) | diff --git a/pom.xml b/pom.xml index 4c98fc4..a7ba932 100644 --- a/pom.xml +++ b/pom.xml @@ -148,6 +148,10 @@ jsoup 1.17.2 + + commons-codec + commons-codec + diff --git a/src/main/java/com/jobtracker/config/GoogleDriveProperties.java b/src/main/java/com/jobtracker/config/GoogleDriveProperties.java new file mode 100644 index 0000000..f321029 --- /dev/null +++ b/src/main/java/com/jobtracker/config/GoogleDriveProperties.java @@ -0,0 +1,83 @@ +package com.jobtracker.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class GoogleDriveProperties { + + private static final List DEFAULT_SCOPES = List.of("https://www.googleapis.com/auth/drive"); + + private final String clientId; + private final String clientSecret; + private final String redirectUri; + private final String oauthCompleteUrl; + private final String authorizationUri; + private final String tokenUri; + private final List scopes; + + public GoogleDriveProperties( + @Value("${app.google-drive.client-id:}") String clientId, + @Value("${app.google-drive.client-secret:}") String clientSecret, + @Value("${app.google-drive.redirect-uri:}") String redirectUri, + @Value("${app.google-drive.oauth-complete-url:http://localhost:5173/settings/google-drive/callback}") String oauthCompleteUrl, + @Value("${app.google-drive.authorization-uri:https://accounts.google.com/o/oauth2/v2/auth}") String authorizationUri, + @Value("${app.google-drive.token-uri:https://oauth2.googleapis.com/token}") String tokenUri + ) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + this.oauthCompleteUrl = oauthCompleteUrl; + this.authorizationUri = authorizationUri; + this.tokenUri = tokenUri; + this.scopes = DEFAULT_SCOPES; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getRedirectUri() { + return redirectUri; + } + + public String getOauthCompleteUrl() { + return oauthCompleteUrl; + } + + public String getAuthorizationUri() { + return authorizationUri; + } + + public String getTokenUri() { + return tokenUri; + } + + public List getScopes() { + return scopes; + } + + public String getScopeValue() { + return String.join(" ", scopes); + } + + public boolean isConfigured() { + return hasText(clientId) && hasText(clientSecret) && hasText(redirectUri); + } + + public void validateConfigured() { + if (!isConfigured()) { + throw new IllegalStateException("Google Drive integration is not configured on the server"); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index cbc5fa6..9d96236 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -39,6 +39,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/google-drive/oauth/callback").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() // Actuator is served on a dedicated management port (8081) that is never // exposed to the host; security is enforced via Docker network isolation. diff --git a/src/main/java/com/jobtracker/controller/GoogleDriveController.java b/src/main/java/com/jobtracker/controller/GoogleDriveController.java new file mode 100644 index 0000000..fc4df99 --- /dev/null +++ b/src/main/java/com/jobtracker/controller/GoogleDriveController.java @@ -0,0 +1,98 @@ +package com.jobtracker.controller; + +import com.jobtracker.dto.auth.MessageResponse; +import com.jobtracker.dto.gdrive.*; +import com.jobtracker.service.GoogleDriveOAuthService; +import com.jobtracker.service.GoogleDriveService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.UUID; + +@Tag(name = "Google Drive", description = "Google Drive OAuth and resume copy endpoints") +@RestController +@RequestMapping("/api/v1/google-drive") +public class GoogleDriveController { + + private final GoogleDriveOAuthService googleDriveOAuthService; + private final GoogleDriveService googleDriveService; + + public GoogleDriveController(GoogleDriveOAuthService googleDriveOAuthService, GoogleDriveService googleDriveService) { + this.googleDriveOAuthService = googleDriveOAuthService; + this.googleDriveService = googleDriveService; + } + + @Operation( + summary = "Start Google Drive OAuth flow", + responses = @ApiResponse(responseCode = "200", description = "Authorization URL generated", + content = @Content(schema = @Schema(implementation = GoogleDriveOAuthStartResponse.class))) + ) + @PostMapping("/oauth/start") + public ResponseEntity startOauth() { + return ResponseEntity.ok(googleDriveOAuthService.startAuthorization()); + } + + @Operation(summary = "Google Drive OAuth callback") + @GetMapping("/oauth/callback") + public void oauthCallback(@RequestParam(required = false) String state, + @RequestParam(required = false) String code, + @RequestParam(required = false) String error, + HttpServletResponse response) throws IOException { + response.sendRedirect(googleDriveOAuthService.handleCallback(state, code, error)); + } + + @Operation( + summary = "Get Google Drive status", + responses = @ApiResponse(responseCode = "200", description = "Current Google Drive integration status", + content = @Content(schema = @Schema(implementation = GoogleDriveStatusResponse.class))) + ) + @GetMapping("/status") + public ResponseEntity getStatus() { + return ResponseEntity.ok(googleDriveService.getStatus()); + } + + @Operation(summary = "Disconnect Google Drive") + @DeleteMapping("/connection") + public ResponseEntity disconnect() { + return ResponseEntity.ok(googleDriveOAuthService.disconnect()); + } + + @Operation(summary = "Update Google Drive root folder") + @PutMapping("/root-folder") + public ResponseEntity updateRootFolder( + @Valid @RequestBody GoogleDriveRootFolderRequest request) { + return ResponseEntity.ok(googleDriveService.updateRootFolder(request)); + } + + @Operation(summary = "Register a Google Docs base resume") + @PostMapping("/base-resumes") + public ResponseEntity addBaseResume( + @Valid @RequestBody GoogleDriveBaseResumeRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(googleDriveService.addBaseResume(request)); + } + + @Operation(summary = "Delete a configured base resume") + @DeleteMapping("/base-resumes/{baseResumeId}") + public ResponseEntity deleteBaseResume(@PathVariable UUID baseResumeId) { + googleDriveService.deleteBaseResume(baseResumeId); + return ResponseEntity.ok(new MessageResponse("Base resume deleted successfully")); + } + + @Operation(summary = "Copy a base resume into an application folder") + @PostMapping("/applications/{applicationId}/resume-copies") + public ResponseEntity copyBaseResume( + @PathVariable UUID applicationId, + @Valid @RequestBody GoogleDriveResumeCopyRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(googleDriveService.copyBaseResumeToApplication(applicationId, request)); + } +} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeRequest.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeRequest.java new file mode 100644 index 0000000..65dc2c2 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeRequest.java @@ -0,0 +1,11 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request to register a Google Docs base resume") +public record GoogleDriveBaseResumeRequest( + @Schema(description = "Google Docs document ID or URL", example = "https://docs.google.com/document/d/1234567890abcdef/edit") + @NotBlank(message = "documentIdOrUrl is required") + String documentIdOrUrl +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java new file mode 100644 index 0000000..792bfc8 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java @@ -0,0 +1,15 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Schema(description = "Configured Google Docs base resume") +public record GoogleDriveBaseResumeResponse( + UUID id, + String googleFileId, + String documentName, + String webViewLink, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveOAuthStartResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveOAuthStartResponse.java new file mode 100644 index 0000000..1d38649 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveOAuthStartResponse.java @@ -0,0 +1,17 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "OAuth start response for Google Drive connection") +public record GoogleDriveOAuthStartResponse( + @Schema(description = "Google OAuth authorization URL") + String authorizationUrl, + @Schema(description = "Opaque state generated by the backend") + String state, + @Schema(description = "Configured backend redirect URI") + String redirectUri, + @Schema(description = "OAuth scopes requested by the backend") + List scopes +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyRequest.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyRequest.java new file mode 100644 index 0000000..e81f89e --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyRequest.java @@ -0,0 +1,13 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +@Schema(description = "Request to copy a configured base resume into an application folder") +public record GoogleDriveResumeCopyRequest( + @Schema(description = "Configured base resume identifier") + @NotNull(message = "baseResumeId is required") + UUID baseResumeId +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyResponse.java new file mode 100644 index 0000000..74034d8 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveResumeCopyResponse.java @@ -0,0 +1,17 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +@Schema(description = "Result of copying a Google Docs resume into Drive") +public record GoogleDriveResumeCopyResponse( + UUID applicationId, + UUID baseResumeId, + String copiedFileId, + String copiedFileName, + String documentWebViewLink, + String vacancyFolderId, + String vacancyFolderName, + String vacancyFolderWebViewLink +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveRootFolderRequest.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveRootFolderRequest.java new file mode 100644 index 0000000..3880d20 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveRootFolderRequest.java @@ -0,0 +1,11 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "Request to configure the user's Google Drive root folder") +public record GoogleDriveRootFolderRequest( + @Schema(description = "Google Drive folder ID or URL", example = "https://drive.google.com/drive/folders/1234567890abcdef") + @NotBlank(message = "folderIdOrUrl is required") + String folderIdOrUrl +) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveStatusResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveStatusResponse.java new file mode 100644 index 0000000..6335021 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveStatusResponse.java @@ -0,0 +1,19 @@ +package com.jobtracker.dto.gdrive; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "Current Google Drive integration status") +public record GoogleDriveStatusResponse( + boolean configured, + boolean connected, + String googleEmail, + String googleDisplayName, + String googleAccountId, + String rootFolderId, + String rootFolderName, + LocalDateTime connectedAt, + List baseResumes +) {} diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java new file mode 100644 index 0000000..ca78d03 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java @@ -0,0 +1,107 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "google_drive_base_resumes", indexes = { + @Index(name = "idx_gdrive_resume_connection", columnList = "connection_id"), + @Index(name = "idx_gdrive_resume_file", columnList = "google_file_id"), + @Index(name = "uk_gdrive_resume_connection_file", columnList = "connection_id,google_file_id", unique = true) +}) +public class GoogleDriveBaseResume { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "connection_id", nullable = false) + private GoogleDriveConnection connection; + + @Column(name = "google_file_id", nullable = false, length = 255) + private String googleFileId; + + @Column(name = "document_name", nullable = false, length = 255) + private String documentName; + + @Column(name = "web_view_link", length = 2048) + private String webViewLink; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public GoogleDriveConnection getConnection() { + return connection; + } + + public void setConnection(GoogleDriveConnection connection) { + this.connection = connection; + } + + public String getGoogleFileId() { + return googleFileId; + } + + public void setGoogleFileId(String googleFileId) { + this.googleFileId = googleFileId; + } + + public String getDocumentName() { + return documentName; + } + + public void setDocumentName(String documentName) { + this.documentName = documentName; + } + + public String getWebViewLink() { + return webViewLink; + } + + public void setWebViewLink(String webViewLink) { + this.webViewLink = webViewLink; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java b/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java new file mode 100644 index 0000000..ee34ae0 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/GoogleDriveConnection.java @@ -0,0 +1,199 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "google_drive_connections", indexes = { + @Index(name = "idx_gdrive_connection_user", columnList = "user_id", unique = true), + @Index(name = "idx_gdrive_connection_google_account", columnList = "google_account_id") +}) +public class GoogleDriveConnection { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(name = "google_account_id", nullable = false, length = 255) + private String googleAccountId; + + @Column(name = "google_email", nullable = false, length = 255) + private String googleEmail; + + @Column(name = "google_display_name", length = 255) + private String googleDisplayName; + + @Column(name = "access_token", nullable = false, length = 4096) + private String accessToken; + + @Column(name = "refresh_token", nullable = false, length = 4096) + private String refreshToken; + + @Column(name = "access_token_expires_at", nullable = false) + private LocalDateTime accessTokenExpiresAt; + + @Column(name = "granted_scopes", nullable = false, length = 2048) + private String grantedScopes; + + @Column(name = "root_folder_id", length = 255) + private String rootFolderId; + + @Column(name = "root_folder_name", length = 255) + private String rootFolderName; + + @Column(name = "connected_at", nullable = false) + private LocalDateTime connectedAt; + + @OneToMany(mappedBy = "connection", cascade = CascadeType.ALL, orphanRemoval = true) + private List baseResumes = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (connectedAt == null) { + connectedAt = createdAt; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getGoogleAccountId() { + return googleAccountId; + } + + public void setGoogleAccountId(String googleAccountId) { + this.googleAccountId = googleAccountId; + } + + public String getGoogleEmail() { + return googleEmail; + } + + public void setGoogleEmail(String googleEmail) { + this.googleEmail = googleEmail; + } + + public String getGoogleDisplayName() { + return googleDisplayName; + } + + public void setGoogleDisplayName(String googleDisplayName) { + this.googleDisplayName = googleDisplayName; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public LocalDateTime getAccessTokenExpiresAt() { + return accessTokenExpiresAt; + } + + public void setAccessTokenExpiresAt(LocalDateTime accessTokenExpiresAt) { + this.accessTokenExpiresAt = accessTokenExpiresAt; + } + + public String getGrantedScopes() { + return grantedScopes; + } + + public void setGrantedScopes(String grantedScopes) { + this.grantedScopes = grantedScopes; + } + + public String getRootFolderId() { + return rootFolderId; + } + + public void setRootFolderId(String rootFolderId) { + this.rootFolderId = rootFolderId; + } + + public String getRootFolderName() { + return rootFolderName; + } + + public void setRootFolderName(String rootFolderName) { + this.rootFolderName = rootFolderName; + } + + public LocalDateTime getConnectedAt() { + return connectedAt; + } + + public void setConnectedAt(LocalDateTime connectedAt) { + this.connectedAt = connectedAt; + } + + public List getBaseResumes() { + return baseResumes; + } + + public void setBaseResumes(List baseResumes) { + this.baseResumes = baseResumes; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java b/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java new file mode 100644 index 0000000..0013852 --- /dev/null +++ b/src/main/java/com/jobtracker/entity/GoogleDriveOAuthState.java @@ -0,0 +1,79 @@ +package com.jobtracker.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "google_drive_oauth_states", indexes = { + @Index(name = "uk_gdrive_oauth_state", columnList = "state_token", unique = true), + @Index(name = "idx_gdrive_oauth_user", columnList = "user_id"), + @Index(name = "idx_gdrive_oauth_expires", columnList = "expires_at") +}) +public class GoogleDriveOAuthState { + + @Id + @UuidGenerator(style = UuidGenerator.Style.TIME) + @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "state_token", nullable = false, length = 255) + private String stateToken; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getStateToken() { + return stateToken; + } + + public void setStateToken(String stateToken) { + this.stateToken = stateToken; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java new file mode 100644 index 0000000..258c5ee --- /dev/null +++ b/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java @@ -0,0 +1,15 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.GoogleDriveBaseResume; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface GoogleDriveBaseResumeRepository extends JpaRepository { + + List findAllByConnectionIdOrderByCreatedAtAsc(UUID connectionId); + + Optional findByIdAndConnectionUserId(UUID id, UUID userId); +} diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveConnectionRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveConnectionRepository.java new file mode 100644 index 0000000..25457a5 --- /dev/null +++ b/src/main/java/com/jobtracker/repository/GoogleDriveConnectionRepository.java @@ -0,0 +1,14 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.GoogleDriveConnection; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface GoogleDriveConnectionRepository extends JpaRepository { + + @EntityGraph(attributePaths = "baseResumes") + Optional findByUserId(UUID userId); +} diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java new file mode 100644 index 0000000..26e184f --- /dev/null +++ b/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java @@ -0,0 +1,15 @@ +package com.jobtracker.repository; + +import com.jobtracker.entity.GoogleDriveOAuthState; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +public interface GoogleDriveOAuthStateRepository extends JpaRepository { + + Optional findByStateToken(String stateToken); + + void deleteByExpiresAtBefore(LocalDateTime expiresAt); +} diff --git a/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java new file mode 100644 index 0000000..564a9ae --- /dev/null +++ b/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java @@ -0,0 +1,235 @@ +package com.jobtracker.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.exception.BadRequestException; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Component +public class DefaultGoogleDriveApiClient implements GoogleDriveApiClient { + + private static final String DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3"; + + private final GoogleDriveProperties properties; + private final RestClient restClient; + + public DefaultGoogleDriveApiClient(GoogleDriveProperties properties, RestClient.Builder restClientBuilder) { + this.properties = properties; + this.restClient = restClientBuilder.build(); + } + + @Override + public String buildAuthorizationUrl(String state) { + properties.validateConfigured(); + return UriComponentsBuilder.fromUriString(properties.getAuthorizationUri()) + .queryParam("client_id", properties.getClientId()) + .queryParam("redirect_uri", properties.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", properties.getScopeValue()) + .queryParam("access_type", "offline") + .queryParam("include_granted_scopes", "true") + .queryParam("prompt", "consent") + .queryParam("state", state) + .build() + .encode() + .toUriString(); + } + + @Override + public OAuthTokens exchangeAuthorizationCode(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", properties.getClientId()); + body.add("client_secret", properties.getClientSecret()); + body.add("redirect_uri", properties.getRedirectUri()); + body.add("grant_type", "authorization_code"); + + JsonNode response = postForm(properties.getTokenUri(), body, "exchange Google authorization code"); + String refreshToken = textValue(response, "refresh_token"); + if (refreshToken == null || refreshToken.isBlank()) { + throw new BadRequestException("Google OAuth did not return a refresh token. Reconnect and grant consent again."); + } + return toTokens(response, refreshToken); + } + + @Override + public OAuthTokens refreshAccessToken(String refreshToken) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("refresh_token", refreshToken); + body.add("client_id", properties.getClientId()); + body.add("client_secret", properties.getClientSecret()); + body.add("grant_type", "refresh_token"); + + JsonNode response = postForm(properties.getTokenUri(), body, "refresh Google access token"); + return toTokens(response, refreshToken); + } + + @Override + public GoogleDriveAccountProfile getCurrentAccount(String accessToken) { + String uri = UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/about") + .queryParam("fields", "user(emailAddress,displayName,permissionId)") + .build() + .encode() + .toUriString(); + JsonNode response = getAuthorizedJson(uri, accessToken, "read Google Drive account"); + JsonNode userNode = response.path("user"); + return new GoogleDriveAccountProfile( + userNode.path("permissionId").asText(), + userNode.path("emailAddress").asText(), + userNode.path("displayName").asText(null) + ); + } + + @Override + public DriveFileMetadata getFileMetadata(String accessToken, String fileId) { + String uri = UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files/" + fileId) + .queryParam("supportsAllDrives", "true") + .queryParam("fields", "id,name,mimeType,webViewLink") + .build() + .encode() + .toUriString(); + JsonNode response = getAuthorizedJson(uri, accessToken, "read Google Drive file metadata"); + return toDriveFileMetadata(response); + } + + @Override + public Optional findFolderByName(String accessToken, String parentFolderId, String folderName) { + String escapedFolderName = folderName.replace("\\", "\\\\").replace("'", "\\'"); + String query = "mimeType='" + GOOGLE_FOLDER_MIME_TYPE + "' and trashed=false and '" + parentFolderId + + "' in parents and name='" + escapedFolderName + "'"; + String uri = UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files") + .queryParam("supportsAllDrives", "true") + .queryParam("includeItemsFromAllDrives", "true") + .queryParam("corpora", "allDrives") + .queryParam("q", query) + .queryParam("pageSize", 1) + .queryParam("fields", "files(id,name,mimeType,webViewLink)") + .build() + .encode() + .toUriString(); + + JsonNode response = getAuthorizedJson(uri, accessToken, "find Google Drive folder"); + JsonNode filesNode = response.path("files"); + if (!filesNode.isArray() || filesNode.isEmpty()) { + return Optional.empty(); + } + return Optional.of(toDriveFileMetadata(filesNode.get(0))); + } + + @Override + public DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName) { + JsonNode response = postAuthorizedJson( + UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files") + .queryParam("supportsAllDrives", "true") + .queryParam("fields", "id,name,mimeType,webViewLink") + .build() + .encode() + .toUriString(), + accessToken, + new FolderCreateRequest(folderName, GOOGLE_FOLDER_MIME_TYPE, List.of(parentFolderId)), + "create Google Drive folder" + ); + return toDriveFileMetadata(response); + } + + @Override + public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) { + JsonNode response = postAuthorizedJson( + UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files/" + sourceFileId + "/copy") + .queryParam("supportsAllDrives", "true") + .queryParam("fields", "id,name,mimeType,webViewLink") + .build() + .encode() + .toUriString(), + accessToken, + new FolderCreateRequest(newName, null, List.of(targetFolderId)), + "copy Google Docs file" + ); + return toDriveFileMetadata(response); + } + + private JsonNode postForm(String uri, MultiValueMap body, String action) { + try { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(JsonNode.class); + } catch (RestClientResponseException ex) { + throw googleApiException(action, ex); + } + } + + private JsonNode getAuthorizedJson(String uri, String accessToken, String action) { + try { + return restClient.get() + .uri(uri) + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .body(JsonNode.class); + } catch (RestClientResponseException ex) { + throw googleApiException(action, ex); + } + } + + private JsonNode postAuthorizedJson(String uri, String accessToken, Object body, String action) { + try { + return restClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_JSON) + .headers(headers -> headers.setBearerAuth(accessToken)) + .body(body) + .retrieve() + .body(JsonNode.class); + } catch (RestClientResponseException ex) { + throw googleApiException(action, ex); + } + } + + private OAuthTokens toTokens(JsonNode response, String refreshToken) { + String accessToken = textValue(response, "access_token"); + if (accessToken == null || accessToken.isBlank()) { + throw new BadRequestException("Google OAuth response did not include an access token"); + } + long expiresIn = response.path("expires_in").asLong(3600); + String scope = textValue(response, "scope"); + return new OAuthTokens(accessToken, refreshToken, LocalDateTime.now().plus(Duration.ofSeconds(expiresIn)), scope); + } + + private DriveFileMetadata toDriveFileMetadata(JsonNode node) { + return new DriveFileMetadata( + node.path("id").asText(), + node.path("name").asText(), + node.path("mimeType").asText(), + node.path("webViewLink").asText(null) + ); + } + + private String textValue(JsonNode node, String fieldName) { + JsonNode child = node.get(fieldName); + return child == null || child.isNull() ? null : child.asText(); + } + + private BadRequestException googleApiException(String action, RestClientResponseException ex) { + String responseBody = ex.getResponseBodyAsString(); + String message = responseBody; + if (message == null || message.isBlank()) { + message = ex.getStatusText(); + } + throw new BadRequestException("Failed to " + action + ": " + message); + } + + private record FolderCreateRequest(String name, String mimeType, List parents) {} +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java new file mode 100644 index 0000000..163b0cd --- /dev/null +++ b/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java @@ -0,0 +1,32 @@ +package com.jobtracker.service; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface GoogleDriveApiClient { + + String GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; + String GOOGLE_FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + + String buildAuthorizationUrl(String state); + + OAuthTokens exchangeAuthorizationCode(String code); + + OAuthTokens refreshAccessToken(String refreshToken); + + GoogleDriveAccountProfile getCurrentAccount(String accessToken); + + DriveFileMetadata getFileMetadata(String accessToken, String fileId); + + Optional findFolderByName(String accessToken, String parentFolderId, String folderName); + + DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName); + + DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName); + + record OAuthTokens(String accessToken, String refreshToken, LocalDateTime accessTokenExpiresAt, String scope) {} + + record GoogleDriveAccountProfile(String accountId, String emailAddress, String displayName) {} + + record DriveFileMetadata(String id, String name, String mimeType, String webViewLink) {} +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java new file mode 100644 index 0000000..ca2cefe --- /dev/null +++ b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java @@ -0,0 +1,155 @@ +package com.jobtracker.service; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.auth.MessageResponse; +import com.jobtracker.dto.gdrive.GoogleDriveOAuthStartResponse; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.GoogleDriveOAuthState; +import com.jobtracker.entity.User; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.repository.GoogleDriveOAuthStateRepository; +import com.jobtracker.util.SecurityUtils; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.UUID; + +@Service +public class GoogleDriveOAuthService { + + private static final int OAUTH_STATE_TTL_MINUTES = 10; + + private final GoogleDriveApiClient googleDriveApiClient; + private final GoogleDriveProperties googleDriveProperties; + private final GoogleDriveConnectionRepository connectionRepository; + private final GoogleDriveOAuthStateRepository oauthStateRepository; + private final SecurityUtils securityUtils; + + public GoogleDriveOAuthService(GoogleDriveApiClient googleDriveApiClient, + GoogleDriveProperties googleDriveProperties, + GoogleDriveConnectionRepository connectionRepository, + GoogleDriveOAuthStateRepository oauthStateRepository, + SecurityUtils securityUtils) { + this.googleDriveApiClient = googleDriveApiClient; + this.googleDriveProperties = googleDriveProperties; + this.connectionRepository = connectionRepository; + this.oauthStateRepository = oauthStateRepository; + this.securityUtils = securityUtils; + } + + @Transactional + public GoogleDriveOAuthStartResponse startAuthorization() { + validateServerConfigured(); + oauthStateRepository.deleteByExpiresAtBefore(LocalDateTime.now()); + + User currentUser = securityUtils.getCurrentUser(); + String state = generateState(currentUser.getId()); + + GoogleDriveOAuthState oauthState = new GoogleDriveOAuthState(); + oauthState.setUser(currentUser); + oauthState.setStateToken(state); + oauthState.setExpiresAt(LocalDateTime.now().plusMinutes(OAUTH_STATE_TTL_MINUTES)); + oauthStateRepository.save(oauthState); + + return new GoogleDriveOAuthStartResponse( + googleDriveApiClient.buildAuthorizationUrl(state), + state, + googleDriveProperties.getRedirectUri(), + googleDriveProperties.getScopes() + ); + } + + @Transactional + public String handleCallback(String state, String code, String error) { + validateServerConfigured(); + oauthStateRepository.deleteByExpiresAtBefore(LocalDateTime.now()); + + if (error != null && !error.isBlank()) { + return buildFrontendRedirect("error", "Google returned an OAuth error: " + error); + } + if (state == null || state.isBlank()) { + return buildFrontendRedirect("error", "Missing OAuth state"); + } + if (code == null || code.isBlank()) { + return buildFrontendRedirect("error", "Missing authorization code"); + } + + GoogleDriveOAuthState oauthState = null; + try { + oauthState = oauthStateRepository.findByStateToken(state) + .orElseThrow(() -> new BadRequestException("Invalid or expired Google OAuth state")); + if (oauthState.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new BadRequestException("Google OAuth state expired"); + } + + GoogleDriveApiClient.OAuthTokens tokens = googleDriveApiClient.exchangeAuthorizationCode(code); + GoogleDriveApiClient.GoogleDriveAccountProfile accountProfile = + googleDriveApiClient.getCurrentAccount(tokens.accessToken()); + + GoogleDriveConnection connection = connectionRepository.findByUserId(oauthState.getUser().getId()) + .orElseGet(GoogleDriveConnection::new); + boolean accountChanged = connection.getGoogleAccountId() != null + && !connection.getGoogleAccountId().equals(accountProfile.accountId()); + + connection.setUser(oauthState.getUser()); + connection.setGoogleAccountId(accountProfile.accountId()); + connection.setGoogleEmail(accountProfile.emailAddress()); + connection.setGoogleDisplayName(accountProfile.displayName()); + connection.setAccessToken(tokens.accessToken()); + connection.setRefreshToken(tokens.refreshToken()); + connection.setAccessTokenExpiresAt(tokens.accessTokenExpiresAt()); + connection.setGrantedScopes(tokens.scope() == null || tokens.scope().isBlank() + ? googleDriveProperties.getScopeValue() : tokens.scope()); + connection.setConnectedAt(LocalDateTime.now()); + if (accountChanged) { + connection.setRootFolderId(null); + connection.setRootFolderName(null); + connection.getBaseResumes().clear(); + } + connectionRepository.save(connection); + + return buildFrontendRedirect("success", "Google Drive connected successfully"); + } catch (BadRequestException ex) { + return buildFrontendRedirect("error", ex.getMessage()); + } finally { + if (oauthState != null) { + oauthStateRepository.delete(oauthState); + } + } + } + + @Transactional + public MessageResponse disconnect() { + connectionRepository.findByUserId(securityUtils.getCurrentUserId()) + .ifPresent(connectionRepository::delete); + return new MessageResponse("Google Drive connection removed"); + } + + private void validateServerConfigured() { + try { + googleDriveProperties.validateConfigured(); + } catch (IllegalStateException ex) { + throw new BadRequestException(ex.getMessage()); + } + } + + private String generateState(UUID userId) { + String raw = userId + ":" + UUID.randomUUID() + ":" + System.nanoTime(); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(DigestUtils.sha256(raw)); + } + + private String buildFrontendRedirect(String status, String message) { + return UriComponentsBuilder.fromUriString(googleDriveProperties.getOauthCompleteUrl()) + .queryParam("status", status) + .queryParam("message", message) + .build() + .encode() + .toUriString(); + } +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java new file mode 100644 index 0000000..dcd11ea --- /dev/null +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -0,0 +1,281 @@ +package com.jobtracker.service; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.gdrive.*; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.JobApplication; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.ResourceNotFoundException; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveBaseResumeRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.util.SecurityUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class GoogleDriveService { + + private static final Pattern GOOGLE_DRIVE_PATH_ID_PATTERN = Pattern.compile("/(?:d|folders)/([a-zA-Z0-9_-]{10,})"); + private static final Pattern GOOGLE_DRIVE_QUERY_ID_PATTERN = Pattern.compile("[?&]id=([a-zA-Z0-9_-]{10,})"); + + private final GoogleDriveApiClient googleDriveApiClient; + private final GoogleDriveProperties googleDriveProperties; + private final GoogleDriveConnectionRepository connectionRepository; + private final GoogleDriveBaseResumeRepository baseResumeRepository; + private final ApplicationRepository applicationRepository; + private final SecurityUtils securityUtils; + + public GoogleDriveService(GoogleDriveApiClient googleDriveApiClient, + GoogleDriveProperties googleDriveProperties, + GoogleDriveConnectionRepository connectionRepository, + GoogleDriveBaseResumeRepository baseResumeRepository, + ApplicationRepository applicationRepository, + SecurityUtils securityUtils) { + this.googleDriveApiClient = googleDriveApiClient; + this.googleDriveProperties = googleDriveProperties; + this.connectionRepository = connectionRepository; + this.baseResumeRepository = baseResumeRepository; + this.applicationRepository = applicationRepository; + this.securityUtils = securityUtils; + } + + @Transactional(readOnly = true) + public GoogleDriveStatusResponse getStatus() { + if (!googleDriveProperties.isConfigured()) { + return new GoogleDriveStatusResponse(false, false, null, null, null, null, null, null, List.of()); + } + + Optional connectionOptional = connectionRepository.findByUserId(securityUtils.getCurrentUserId()); + if (connectionOptional.isEmpty()) { + return new GoogleDriveStatusResponse(true, false, null, null, null, null, null, null, List.of()); + } + + return toStatusResponse(connectionOptional.get()); + } + + @Transactional + public GoogleDriveStatusResponse updateRootFolder(GoogleDriveRootFolderRequest request) { + GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); + String folderId = extractGoogleFileId(request.folderIdOrUrl()); + + GoogleDriveApiClient.DriveFileMetadata folder = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), folderId); + if (!GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE.equals(folder.mimeType())) { + throw new BadRequestException("Configured root folder must be a Google Drive folder"); + } + + connection.setRootFolderId(folder.id()); + connection.setRootFolderName(folder.name()); + connectionRepository.save(connection); + return toStatusResponse(connection); + } + + @Transactional + public GoogleDriveBaseResumeResponse addBaseResume(GoogleDriveBaseResumeRequest request) { + GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); + String documentId = extractGoogleFileId(request.documentIdOrUrl()); + + GoogleDriveApiClient.DriveFileMetadata file = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), documentId); + if (!GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE.equals(file.mimeType())) { + throw new BadRequestException("Only Google Docs base resumes are supported"); + } + + GoogleDriveBaseResume resume = baseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(connection.getId()) + .stream() + .filter(existing -> existing.getGoogleFileId().equals(file.id())) + .findFirst() + .orElseGet(GoogleDriveBaseResume::new); + + resume.setConnection(connection); + resume.setGoogleFileId(file.id()); + resume.setDocumentName(file.name()); + resume.setWebViewLink(resolveDocumentLink(file)); + GoogleDriveBaseResume saved = baseResumeRepository.save(resume); + return toBaseResumeResponse(saved); + } + + @Transactional + public void deleteBaseResume(UUID baseResumeId) { + UUID userId = securityUtils.getCurrentUserId(); + GoogleDriveBaseResume resume = baseResumeRepository.findByIdAndConnectionUserId(baseResumeId, userId) + .orElseThrow(() -> new ResourceNotFoundException("Base resume not found with id: " + baseResumeId)); + baseResumeRepository.delete(resume); + } + + @Transactional + public GoogleDriveResumeCopyResponse copyBaseResumeToApplication(UUID applicationId, GoogleDriveResumeCopyRequest request) { + UUID userId = securityUtils.getCurrentUserId(); + GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); + JobApplication application = applicationRepository.findByIdAndUserId(applicationId, userId) + .orElseThrow(() -> new ResourceNotFoundException("Application not found with id: " + applicationId)); + + if (!StringUtils.hasText(connection.getRootFolderId())) { + throw new BadRequestException("Configure a Google Drive root folder before copying resumes"); + } + + GoogleDriveBaseResume baseResume = baseResumeRepository.findByIdAndConnectionUserId(request.baseResumeId(), userId) + .orElseThrow(() -> new ResourceNotFoundException("Base resume not found with id: " + request.baseResumeId())); + + GoogleDriveApiClient.DriveFileMetadata rootFolder = + googleDriveApiClient.getFileMetadata(connection.getAccessToken(), connection.getRootFolderId()); + if (!GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE.equals(rootFolder.mimeType())) { + throw new BadRequestException("Configured root folder is no longer a valid Google Drive folder"); + } + connection.setRootFolderName(rootFolder.name()); + + String vacancyFolderName = buildVacancyFolderName(application); + GoogleDriveApiClient.DriveFileMetadata vacancyFolder = googleDriveApiClient + .findFolderByName(connection.getAccessToken(), rootFolder.id(), vacancyFolderName) + .orElseGet(() -> googleDriveApiClient.createFolder(connection.getAccessToken(), rootFolder.id(), vacancyFolderName)); + + String copiedFileName = buildCopiedDocumentName(application, baseResume.getDocumentName()); + GoogleDriveApiClient.DriveFileMetadata copiedFile = googleDriveApiClient.copyGoogleDoc( + connection.getAccessToken(), + baseResume.getGoogleFileId(), + vacancyFolder.id(), + copiedFileName + ); + + connectionRepository.save(connection); + return new GoogleDriveResumeCopyResponse( + application.getId(), + baseResume.getId(), + copiedFile.id(), + copiedFile.name(), + resolveDocumentLink(copiedFile), + vacancyFolder.id(), + vacancyFolder.name(), + resolveFolderLink(vacancyFolder.id(), vacancyFolder.webViewLink()) + ); + } + + private GoogleDriveConnection getConnectionWithFreshAccessToken() { + requireServerConfigured(); + GoogleDriveConnection connection = connectionRepository.findByUserId(securityUtils.getCurrentUserId()) + .orElseThrow(() -> new BadRequestException("Google Drive is not connected for the current user")); + return refreshAccessTokenIfNeeded(connection); + } + + private GoogleDriveConnection refreshAccessTokenIfNeeded(GoogleDriveConnection connection) { + if (connection.getAccessTokenExpiresAt() != null + && connection.getAccessTokenExpiresAt().isAfter(LocalDateTime.now().plusMinutes(1))) { + return connection; + } + + GoogleDriveApiClient.OAuthTokens refreshed = googleDriveApiClient.refreshAccessToken(connection.getRefreshToken()); + connection.setAccessToken(refreshed.accessToken()); + connection.setAccessTokenExpiresAt(refreshed.accessTokenExpiresAt()); + if (StringUtils.hasText(refreshed.scope())) { + connection.setGrantedScopes(refreshed.scope()); + } + return connectionRepository.save(connection); + } + + private GoogleDriveStatusResponse toStatusResponse(GoogleDriveConnection connection) { + List resumes = baseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(connection.getId()) + .stream() + .sorted(Comparator.comparing(GoogleDriveBaseResume::getCreatedAt)) + .map(this::toBaseResumeResponse) + .toList(); + return new GoogleDriveStatusResponse( + true, + true, + connection.getGoogleEmail(), + connection.getGoogleDisplayName(), + connection.getGoogleAccountId(), + connection.getRootFolderId(), + connection.getRootFolderName(), + connection.getConnectedAt(), + resumes + ); + } + + private GoogleDriveBaseResumeResponse toBaseResumeResponse(GoogleDriveBaseResume resume) { + return new GoogleDriveBaseResumeResponse( + resume.getId(), + resume.getGoogleFileId(), + resume.getDocumentName(), + resume.getWebViewLink(), + resume.getCreatedAt() + ); + } + + private void requireServerConfigured() { + if (!googleDriveProperties.isConfigured()) { + throw new BadRequestException("Google Drive integration is not configured on the server"); + } + } + + private String extractGoogleFileId(String rawValue) { + if (!StringUtils.hasText(rawValue)) { + throw new BadRequestException("Google file or folder ID is required"); + } + + String trimmed = rawValue.trim(); + if (!trimmed.contains("/")) { + return trimmed; + } + + Matcher pathMatcher = GOOGLE_DRIVE_PATH_ID_PATTERN.matcher(trimmed); + if (pathMatcher.find()) { + return pathMatcher.group(1); + } + + Matcher queryMatcher = GOOGLE_DRIVE_QUERY_ID_PATTERN.matcher(trimmed); + if (queryMatcher.find()) { + return queryMatcher.group(1); + } + throw new BadRequestException("Could not extract a Google file or folder ID from the provided value"); + } + + private String buildVacancyFolderName(JobApplication application) { + String baseName = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); + String suffix = "APP-" + application.getId().toString(); + return truncateFileName(sanitizeFileName(baseName + " - " + suffix), 180); + } + + private String buildCopiedDocumentName(JobApplication application, String baseResumeName) { + String vacancyName = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); + String prefix = "APP-" + application.getId() + " - " + vacancyName; + return truncateFileName(sanitizeFileName(prefix + " - " + baseResumeName), 220); + } + + private String sanitizeFileName(String value) { + return value.replaceAll("[\\\\/:*?\"<>|]+", "-").replaceAll("\\s+", " ").trim(); + } + + private String truncateFileName(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength).trim(); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } + + private String resolveDocumentLink(GoogleDriveApiClient.DriveFileMetadata file) { + return StringUtils.hasText(file.webViewLink()) + ? file.webViewLink() + : "https://docs.google.com/document/d/" + file.id() + "/edit"; + } + + private String resolveFolderLink(String folderId, String webViewLink) { + return StringUtils.hasText(webViewLink) + ? webViewLink + : "https://drive.google.com/drive/folders/" + folderId; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fe24882..c30aa57 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,6 +69,13 @@ app: mail: enabled: ${APP_MAIL_ENABLED:true} from: ${APP_MAIL_FROM:no-reply@jobtracker.com} + google-drive: + client-id: ${GOOGLE_DRIVE_CLIENT_ID:} + client-secret: ${GOOGLE_DRIVE_CLIENT_SECRET:} + redirect-uri: ${GOOGLE_DRIVE_REDIRECT_URI:http://localhost:8080/api/v1/google-drive/oauth/callback} + oauth-complete-url: ${GOOGLE_DRIVE_OAUTH_COMPLETE_URL:http://localhost:5173/settings/google-drive/callback} + authorization-uri: ${GOOGLE_DRIVE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth} + token-uri: ${GOOGLE_DRIVE_TOKEN_URI:https://oauth2.googleapis.com/token} management: server: @@ -142,3 +149,6 @@ springdoc: - group: gamification display-name: Gamification API paths-to-match: /api/v1/gamification/** + - group: google-drive + display-name: Google Drive API + paths-to-match: /api/v1/google-drive/** diff --git a/src/main/resources/db/migration/V11__add_google_drive_integration.sql b/src/main/resources/db/migration/V11__add_google_drive_integration.sql new file mode 100644 index 0000000..7d34577 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_google_drive_integration.sql @@ -0,0 +1,50 @@ +CREATE TABLE google_drive_connections ( + id BINARY(16) NOT NULL, + user_id BINARY(16) 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 DATETIME NOT NULL, + granted_scopes VARCHAR(2048) NOT NULL, + root_folder_id VARCHAR(255) NULL, + root_folder_name VARCHAR(255) NULL, + connected_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME 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 BINARY(16) NOT NULL, + connection_id BINARY(16) NOT NULL, + google_file_id VARCHAR(255) NOT NULL, + document_name VARCHAR(255) NOT NULL, + web_view_link VARCHAR(2048) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME 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 BINARY(16) NOT NULL, + user_id BINARY(16) NOT NULL, + state_token VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME 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/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java new file mode 100644 index 0000000..f9c6fd2 --- /dev/null +++ b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java @@ -0,0 +1,216 @@ +package com.jobtracker.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobtracker.dto.auth.AuthResponse; +import com.jobtracker.dto.auth.RegisterRequest; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveBaseResumeRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.repository.GoogleDriveOAuthStateRepository; +import com.jobtracker.repository.PasswordResetTokenRepository; +import com.jobtracker.repository.RefreshTokenRepository; +import com.jobtracker.repository.UserAchievementRepository; +import com.jobtracker.repository.UserGamificationRepository; +import com.jobtracker.repository.UserRepository; +import com.jobtracker.service.GoogleDriveApiClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class GoogleDriveControllerIT extends AbstractIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private UserRepository userRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; + @Autowired private PasswordResetTokenRepository passwordResetTokenRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private UserGamificationRepository userGamificationRepository; + @Autowired private UserAchievementRepository userAchievementRepository; + @Autowired private GoogleDriveConnectionRepository googleDriveConnectionRepository; + @Autowired private GoogleDriveBaseResumeRepository googleDriveBaseResumeRepository; + @Autowired private GoogleDriveOAuthStateRepository googleDriveOAuthStateRepository; + @Autowired private FakeGoogleDriveApiClient googleDriveApiClient; + + private String accessToken; + + @BeforeEach + void setUp() throws Exception { + googleDriveOAuthStateRepository.deleteAll(); + googleDriveBaseResumeRepository.deleteAll(); + googleDriveConnectionRepository.deleteAll(); + userAchievementRepository.deleteAll(); + userGamificationRepository.deleteAll(); + applicationRepository.deleteAll(); + passwordResetTokenRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + userRepository.deleteAll(); + + RegisterRequest reg = new RegisterRequest("Drive User", "driveuser@example.com", "pass1234", "pass1234"); + MvcResult result = mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reg))) + .andReturn(); + + AuthResponse auth = objectMapper.readValue(result.getResponse().getContentAsString(), AuthResponse.class); + accessToken = auth.accessToken(); + } + + @Test + void startOauth_shouldReturnAuthorizationUrl() throws Exception { + mockMvc.perform(post("/api/v1/google-drive/oauth/start") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authorizationUrl").value(org.hamcrest.Matchers.containsString("https://accounts.google.com/o/oauth2/v2/auth"))) + .andExpect(jsonPath("$.state").isNotEmpty()) + .andExpect(jsonPath("$.redirectUri").value("http://localhost:8080/api/v1/google-drive/oauth/callback")) + .andExpect(jsonPath("$.scopes[0]").value("https://www.googleapis.com/auth/drive")); + } + + @Test + void oauthCallback_shouldPersistConnectionAndRedirectToFrontend() throws Exception { + googleDriveApiClient.tokens = new GoogleDriveApiClient.OAuthTokens( + "drive-access", + "drive-refresh", + LocalDateTime.now().plusHours(1), + "https://www.googleapis.com/auth/drive" + ); + googleDriveApiClient.accountProfile = + new GoogleDriveApiClient.GoogleDriveAccountProfile("perm-123", "connected@example.com", "Drive User"); + + MvcResult startResult = mockMvc.perform(post("/api/v1/google-drive/oauth/start") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode startJson = objectMapper.readTree(startResult.getResponse().getContentAsString()); + String state = startJson.get("state").asText(); + + mockMvc.perform(get("/api/v1/google-drive/oauth/callback") + .param("state", state) + .param("code", "auth-code")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("status=success"))); + + GoogleDriveConnection connection = googleDriveConnectionRepository.findAll().getFirst(); + assertThat(connection.getGoogleEmail()).isEqualTo("connected@example.com"); + assertThat(connection.getRefreshToken()).isEqualTo("drive-refresh"); + } + + @Test + void updateRootFolder_shouldReturnUpdatedStatus() throws Exception { + googleDriveConnectionRepository.save(buildConnection()); + googleDriveApiClient.fileMetadataById.put("folder-123", + new GoogleDriveApiClient.DriveFileMetadata( + "folder-123", + "Root Folder", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/folder-123" + )); + + mockMvc.perform(put("/api/v1/google-drive/root-folder") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"folderIdOrUrl\":\"folder-123\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.connected").value(true)) + .andExpect(jsonPath("$.rootFolderId").value("folder-123")) + .andExpect(jsonPath("$.rootFolderName").value("Root Folder")); + } + + private GoogleDriveConnection buildConnection() { + GoogleDriveConnection connection = new GoogleDriveConnection(); + connection.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + connection.setGoogleAccountId("perm-123"); + connection.setGoogleEmail("connected@example.com"); + connection.setGoogleDisplayName("Drive User"); + connection.setAccessToken("drive-access"); + connection.setRefreshToken("drive-refresh"); + connection.setAccessTokenExpiresAt(LocalDateTime.now().plusHours(1)); + connection.setGrantedScopes("https://www.googleapis.com/auth/drive"); + connection.setConnectedAt(LocalDateTime.now()); + return connection; + } + + @TestConfiguration + static class GoogleDriveTestConfig { + @Bean + @Primary + FakeGoogleDriveApiClient googleDriveApiClient() { + return new FakeGoogleDriveApiClient(); + } + } + + static class FakeGoogleDriveApiClient implements GoogleDriveApiClient { + private OAuthTokens tokens = new OAuthTokens( + "drive-access", + "drive-refresh", + LocalDateTime.now().plusHours(1), + "https://www.googleapis.com/auth/drive" + ); + private GoogleDriveAccountProfile accountProfile = + new GoogleDriveAccountProfile("perm-123", "connected@example.com", "Drive User"); + private final Map fileMetadataById = new HashMap<>(); + + @Override + public String buildAuthorizationUrl(String state) { + return "https://accounts.google.com/o/oauth2/v2/auth?state=" + state; + } + + @Override + public OAuthTokens exchangeAuthorizationCode(String code) { + return tokens; + } + + @Override + public OAuthTokens refreshAccessToken(String refreshToken) { + return tokens; + } + + @Override + public GoogleDriveAccountProfile getCurrentAccount(String accessToken) { + return accountProfile; + } + + @Override + public DriveFileMetadata getFileMetadata(String accessToken, String fileId) { + return fileMetadataById.get(fileId); + } + + @Override + public Optional findFolderByName(String accessToken, String parentFolderId, String folderName) { + return Optional.empty(); + } + + @Override + public DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName) { + return new DriveFileMetadata("created-folder", folderName, GOOGLE_FOLDER_MIME_TYPE, null); + } + + @Override + public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) { + return new DriveFileMetadata("copied-file", newName, GOOGLE_DOC_MIME_TYPE, null); + } + } +} diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java new file mode 100644 index 0000000..f155670 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java @@ -0,0 +1,202 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.dto.gdrive.GoogleDriveBaseResumeRequest; +import com.jobtracker.dto.gdrive.GoogleDriveResumeCopyRequest; +import com.jobtracker.dto.gdrive.GoogleDriveRootFolderRequest; +import com.jobtracker.dto.gdrive.GoogleDriveStatusResponse; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.JobApplication; +import com.jobtracker.entity.User; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.repository.ApplicationRepository; +import com.jobtracker.repository.GoogleDriveBaseResumeRepository; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.service.GoogleDriveApiClient; +import com.jobtracker.service.GoogleDriveService; +import com.jobtracker.util.SecurityUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GoogleDriveServiceTest { + + private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID CONNECTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID RESUME_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + private static final UUID APPLICATION_ID = UUID.fromString("00000000-0000-0000-0000-000000000004"); + + @Mock private GoogleDriveApiClient googleDriveApiClient; + @Mock private GoogleDriveConnectionRepository connectionRepository; + @Mock private GoogleDriveBaseResumeRepository baseResumeRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private SecurityUtils securityUtils; + + private GoogleDriveProperties googleDriveProperties; + + @InjectMocks + private GoogleDriveService googleDriveService; + + private GoogleDriveConnection connection; + private GoogleDriveBaseResume baseResume; + private JobApplication application; + + @BeforeEach + void setUp() { + googleDriveProperties = new GoogleDriveProperties( + "client-id", + "client-secret", + "http://localhost:8080/api/v1/google-drive/oauth/callback", + "http://localhost:5173/settings/google-drive/callback", + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token" + ); + googleDriveService = new GoogleDriveService( + googleDriveApiClient, + googleDriveProperties, + connectionRepository, + baseResumeRepository, + applicationRepository, + securityUtils + ); + + User user = new User(); + user.setId(USER_ID); + user.setEmail("user@example.com"); + + connection = new GoogleDriveConnection(); + connection.setId(CONNECTION_ID); + connection.setUser(user); + connection.setGoogleAccountId("perm-1"); + connection.setGoogleEmail("drive@example.com"); + connection.setAccessToken("access-token"); + connection.setRefreshToken("refresh-token"); + connection.setAccessTokenExpiresAt(LocalDateTime.now().plusHours(1)); + connection.setGrantedScopes("https://www.googleapis.com/auth/drive"); + + baseResume = new GoogleDriveBaseResume(); + baseResume.setId(RESUME_ID); + baseResume.setConnection(connection); + baseResume.setGoogleFileId("resume-file-id"); + baseResume.setDocumentName("Base Resume"); + baseResume.setWebViewLink("https://docs.google.com/document/d/resume-file-id/edit"); + baseResume.setCreatedAt(LocalDateTime.now()); + + application = new JobApplication(); + application.setId(APPLICATION_ID); + application.setVacancyName("Backend Engineer"); + application.setOrganization("Acme"); + application.setApplicationDate(LocalDate.now()); + application.setUser(user); + } + + @Test + void getStatus_shouldReturnDisconnected_whenNoConnectionExists() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + GoogleDriveStatusResponse response = googleDriveService.getStatus(); + + assertThat(response.configured()).isTrue(); + assertThat(response.connected()).isFalse(); + assertThat(response.baseResumes()).isEmpty(); + } + + @Test + void updateRootFolder_shouldPersistFolderMetadata() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + when(googleDriveApiClient.getFileMetadata("access-token", "root-folder-id")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + when(baseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(CONNECTION_ID)).thenReturn(List.of()); + + GoogleDriveStatusResponse response = googleDriveService.updateRootFolder(new GoogleDriveRootFolderRequest("root-folder-id")); + + assertThat(response.rootFolderId()).isEqualTo("root-folder-id"); + assertThat(response.rootFolderName()).isEqualTo("Job Tracker Root"); + verify(connectionRepository).save(connection); + } + + @Test + void addBaseResume_shouldRejectNonGoogleDocsFiles() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + when(googleDriveApiClient.getFileMetadata("access-token", "not-a-doc")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "not-a-doc", + "Resume.pdf", + "application/pdf", + null + )); + + assertThatThrownBy(() -> googleDriveService.addBaseResume(new GoogleDriveBaseResumeRequest("not-a-doc"))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Only Google Docs base resumes are supported"); + } + + @Test + void copyBaseResumeToApplication_shouldCreateFolderAndCopyDocument() { + connection.setRootFolderId("root-folder-id"); + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + when(applicationRepository.findByIdAndUserId(APPLICATION_ID, USER_ID)).thenReturn(Optional.of(application)); + when(baseResumeRepository.findByIdAndConnectionUserId(RESUME_ID, USER_ID)).thenReturn(Optional.of(baseResume)); + when(googleDriveApiClient.getFileMetadata("access-token", "root-folder-id")) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + when(googleDriveApiClient.findFolderByName(eq("access-token"), eq("root-folder-id"), contains("APP-"))) + .thenReturn(Optional.empty()); + when(googleDriveApiClient.createFolder(eq("access-token"), eq("root-folder-id"), contains("APP-"))) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "vacancy-folder-id", + "Backend Engineer - APP-" + APPLICATION_ID, + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/vacancy-folder-id" + )); + when(googleDriveApiClient.copyGoogleDoc(eq("access-token"), eq("resume-file-id"), eq("vacancy-folder-id"), contains("APP-"))) + .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( + "copied-doc-id", + "APP-" + APPLICATION_ID + " - Backend Engineer - Base Resume", + GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE, + "https://docs.google.com/document/d/copied-doc-id/edit" + )); + + var response = googleDriveService.copyBaseResumeToApplication(APPLICATION_ID, new GoogleDriveResumeCopyRequest(RESUME_ID)); + + assertThat(response.applicationId()).isEqualTo(APPLICATION_ID); + assertThat(response.baseResumeId()).isEqualTo(RESUME_ID); + assertThat(response.copiedFileId()).isEqualTo("copied-doc-id"); + assertThat(response.documentWebViewLink()).contains("copied-doc-id"); + + ArgumentCaptor folderNameCaptor = ArgumentCaptor.forClass(String.class); + verify(googleDriveApiClient).createFolder(eq("access-token"), eq("root-folder-id"), folderNameCaptor.capture()); + assertThat(folderNameCaptor.getValue()).contains("APP-" + APPLICATION_ID); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index d396fd1..2bc7795 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -31,6 +31,11 @@ app: password-reset-token-expiration-hours: 24 mail: enabled: false + google-drive: + client-id: test-google-client-id + client-secret: test-google-client-secret + redirect-uri: http://localhost:8080/api/v1/google-drive/oauth/callback + oauth-complete-url: http://localhost:5173/settings/google-drive/callback cors: allowed-origins: http://localhost:3000 From eb02e6457d81113aee665e65f13039252b7e40ba Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Tue, 5 May 2026 15:29:58 -0300 Subject: [PATCH 2/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../jobtracker/service/DefaultGoogleDriveApiClient.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java index 564a9ae..eec5bd8 100644 --- a/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java @@ -223,12 +223,9 @@ private String textValue(JsonNode node, String fieldName) { } private BadRequestException googleApiException(String action, RestClientResponseException ex) { - String responseBody = ex.getResponseBodyAsString(); - String message = responseBody; - if (message == null || message.isBlank()) { - message = ex.getStatusText(); - } - throw new BadRequestException("Failed to " + action + ": " + message); + String message = "Failed to " + action + + " due to an upstream Google API error (status " + ex.getStatusCode().value() + ")"; + return new BadRequestException(message); } private record FolderCreateRequest(String name, String mimeType, List parents) {} From fed5762ee657c43f8f5c7550a0f45ddd5ee9c9cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 18:44:54 +0000 Subject: [PATCH 3/9] fix: address PR review comments - timeouts, isConfigured, folder naming, locking, OAuth tests Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/04ecdf63-053c-45df-a658-80942795419b Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --- .../config/GoogleDriveProperties.java | 4 +- .../repository/ApplicationRepository.java | 6 + .../service/DefaultGoogleDriveApiClient.java | 6 +- .../service/GoogleDriveService.java | 9 +- src/main/resources/application.yml | 2 +- .../unit/GoogleDriveOAuthServiceTest.java | 245 ++++++++++++++++++ .../unit/GoogleDriveServiceTest.java | 2 +- 7 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java diff --git a/src/main/java/com/jobtracker/config/GoogleDriveProperties.java b/src/main/java/com/jobtracker/config/GoogleDriveProperties.java index f321029..7c1ef58 100644 --- a/src/main/java/com/jobtracker/config/GoogleDriveProperties.java +++ b/src/main/java/com/jobtracker/config/GoogleDriveProperties.java @@ -22,7 +22,7 @@ public GoogleDriveProperties( @Value("${app.google-drive.client-id:}") String clientId, @Value("${app.google-drive.client-secret:}") String clientSecret, @Value("${app.google-drive.redirect-uri:}") String redirectUri, - @Value("${app.google-drive.oauth-complete-url:http://localhost:5173/settings/google-drive/callback}") String oauthCompleteUrl, + @Value("${app.google-drive.oauth-complete-url:}") String oauthCompleteUrl, @Value("${app.google-drive.authorization-uri:https://accounts.google.com/o/oauth2/v2/auth}") String authorizationUri, @Value("${app.google-drive.token-uri:https://oauth2.googleapis.com/token}") String tokenUri ) { @@ -68,7 +68,7 @@ public String getScopeValue() { } public boolean isConfigured() { - return hasText(clientId) && hasText(clientSecret) && hasText(redirectUri); + return hasText(clientId) && hasText(clientSecret) && hasText(redirectUri) && hasText(oauthCompleteUrl); } public void validateConfigured() { diff --git a/src/main/java/com/jobtracker/repository/ApplicationRepository.java b/src/main/java/com/jobtracker/repository/ApplicationRepository.java index 9bf1e2f..9794da3 100644 --- a/src/main/java/com/jobtracker/repository/ApplicationRepository.java +++ b/src/main/java/com/jobtracker/repository/ApplicationRepository.java @@ -2,10 +2,12 @@ import com.jobtracker.entity.JobApplication; import com.jobtracker.entity.enums.ApplicationStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -20,6 +22,10 @@ public interface ApplicationRepository extends JpaRepository findByIdAndUserId(UUID id, UUID userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM JobApplication a WHERE a.id = :id AND a.user.id = :userId") + Optional findByIdAndUserIdForUpdate(@Param("id") UUID id, @Param("userId") UUID userId); + long countByUserIdAndArchivedFalse(UUID userId); long countByUserIdAndInterviewScheduledTrueAndArchivedFalse(UUID userId); diff --git a/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java index eec5bd8..9aa0d21 100644 --- a/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java @@ -4,6 +4,7 @@ import com.jobtracker.config.GoogleDriveProperties; import com.jobtracker.exception.BadRequestException; import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -26,7 +27,10 @@ public class DefaultGoogleDriveApiClient implements GoogleDriveApiClient { public DefaultGoogleDriveApiClient(GoogleDriveProperties properties, RestClient.Builder restClientBuilder) { this.properties = properties; - this.restClient = restClientBuilder.build(); + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(10)); + requestFactory.setReadTimeout(Duration.ofSeconds(30)); + this.restClient = restClientBuilder.requestFactory(requestFactory).build(); } @Override diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java index dcd11ea..21afb4b 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -116,7 +116,7 @@ public void deleteBaseResume(UUID baseResumeId) { public GoogleDriveResumeCopyResponse copyBaseResumeToApplication(UUID applicationId, GoogleDriveResumeCopyRequest request) { UUID userId = securityUtils.getCurrentUserId(); GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); - JobApplication application = applicationRepository.findByIdAndUserId(applicationId, userId) + JobApplication application = applicationRepository.findByIdAndUserIdForUpdate(applicationId, userId) .orElseThrow(() -> new ResourceNotFoundException("Application not found with id: " + applicationId)); if (!StringUtils.hasText(connection.getRootFolderId())) { @@ -239,9 +239,10 @@ private String extractGoogleFileId(String rawValue) { } private String buildVacancyFolderName(JobApplication application) { - String baseName = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); - String suffix = "APP-" + application.getId().toString(); - return truncateFileName(sanitizeFileName(baseName + " - " + suffix), 180); + String suffix = " - APP-" + application.getId().toString(); + String rawBase = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); + String truncatedBase = truncateFileName(sanitizeFileName(rawBase), 180 - suffix.length()); + return truncatedBase + suffix; } private String buildCopiedDocumentName(JobApplication application, String baseResumeName) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c30aa57..9b96c61 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -73,7 +73,7 @@ app: client-id: ${GOOGLE_DRIVE_CLIENT_ID:} client-secret: ${GOOGLE_DRIVE_CLIENT_SECRET:} redirect-uri: ${GOOGLE_DRIVE_REDIRECT_URI:http://localhost:8080/api/v1/google-drive/oauth/callback} - oauth-complete-url: ${GOOGLE_DRIVE_OAUTH_COMPLETE_URL:http://localhost:5173/settings/google-drive/callback} + oauth-complete-url: ${GOOGLE_DRIVE_OAUTH_COMPLETE_URL:} authorization-uri: ${GOOGLE_DRIVE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth} token-uri: ${GOOGLE_DRIVE_TOKEN_URI:https://oauth2.googleapis.com/token} diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java new file mode 100644 index 0000000..ead99ef --- /dev/null +++ b/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java @@ -0,0 +1,245 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.entity.GoogleDriveBaseResume; +import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.GoogleDriveOAuthState; +import com.jobtracker.entity.User; +import com.jobtracker.repository.GoogleDriveConnectionRepository; +import com.jobtracker.repository.GoogleDriveOAuthStateRepository; +import com.jobtracker.service.GoogleDriveApiClient; +import com.jobtracker.service.GoogleDriveOAuthService; +import com.jobtracker.util.SecurityUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GoogleDriveOAuthServiceTest { + + private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final String VALID_STATE = "valid-state-token"; + private static final String AUTH_CODE = "auth-code-123"; + + @Mock private GoogleDriveApiClient googleDriveApiClient; + @Mock private GoogleDriveConnectionRepository connectionRepository; + @Mock private GoogleDriveOAuthStateRepository oauthStateRepository; + @Mock private SecurityUtils securityUtils; + + private GoogleDriveProperties googleDriveProperties; + private GoogleDriveOAuthService oauthService; + + private User user; + private GoogleDriveOAuthState validOAuthState; + + @BeforeEach + void setUp() { + googleDriveProperties = new GoogleDriveProperties( + "client-id", + "client-secret", + "http://localhost:8080/api/v1/google-drive/oauth/callback", + "http://localhost:5173/settings/google-drive/callback", + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token" + ); + oauthService = new GoogleDriveOAuthService( + googleDriveApiClient, + googleDriveProperties, + connectionRepository, + oauthStateRepository, + securityUtils + ); + + user = new User(); + user.setId(USER_ID); + user.setEmail("user@example.com"); + + validOAuthState = new GoogleDriveOAuthState(); + validOAuthState.setUser(user); + validOAuthState.setStateToken(VALID_STATE); + validOAuthState.setExpiresAt(LocalDateTime.now().plusMinutes(5)); + } + + @Test + void handleCallback_shouldRedirectWithError_whenGoogleReturnsError() { + String result = oauthService.handleCallback(VALID_STATE, null, "access_denied"); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Google"); + assertThat(result).contains("OAuth"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenStateIsExpired() { + validOAuthState.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=error"); + assertThat(result).containsIgnoringCase("expired"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenStateIsInvalid() { + when(oauthStateRepository.findByStateToken("unknown-state")).thenReturn(Optional.empty()); + + String result = oauthService.handleCallback("unknown-state", AUTH_CODE, null); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Invalid"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenMissingState() { + String result = oauthService.handleCallback(null, AUTH_CODE, null); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Missing"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldRedirectWithError_whenMissingCode() { + String result = oauthService.handleCallback(VALID_STATE, null, null); + + assertThat(result).contains("status=error"); + assertThat(result).contains("Missing"); + verifyNoInteractions(googleDriveApiClient); + verifyNoInteractions(connectionRepository); + } + + @Test + void handleCallback_shouldCreateNewConnection_onFirstConnect() { + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + when(googleDriveApiClient.exchangeAuthorizationCode(AUTH_CODE)).thenReturn( + new GoogleDriveApiClient.OAuthTokens("access-token", "refresh-token", + LocalDateTime.now().plusHours(1), "https://www.googleapis.com/auth/drive") + ); + when(googleDriveApiClient.getCurrentAccount("access-token")).thenReturn( + new GoogleDriveApiClient.GoogleDriveAccountProfile("perm-1", "user@gmail.com", "User") + ); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(connectionRepository.save(any(GoogleDriveConnection.class))).thenAnswer(inv -> inv.getArgument(0)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=success"); + ArgumentCaptor captor = ArgumentCaptor.forClass(GoogleDriveConnection.class); + verify(connectionRepository).save(captor.capture()); + GoogleDriveConnection saved = captor.getValue(); + assertThat(saved.getGoogleEmail()).isEqualTo("user@gmail.com"); + assertThat(saved.getRefreshToken()).isEqualTo("refresh-token"); + verify(oauthStateRepository).delete(validOAuthState); + } + + @Test + void handleCallback_shouldClearRootFolderAndBaseResumes_whenDifferentAccountConnects() { + GoogleDriveConnection existingConnection = new GoogleDriveConnection(); + existingConnection.setGoogleAccountId("old-account-id"); + existingConnection.setRootFolderId("old-root-folder"); + existingConnection.setRootFolderName("Old Root"); + existingConnection.setBaseResumes(new ArrayList<>(List.of(new GoogleDriveBaseResume()))); + + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + when(googleDriveApiClient.exchangeAuthorizationCode(AUTH_CODE)).thenReturn( + new GoogleDriveApiClient.OAuthTokens("new-access", "new-refresh", + LocalDateTime.now().plusHours(1), "https://www.googleapis.com/auth/drive") + ); + when(googleDriveApiClient.getCurrentAccount("new-access")).thenReturn( + new GoogleDriveApiClient.GoogleDriveAccountProfile("new-account-id", "new@gmail.com", "New User") + ); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(existingConnection)); + when(connectionRepository.save(any(GoogleDriveConnection.class))).thenAnswer(inv -> inv.getArgument(0)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=success"); + ArgumentCaptor captor = ArgumentCaptor.forClass(GoogleDriveConnection.class); + verify(connectionRepository).save(captor.capture()); + GoogleDriveConnection saved = captor.getValue(); + assertThat(saved.getRootFolderId()).isNull(); + assertThat(saved.getRootFolderName()).isNull(); + assertThat(saved.getBaseResumes()).isEmpty(); + assertThat(saved.getGoogleEmail()).isEqualTo("new@gmail.com"); + } + + @Test + void handleCallback_shouldPreserveRootFolder_whenSameAccountReconnects() { + GoogleDriveConnection existingConnection = new GoogleDriveConnection(); + existingConnection.setGoogleAccountId("same-account-id"); + existingConnection.setRootFolderId("existing-root-folder"); + existingConnection.setRootFolderName("Job Tracker Root"); + existingConnection.setBaseResumes(new ArrayList<>()); + + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + when(googleDriveApiClient.exchangeAuthorizationCode(AUTH_CODE)).thenReturn( + new GoogleDriveApiClient.OAuthTokens("new-access", "new-refresh", + LocalDateTime.now().plusHours(1), "https://www.googleapis.com/auth/drive") + ); + when(googleDriveApiClient.getCurrentAccount("new-access")).thenReturn( + new GoogleDriveApiClient.GoogleDriveAccountProfile("same-account-id", "user@gmail.com", "User") + ); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(existingConnection)); + when(connectionRepository.save(any(GoogleDriveConnection.class))).thenAnswer(inv -> inv.getArgument(0)); + + String result = oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + assertThat(result).contains("status=success"); + ArgumentCaptor captor = ArgumentCaptor.forClass(GoogleDriveConnection.class); + verify(connectionRepository).save(captor.capture()); + GoogleDriveConnection saved = captor.getValue(); + assertThat(saved.getRootFolderId()).isEqualTo("existing-root-folder"); + } + + @Test + void handleCallback_shouldDeleteStateEvenOnError() { + validOAuthState.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + + oauthService.handleCallback(VALID_STATE, AUTH_CODE, null); + + verify(oauthStateRepository).delete(validOAuthState); + } + + @Test + void disconnect_shouldDeleteExistingConnection() { + GoogleDriveConnection connection = new GoogleDriveConnection(); + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); + + oauthService.disconnect(); + + verify(connectionRepository).delete(connection); + } + + @Test + void disconnect_shouldSucceed_whenNoConnectionExists() { + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + oauthService.disconnect(); + + verify(connectionRepository, never()).delete(any()); + } +} diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java index f155670..18b61e4 100644 --- a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java +++ b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java @@ -162,7 +162,7 @@ void copyBaseResumeToApplication_shouldCreateFolderAndCopyDocument() { connection.setRootFolderId("root-folder-id"); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); - when(applicationRepository.findByIdAndUserId(APPLICATION_ID, USER_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.findByIdAndUserIdForUpdate(APPLICATION_ID, USER_ID)).thenReturn(Optional.of(application)); when(baseResumeRepository.findByIdAndConnectionUserId(RESUME_ID, USER_ID)).thenReturn(Optional.of(baseResume)); when(googleDriveApiClient.getFileMetadata("access-token", "root-folder-id")) .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( From c50370265866939c3b3cdf3bc6176b33fab22114 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 19:46:51 +0000 Subject: [PATCH 4/9] feat: replace manual HTTP client with Google Drive SDK + Spring OAuth2 client; apply 6 review fixes Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/b8946eb7-6687-4cb6-8834-03fbc41a4f1d Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --- pom.xml | 20 ++ .../config/GoogleDriveClientConfig.java | 92 ++++++ .../exception/GlobalExceptionHandler.java | 6 + .../ServiceUnavailableException.java | 8 + .../repository/ApplicationRepository.java | 6 - .../GoogleDriveBaseResumeRepository.java | 2 + .../GoogleDriveOAuthStateRepository.java | 2 + .../service/DefaultGoogleDriveApiClient.java | 236 --------------- .../service/DriveClientFactory.java | 63 ++++ .../service/GoogleDriveOAuthService.java | 15 +- .../service/GoogleDriveService.java | 20 +- .../service/SdkGoogleDriveApiClient.java | 275 ++++++++++++++++++ .../integration/GoogleDriveControllerIT.java | 156 ++++++++++ .../unit/GoogleDriveServiceTest.java | 2 +- 14 files changed, 652 insertions(+), 251 deletions(-) create mode 100644 src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java create mode 100644 src/main/java/com/jobtracker/exception/ServiceUnavailableException.java delete mode 100644 src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java create mode 100644 src/main/java/com/jobtracker/service/DriveClientFactory.java create mode 100644 src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java diff --git a/pom.xml b/pom.xml index a7ba932..57b8b71 100644 --- a/pom.xml +++ b/pom.xml @@ -153,6 +153,26 @@ commons-codec + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + + com.google.apis + google-api-services-drive + v3-rev20240521-2.0.0 + + + + + com.google.auth + google-auth-library-oauth2-http + 1.25.0 + + org.springframework.boot diff --git a/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java new file mode 100644 index 0000000..9b39102 --- /dev/null +++ b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java @@ -0,0 +1,92 @@ +package com.jobtracker.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +/** + * Configures the Spring OAuth2 client infrastructure used by the Google Drive integration. + *

+ * The {@link ClientRegistration} bean captures all Google OAuth2 parameters in one place so that + * Spring's standard {@link DefaultAuthorizationCodeTokenResponseClient} and + * {@link DefaultRefreshTokenTokenResponseClient} can handle token exchange and refresh without any + * manual HTTP calls. + *

+ * How the Spring Security context provides the authorized client:
+ * When a user completes the OAuth2 consent flow, {@code GoogleDriveOAuthService.handleCallback()} + * calls {@code authorizationCodeTokenResponseClient.getTokenResponse(grantRequest)}. The resulting + * access and refresh tokens are stored in our {@code google_drive_connections} table via + * {@code GoogleDriveConnectionRepository}. For every subsequent Drive API call, + * {@code GoogleDriveService} loads the connection, refreshes the token if needed using + * {@code refreshTokenResponseClient}, and passes the fresh access token to {@link + * com.jobtracker.service.DriveClientFactory#create} – which wraps it in a Google + * {@link com.google.auth.oauth2.OAuth2Credentials} so the SDK can authenticate automatically. + */ +@Configuration +public class GoogleDriveClientConfig { + + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + + /** + * The {@link ClientRegistration} for Google Drive. Built programmatically from + * {@link GoogleDriveProperties} so we have a single source of truth for OAuth2 parameters. + * Note: a {@code ClientRegistrationRepository} bean is intentionally not created; + * that would trigger Spring Security's OAuth2 login auto-configuration which is incompatible + * with this application's stateless JWT architecture. + */ + @Bean + public ClientRegistration googleDriveClientRegistration(GoogleDriveProperties properties) { + return ClientRegistration.withRegistrationId("google-drive") + .clientId(properties.getClientId()) + .clientSecret(properties.getClientSecret()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(properties.getRedirectUri()) + .scope(properties.getScopes().toArray(new String[0])) + .authorizationUri(properties.getAuthorizationUri()) + .tokenUri(properties.getTokenUri()) + .build(); + } + + /** + * Token response client for the Authorization Code grant. Replaces manual RestClient HTTP + * calls with Spring Security's type-safe implementation. Configured with the same timeouts + * used elsewhere in the application. + */ + @Bean + public OAuth2AccessTokenResponseClient authorizationCodeTokenResponseClient() { + DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient(); + client.setRestOperations(buildRestTemplate()); + return client; + } + + /** + * Token response client for the Refresh Token grant. Used by {@code GoogleDriveService} to + * silently renew expired access tokens before each Drive API call. + */ + @Bean + public OAuth2AccessTokenResponseClient refreshTokenResponseClient() { + DefaultRefreshTokenTokenResponseClient client = new DefaultRefreshTokenTokenResponseClient(); + client.setRestOperations(buildRestTemplate()); + return client; + } + + private RestTemplate buildRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofMillis(CONNECT_TIMEOUT_MS).toMillis()); + factory.setReadTimeout((int) Duration.ofMillis(READ_TIMEOUT_MS).toMillis()); + return new RestTemplate(factory); + } +} diff --git a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java index c6b03a4..9352265 100644 --- a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java @@ -45,6 +45,12 @@ public ResponseEntity> handleConflict(ConflictException ex) return buildResponse(HttpStatus.CONFLICT, ex.getMessage()); } + @ExceptionHandler(ServiceUnavailableException.class) + public ResponseEntity> handleServiceUnavailable(ServiceUnavailableException ex) { + log.warn("event=SERVICE_UNAVAILABLE message={}", ex.getMessage()); + return buildResponse(HttpStatus.SERVICE_UNAVAILABLE, ex.getMessage()); + } + @ExceptionHandler({BadCredentialsException.class, AuthenticationException.class}) public ResponseEntity> handleBadCredentials(RuntimeException ex) { return buildResponse(HttpStatus.UNAUTHORIZED, "Invalid credentials"); diff --git a/src/main/java/com/jobtracker/exception/ServiceUnavailableException.java b/src/main/java/com/jobtracker/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..a6070ae --- /dev/null +++ b/src/main/java/com/jobtracker/exception/ServiceUnavailableException.java @@ -0,0 +1,8 @@ +package com.jobtracker.exception; + +public class ServiceUnavailableException extends RuntimeException { + + public ServiceUnavailableException(String message) { + super(message); + } +} diff --git a/src/main/java/com/jobtracker/repository/ApplicationRepository.java b/src/main/java/com/jobtracker/repository/ApplicationRepository.java index 9794da3..9bf1e2f 100644 --- a/src/main/java/com/jobtracker/repository/ApplicationRepository.java +++ b/src/main/java/com/jobtracker/repository/ApplicationRepository.java @@ -2,12 +2,10 @@ import com.jobtracker.entity.JobApplication; import com.jobtracker.entity.enums.ApplicationStatus; -import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -22,10 +20,6 @@ public interface ApplicationRepository extends JpaRepository findByIdAndUserId(UUID id, UUID userId); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT a FROM JobApplication a WHERE a.id = :id AND a.user.id = :userId") - Optional findByIdAndUserIdForUpdate(@Param("id") UUID id, @Param("userId") UUID userId); - long countByUserIdAndArchivedFalse(UUID userId); long countByUserIdAndInterviewScheduledTrueAndArchivedFalse(UUID userId); diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java index 258c5ee..eaf5cd8 100644 --- a/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java +++ b/src/main/java/com/jobtracker/repository/GoogleDriveBaseResumeRepository.java @@ -12,4 +12,6 @@ public interface GoogleDriveBaseResumeRepository extends JpaRepository findAllByConnectionIdOrderByCreatedAtAsc(UUID connectionId); Optional findByIdAndConnectionUserId(UUID id, UUID userId); + + Optional findByConnectionIdAndGoogleFileId(UUID connectionId, String googleFileId); } diff --git a/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java b/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java index 26e184f..da5cb2f 100644 --- a/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java +++ b/src/main/java/com/jobtracker/repository/GoogleDriveOAuthStateRepository.java @@ -12,4 +12,6 @@ public interface GoogleDriveOAuthStateRepository extends JpaRepository findByStateToken(String stateToken); void deleteByExpiresAtBefore(LocalDateTime expiresAt); + + void deleteByUserId(UUID userId); } diff --git a/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java deleted file mode 100644 index 9aa0d21..0000000 --- a/src/main/java/com/jobtracker/service/DefaultGoogleDriveApiClient.java +++ /dev/null @@ -1,236 +0,0 @@ -package com.jobtracker.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.jobtracker.config.GoogleDriveProperties; -import com.jobtracker.exception.BadRequestException; -import org.springframework.http.MediaType; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientResponseException; -import org.springframework.web.util.UriComponentsBuilder; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -@Component -public class DefaultGoogleDriveApiClient implements GoogleDriveApiClient { - - private static final String DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3"; - - private final GoogleDriveProperties properties; - private final RestClient restClient; - - public DefaultGoogleDriveApiClient(GoogleDriveProperties properties, RestClient.Builder restClientBuilder) { - this.properties = properties; - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(Duration.ofSeconds(10)); - requestFactory.setReadTimeout(Duration.ofSeconds(30)); - this.restClient = restClientBuilder.requestFactory(requestFactory).build(); - } - - @Override - public String buildAuthorizationUrl(String state) { - properties.validateConfigured(); - return UriComponentsBuilder.fromUriString(properties.getAuthorizationUri()) - .queryParam("client_id", properties.getClientId()) - .queryParam("redirect_uri", properties.getRedirectUri()) - .queryParam("response_type", "code") - .queryParam("scope", properties.getScopeValue()) - .queryParam("access_type", "offline") - .queryParam("include_granted_scopes", "true") - .queryParam("prompt", "consent") - .queryParam("state", state) - .build() - .encode() - .toUriString(); - } - - @Override - public OAuthTokens exchangeAuthorizationCode(String code) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("code", code); - body.add("client_id", properties.getClientId()); - body.add("client_secret", properties.getClientSecret()); - body.add("redirect_uri", properties.getRedirectUri()); - body.add("grant_type", "authorization_code"); - - JsonNode response = postForm(properties.getTokenUri(), body, "exchange Google authorization code"); - String refreshToken = textValue(response, "refresh_token"); - if (refreshToken == null || refreshToken.isBlank()) { - throw new BadRequestException("Google OAuth did not return a refresh token. Reconnect and grant consent again."); - } - return toTokens(response, refreshToken); - } - - @Override - public OAuthTokens refreshAccessToken(String refreshToken) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("refresh_token", refreshToken); - body.add("client_id", properties.getClientId()); - body.add("client_secret", properties.getClientSecret()); - body.add("grant_type", "refresh_token"); - - JsonNode response = postForm(properties.getTokenUri(), body, "refresh Google access token"); - return toTokens(response, refreshToken); - } - - @Override - public GoogleDriveAccountProfile getCurrentAccount(String accessToken) { - String uri = UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/about") - .queryParam("fields", "user(emailAddress,displayName,permissionId)") - .build() - .encode() - .toUriString(); - JsonNode response = getAuthorizedJson(uri, accessToken, "read Google Drive account"); - JsonNode userNode = response.path("user"); - return new GoogleDriveAccountProfile( - userNode.path("permissionId").asText(), - userNode.path("emailAddress").asText(), - userNode.path("displayName").asText(null) - ); - } - - @Override - public DriveFileMetadata getFileMetadata(String accessToken, String fileId) { - String uri = UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files/" + fileId) - .queryParam("supportsAllDrives", "true") - .queryParam("fields", "id,name,mimeType,webViewLink") - .build() - .encode() - .toUriString(); - JsonNode response = getAuthorizedJson(uri, accessToken, "read Google Drive file metadata"); - return toDriveFileMetadata(response); - } - - @Override - public Optional findFolderByName(String accessToken, String parentFolderId, String folderName) { - String escapedFolderName = folderName.replace("\\", "\\\\").replace("'", "\\'"); - String query = "mimeType='" + GOOGLE_FOLDER_MIME_TYPE + "' and trashed=false and '" + parentFolderId - + "' in parents and name='" + escapedFolderName + "'"; - String uri = UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files") - .queryParam("supportsAllDrives", "true") - .queryParam("includeItemsFromAllDrives", "true") - .queryParam("corpora", "allDrives") - .queryParam("q", query) - .queryParam("pageSize", 1) - .queryParam("fields", "files(id,name,mimeType,webViewLink)") - .build() - .encode() - .toUriString(); - - JsonNode response = getAuthorizedJson(uri, accessToken, "find Google Drive folder"); - JsonNode filesNode = response.path("files"); - if (!filesNode.isArray() || filesNode.isEmpty()) { - return Optional.empty(); - } - return Optional.of(toDriveFileMetadata(filesNode.get(0))); - } - - @Override - public DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName) { - JsonNode response = postAuthorizedJson( - UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files") - .queryParam("supportsAllDrives", "true") - .queryParam("fields", "id,name,mimeType,webViewLink") - .build() - .encode() - .toUriString(), - accessToken, - new FolderCreateRequest(folderName, GOOGLE_FOLDER_MIME_TYPE, List.of(parentFolderId)), - "create Google Drive folder" - ); - return toDriveFileMetadata(response); - } - - @Override - public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) { - JsonNode response = postAuthorizedJson( - UriComponentsBuilder.fromUriString(DRIVE_API_BASE_URL + "/files/" + sourceFileId + "/copy") - .queryParam("supportsAllDrives", "true") - .queryParam("fields", "id,name,mimeType,webViewLink") - .build() - .encode() - .toUriString(), - accessToken, - new FolderCreateRequest(newName, null, List.of(targetFolderId)), - "copy Google Docs file" - ); - return toDriveFileMetadata(response); - } - - private JsonNode postForm(String uri, MultiValueMap body, String action) { - try { - return restClient.post() - .uri(uri) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(body) - .retrieve() - .body(JsonNode.class); - } catch (RestClientResponseException ex) { - throw googleApiException(action, ex); - } - } - - private JsonNode getAuthorizedJson(String uri, String accessToken, String action) { - try { - return restClient.get() - .uri(uri) - .headers(headers -> headers.setBearerAuth(accessToken)) - .retrieve() - .body(JsonNode.class); - } catch (RestClientResponseException ex) { - throw googleApiException(action, ex); - } - } - - private JsonNode postAuthorizedJson(String uri, String accessToken, Object body, String action) { - try { - return restClient.post() - .uri(uri) - .contentType(MediaType.APPLICATION_JSON) - .headers(headers -> headers.setBearerAuth(accessToken)) - .body(body) - .retrieve() - .body(JsonNode.class); - } catch (RestClientResponseException ex) { - throw googleApiException(action, ex); - } - } - - private OAuthTokens toTokens(JsonNode response, String refreshToken) { - String accessToken = textValue(response, "access_token"); - if (accessToken == null || accessToken.isBlank()) { - throw new BadRequestException("Google OAuth response did not include an access token"); - } - long expiresIn = response.path("expires_in").asLong(3600); - String scope = textValue(response, "scope"); - return new OAuthTokens(accessToken, refreshToken, LocalDateTime.now().plus(Duration.ofSeconds(expiresIn)), scope); - } - - private DriveFileMetadata toDriveFileMetadata(JsonNode node) { - return new DriveFileMetadata( - node.path("id").asText(), - node.path("name").asText(), - node.path("mimeType").asText(), - node.path("webViewLink").asText(null) - ); - } - - private String textValue(JsonNode node, String fieldName) { - JsonNode child = node.get(fieldName); - return child == null || child.isNull() ? null : child.asText(); - } - - private BadRequestException googleApiException(String action, RestClientResponseException ex) { - String message = "Failed to " + action - + " due to an upstream Google API error (status " + ex.getStatusCode().value() + ")"; - return new BadRequestException(message); - } - - private record FolderCreateRequest(String name, String mimeType, List parents) {} -} diff --git a/src/main/java/com/jobtracker/service/DriveClientFactory.java b/src/main/java/com/jobtracker/service/DriveClientFactory.java new file mode 100644 index 0000000..2901edc --- /dev/null +++ b/src/main/java/com/jobtracker/service/DriveClientFactory.java @@ -0,0 +1,63 @@ +package com.jobtracker.service; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; + +/** + * Factory that creates ready-to-use {@link Drive} SDK client instances from a stored access token. + * The {@link HttpTransport} and {@link JsonFactory} are shared singletons (thread-safe) to avoid + * the overhead of re-creating TLS contexts on every API call. + */ +@Component +public class DriveClientFactory { + + private static final String APPLICATION_NAME = "JobApplyTracker"; + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + + private final HttpTransport httpTransport; + private final JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + + public DriveClientFactory() throws GeneralSecurityException, IOException { + this.httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + } + + /** + * Creates a {@link Drive} client authenticated with the given access token. + * + * @param accessToken the current OAuth2 access token + * @param accessTokenExpiresAt expiry hint passed to the Google credentials (may be {@code null}) + * @return a configured {@link Drive} instance + */ + public Drive create(String accessToken, LocalDateTime accessTokenExpiresAt) { + Date expiryDate = accessTokenExpiresAt != null + ? Date.from(accessTokenExpiresAt.toInstant(ZoneOffset.UTC)) + : null; + AccessToken token = new AccessToken(accessToken, expiryDate); + OAuth2Credentials credentials = OAuth2Credentials.create(token); + + HttpRequestInitializer requestInitializer = request -> { + new HttpCredentialsAdapter(credentials).initialize(request); + request.setConnectTimeout(CONNECT_TIMEOUT_MS); + request.setReadTimeout(READ_TIMEOUT_MS); + }; + + return new Drive.Builder(httpTransport, jsonFactory, requestInitializer) + .setApplicationName(APPLICATION_NAME) + .build(); + } +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java index ca2cefe..c3f92f3 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java @@ -11,6 +11,8 @@ import com.jobtracker.repository.GoogleDriveOAuthStateRepository; import com.jobtracker.util.SecurityUtils; import org.apache.commons.codec.digest.DigestUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.util.UriComponentsBuilder; @@ -22,6 +24,7 @@ @Service public class GoogleDriveOAuthService { + private static final Logger log = LoggerFactory.getLogger(GoogleDriveOAuthService.class); private static final int OAUTH_STATE_TTL_MINUTES = 10; private final GoogleDriveApiClient googleDriveApiClient; @@ -116,6 +119,9 @@ public String handleCallback(String state, String code, String error) { return buildFrontendRedirect("success", "Google Drive connected successfully"); } catch (BadRequestException ex) { return buildFrontendRedirect("error", ex.getMessage()); + } catch (Exception ex) { + log.error("event=GOOGLE_OAUTH_CALLBACK_ERROR state={}", state, ex); + return buildFrontendRedirect("error", "An unexpected error occurred. Please try again."); } finally { if (oauthState != null) { oauthStateRepository.delete(oauthState); @@ -123,9 +129,16 @@ public String handleCallback(String state, String code, String error) { } } + /** + * Disconnects Google Drive and invalidates any pending OAuth authorization states for the user. + * Revoking pending states prevents a stale consent tab from re-connecting Drive after + * intentional disconnection. + */ @Transactional public MessageResponse disconnect() { - connectionRepository.findByUserId(securityUtils.getCurrentUserId()) + UUID userId = securityUtils.getCurrentUserId(); + oauthStateRepository.deleteByUserId(userId); + connectionRepository.findByUserId(userId) .ifPresent(connectionRepository::delete); return new MessageResponse("Google Drive connection removed"); } diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java index 21afb4b..9b800f0 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -11,6 +11,7 @@ import com.jobtracker.repository.GoogleDriveBaseResumeRepository; import com.jobtracker.repository.GoogleDriveConnectionRepository; import com.jobtracker.util.SecurityUtils; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -90,18 +91,23 @@ public GoogleDriveBaseResumeResponse addBaseResume(GoogleDriveBaseResumeRequest throw new BadRequestException("Only Google Docs base resumes are supported"); } - GoogleDriveBaseResume resume = baseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(connection.getId()) - .stream() - .filter(existing -> existing.getGoogleFileId().equals(file.id())) - .findFirst() + GoogleDriveBaseResume resume = baseResumeRepository + .findByConnectionIdAndGoogleFileId(connection.getId(), file.id()) .orElseGet(GoogleDriveBaseResume::new); resume.setConnection(connection); resume.setGoogleFileId(file.id()); resume.setDocumentName(file.name()); resume.setWebViewLink(resolveDocumentLink(file)); - GoogleDriveBaseResume saved = baseResumeRepository.save(resume); - return toBaseResumeResponse(saved); + try { + GoogleDriveBaseResume saved = baseResumeRepository.save(resume); + return toBaseResumeResponse(saved); + } catch (DataIntegrityViolationException ex) { + // Concurrent insert on the same (connection_id, google_file_id) – return existing + return baseResumeRepository.findByConnectionIdAndGoogleFileId(connection.getId(), file.id()) + .map(this::toBaseResumeResponse) + .orElseThrow(() -> new BadRequestException("Failed to save base resume")); + } } @Transactional @@ -116,7 +122,7 @@ public void deleteBaseResume(UUID baseResumeId) { public GoogleDriveResumeCopyResponse copyBaseResumeToApplication(UUID applicationId, GoogleDriveResumeCopyRequest request) { UUID userId = securityUtils.getCurrentUserId(); GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); - JobApplication application = applicationRepository.findByIdAndUserIdForUpdate(applicationId, userId) + JobApplication application = applicationRepository.findByIdAndUserId(applicationId, userId) .orElseThrow(() -> new ResourceNotFoundException("Application not found with id: " + applicationId)); if (!StringUtils.hasText(connection.getRootFolderId())) { diff --git a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java new file mode 100644 index 0000000..4151a2b --- /dev/null +++ b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java @@ -0,0 +1,275 @@ +package com.jobtracker.service; + +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.About; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.FileList; +import com.google.api.services.drive.model.User; +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.exception.BadRequestException; +import com.jobtracker.exception.ServiceUnavailableException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Google Drive API client backed by the official Google Drive Java SDK and Spring Security's + * OAuth2 token exchange infrastructure. + * + *

    + *
  • All Drive file operations (getFileMetadata, findFolderByName, createFolder, copyGoogleDoc, + * getCurrentAccount) use the {@code google-api-services-drive} SDK via {@link DriveClientFactory}. + *
  • OAuth2 token exchange and refresh use Spring Security's + * {@link org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient} + * and {@link org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient}, + * eliminating all manual HTTP token requests. + *
  • Google API errors are classified: 4xx responses become {@link BadRequestException} (HTTP + * 400); 5xx and rate-limit (429) responses become {@link ServiceUnavailableException} (HTTP + * 503) so that callers can distinguish retryable failures from invalid requests. + *
+ */ +@Component +public class SdkGoogleDriveApiClient implements GoogleDriveApiClient { + + private static final Logger log = LoggerFactory.getLogger(SdkGoogleDriveApiClient.class); + + private final GoogleDriveProperties properties; + private final ClientRegistration clientRegistration; + private final DriveClientFactory driveClientFactory; + private final OAuth2AccessTokenResponseClient authorizationCodeClient; + private final OAuth2AccessTokenResponseClient refreshTokenClient; + + public SdkGoogleDriveApiClient( + GoogleDriveProperties properties, + ClientRegistration clientRegistration, + DriveClientFactory driveClientFactory, + OAuth2AccessTokenResponseClient authorizationCodeClient, + OAuth2AccessTokenResponseClient refreshTokenClient) { + this.properties = properties; + this.clientRegistration = clientRegistration; + this.driveClientFactory = driveClientFactory; + this.authorizationCodeClient = authorizationCodeClient; + this.refreshTokenClient = refreshTokenClient; + } + + // ── OAuth2 operations ──────────────────────────────────────────────────── + + @Override + public String buildAuthorizationUrl(String state) { + properties.validateConfigured(); + return UriComponentsBuilder + .fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) + .queryParam("client_id", clientRegistration.getClientId()) + .queryParam("redirect_uri", clientRegistration.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", clientRegistration.getScopes())) + .queryParam("access_type", "offline") + .queryParam("include_granted_scopes", "true") + .queryParam("prompt", "consent") + .queryParam("state", state) + .build() + .encode() + .toUriString(); + } + + /** + * Exchanges an authorization code for tokens using Spring's + * {@link org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient}. + * State validation is performed upstream by {@code GoogleDriveOAuthService} before calling + * this method, so a placeholder value is used here to satisfy the exchange API contract. + */ + @Override + public OAuthTokens exchangeAuthorizationCode(String code) { + OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest.authorizationCode() + .clientId(clientRegistration.getClientId()) + .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .redirectUri(clientRegistration.getRedirectUri()) + .scopes(clientRegistration.getScopes()) + .state("_") + .build(); + OAuth2AuthorizationResponse authResponse = OAuth2AuthorizationResponse.success(code) + .redirectUri(clientRegistration.getRedirectUri()) + .state("_") + .build(); + try { + OAuth2AccessTokenResponse response = authorizationCodeClient.getTokenResponse( + new OAuth2AuthorizationCodeGrantRequest(clientRegistration, + new OAuth2AuthorizationExchange(authRequest, authResponse))); + if (response.getRefreshToken() == null) { + throw new BadRequestException( + "Google OAuth did not return a refresh token. Reconnect and grant consent again."); + } + return toOAuthTokens(response, response.getRefreshToken().getTokenValue()); + } catch (OAuth2AuthorizationException ex) { + throw translateOAuth2Exception(ex, "exchange authorization code"); + } + } + + @Override + public OAuthTokens refreshAccessToken(String refreshToken) { + // A placeholder access token is required by the grant-request API; Google ignores it. + OAuth2AccessToken placeholder = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "placeholder", null, null); + try { + OAuth2AccessTokenResponse response = refreshTokenClient.getTokenResponse( + new OAuth2RefreshTokenGrantRequest(clientRegistration, placeholder, + new OAuth2RefreshToken(refreshToken, null))); + return toOAuthTokens(response, refreshToken); + } catch (OAuth2AuthorizationException ex) { + throw translateOAuth2Exception(ex, "refresh access token"); + } + } + + // ── Drive file operations (SDK) ────────────────────────────────────────── + + @Override + public GoogleDriveAccountProfile getCurrentAccount(String accessToken) { + return executeDriveOp(accessToken, "read account", drive -> { + About about = drive.about().get() + .setFields("user(emailAddress,displayName,permissionId)") + .execute(); + User user = about.getUser(); + return new GoogleDriveAccountProfile( + user.getPermissionId(), + user.getEmailAddress(), + user.getDisplayName()); + }); + } + + @Override + public DriveFileMetadata getFileMetadata(String accessToken, String fileId) { + return executeDriveOp(accessToken, "get file metadata", drive -> + toDriveFileMetadata(drive.files().get(fileId) + .setSupportsAllDrives(true) + .setFields("id,name,mimeType,webViewLink") + .execute())); + } + + @Override + public Optional findFolderByName(String accessToken, String parentFolderId, String folderName) { + return executeDriveOp(accessToken, "find folder", drive -> { + String escaped = folderName.replace("\\", "\\\\").replace("'", "\\'"); + String q = "mimeType='" + GOOGLE_FOLDER_MIME_TYPE + "' and trashed=false and '" + + parentFolderId + "' in parents and name='" + escaped + "'"; + FileList result = drive.files().list() + .setQ(q) + .setPageSize(1) + .setFields("files(id,name,mimeType,webViewLink)") + .setSupportsAllDrives(true) + .setIncludeItemsFromAllDrives(true) + .setCorpora("allDrives") + .execute(); + List files = result.getFiles(); + if (files == null || files.isEmpty()) { + return Optional.empty(); + } + return Optional.of(toDriveFileMetadata(files.get(0))); + }); + } + + @Override + public DriveFileMetadata createFolder(String accessToken, String parentFolderId, String folderName) { + return executeDriveOp(accessToken, "create folder", drive -> { + File metadata = new File() + .setName(folderName) + .setMimeType(GOOGLE_FOLDER_MIME_TYPE) + .setParents(List.of(parentFolderId)); + return toDriveFileMetadata(drive.files().create(metadata) + .setSupportsAllDrives(true) + .setFields("id,name,mimeType,webViewLink") + .execute()); + }); + } + + @Override + public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) { + return executeDriveOp(accessToken, "copy document", drive -> { + File metadata = new File() + .setName(newName) + .setParents(List.of(targetFolderId)); + return toDriveFileMetadata(drive.files().copy(sourceFileId, metadata) + .setSupportsAllDrives(true) + .setFields("id,name,mimeType,webViewLink") + .execute()); + }); + } + + // ── internal helpers ───────────────────────────────────────────────────── + + @FunctionalInterface + private interface DriveOperation { + T execute(Drive drive) throws IOException; + } + + private T executeDriveOp(String accessToken, String action, DriveOperation op) { + Drive drive = driveClientFactory.create(accessToken, null); + try { + return op.execute(drive); + } catch (GoogleJsonResponseException ex) { + throw translateDriveException(action, ex); + } catch (IOException ex) { + log.warn("event=GOOGLE_DRIVE_IO_ERROR action={} message={}", action, ex.getMessage()); + throw new ServiceUnavailableException("Google Drive is temporarily unavailable (" + action + ")"); + } + } + + private RuntimeException translateDriveException(String action, GoogleJsonResponseException ex) { + int status = ex.getStatusCode(); + String message = "Failed to " + action + " (status " + status + ")"; + if (status == 429 || status >= 500) { + log.warn("event=GOOGLE_DRIVE_UPSTREAM_ERROR action={} status={}", action, status); + return new ServiceUnavailableException(message); + } + log.warn("event=GOOGLE_DRIVE_CLIENT_ERROR action={} status={}", action, status); + return new BadRequestException(message); + } + + private RuntimeException translateOAuth2Exception(OAuth2AuthorizationException ex, String action) { + String code = ex.getError().getErrorCode(); + log.warn("event=GOOGLE_OAUTH_ERROR action={} errorCode={}", action, code); + if ("invalid_grant".equals(code) || "invalid_client".equals(code)) { + return new BadRequestException("Google OAuth error during " + action + ": " + code); + } + return new ServiceUnavailableException("Google OAuth service unavailable during " + action); + } + + private OAuthTokens toOAuthTokens(OAuth2AccessTokenResponse response, String refreshTokenValue) { + OAuth2AccessToken accessToken = response.getAccessToken(); + Instant expiresAt = accessToken.getExpiresAt(); + LocalDateTime expiresAtLdt = expiresAt != null + ? LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC) + : LocalDateTime.now().plusHours(1); + Set scopes = accessToken.getScopes(); + String scopeStr = (scopes == null || scopes.isEmpty()) ? null : String.join(" ", scopes); + return new OAuthTokens(accessToken.getTokenValue(), refreshTokenValue, expiresAtLdt, scopeStr); + } + + private DriveFileMetadata toDriveFileMetadata(File file) { + return new DriveFileMetadata( + file.getId(), + file.getName(), + file.getMimeType(), + file.getWebViewLink()); + } +} diff --git a/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java index f9c6fd2..095bcab 100644 --- a/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java +++ b/src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jobtracker.dto.auth.AuthResponse; import com.jobtracker.dto.auth.RegisterRequest; +import com.jobtracker.entity.GoogleDriveBaseResume; import com.jobtracker.entity.GoogleDriveConnection; +import com.jobtracker.entity.JobApplication; import com.jobtracker.repository.ApplicationRepository; import com.jobtracker.repository.GoogleDriveBaseResumeRepository; import com.jobtracker.repository.GoogleDriveConnectionRepository; @@ -25,12 +27,15 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @@ -139,6 +144,157 @@ void updateRootFolder_shouldReturnUpdatedStatus() throws Exception { .andExpect(jsonPath("$.rootFolderName").value("Root Folder")); } + @Test + void disconnect_shouldRemoveConnectionAndReturnMessage() throws Exception { + googleDriveConnectionRepository.save(buildConnection()); + assertThat(googleDriveConnectionRepository.findAll()).hasSize(1); + + mockMvc.perform(delete("/api/v1/google-drive/connection") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Google Drive connection removed")); + + assertThat(googleDriveConnectionRepository.findAll()).isEmpty(); + } + + @Test + void disconnect_shouldSucceedEvenWithNoExistingConnection() throws Exception { + mockMvc.perform(delete("/api/v1/google-drive/connection") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Google Drive connection removed")); + } + + @Test + void addBaseResume_shouldPersistResumeAndReturn201() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection()); + googleDriveApiClient.fileMetadataById.put("doc-abc", + new GoogleDriveApiClient.DriveFileMetadata( + "doc-abc", + "My Resume", + GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE, + "https://docs.google.com/document/d/doc-abc/edit" + )); + + mockMvc.perform(post("/api/v1/google-drive/base-resumes") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"documentIdOrUrl\":\"doc-abc\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.googleFileId").value("doc-abc")) + .andExpect(jsonPath("$.documentName").value("My Resume")); + + assertThat(googleDriveBaseResumeRepository.findAllByConnectionIdOrderByCreatedAtAsc(connection.getId())) + .hasSize(1); + } + + @Test + void addBaseResume_shouldRejectNonGoogleDocsFile() throws Exception { + googleDriveConnectionRepository.save(buildConnection()); + googleDriveApiClient.fileMetadataById.put("pdf-file", + new GoogleDriveApiClient.DriveFileMetadata( + "pdf-file", + "Resume.pdf", + "application/pdf", + null + )); + + mockMvc.perform(post("/api/v1/google-drive/base-resumes") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"documentIdOrUrl\":\"pdf-file\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void deleteBaseResume_shouldRemoveResumeAndReturn200() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + mockMvc.perform(delete("/api/v1/google-drive/base-resumes/" + resume.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Base resume deleted successfully")); + + assertThat(googleDriveBaseResumeRepository.findAll()).isEmpty(); + } + + @Test + void deleteBaseResume_shouldReturn404ForUnknownId() throws Exception { + mockMvc.perform(delete("/api/v1/google-drive/base-resumes/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNotFound()); + } + + @Test + void copyResume_shouldCreateFolderAndCopyDocument() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnectionWithRootFolder()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + JobApplication application = new JobApplication(); + application.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + application.setVacancyName("Backend Engineer"); + application.setOrganization("Acme"); + application.setApplicationDate(LocalDate.now()); + application = applicationRepository.save(application); + + googleDriveApiClient.fileMetadataById.put("root-folder-id", + new GoogleDriveApiClient.DriveFileMetadata( + "root-folder-id", + "Job Tracker Root", + GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE, + "https://drive.google.com/drive/folders/root-folder-id" + )); + + mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/resume-copies") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"baseResumeId\":\"" + resume.getId() + "\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.applicationId").value(application.getId().toString())) + .andExpect(jsonPath("$.baseResumeId").value(resume.getId().toString())) + .andExpect(jsonPath("$.copiedFileId").value("copied-file")) + .andExpect(jsonPath("$.vacancyFolderId").value("created-folder")); + } + + @Test + void copyResume_shouldReturn400WhenNoRootFolderConfigured() throws Exception { + GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection()); + GoogleDriveBaseResume resume = buildBaseResume(connection); + googleDriveBaseResumeRepository.save(resume); + + JobApplication application = new JobApplication(); + application.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); + application.setVacancyName("SWE"); + application.setOrganization("Corp"); + application.setApplicationDate(LocalDate.now()); + application = applicationRepository.save(application); + + mockMvc.perform(post("/api/v1/google-drive/applications/" + application.getId() + "/resume-copies") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"baseResumeId\":\"" + resume.getId() + "\"}")) + .andExpect(status().isBadRequest()); + } + + private GoogleDriveConnection buildConnectionWithRootFolder() { + GoogleDriveConnection connection = buildConnection(); + connection.setRootFolderId("root-folder-id"); + connection.setRootFolderName("Job Tracker Root"); + return connection; + } + + private GoogleDriveBaseResume buildBaseResume(GoogleDriveConnection connection) { + GoogleDriveBaseResume resume = new GoogleDriveBaseResume(); + resume.setConnection(connection); + resume.setGoogleFileId("resume-file-id"); + resume.setDocumentName("Base Resume"); + resume.setWebViewLink("https://docs.google.com/document/d/resume-file-id/edit"); + return resume; + } + private GoogleDriveConnection buildConnection() { GoogleDriveConnection connection = new GoogleDriveConnection(); connection.setUser(userRepository.findByEmail("driveuser@example.com").orElseThrow()); diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java index 18b61e4..f155670 100644 --- a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java +++ b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java @@ -162,7 +162,7 @@ void copyBaseResumeToApplication_shouldCreateFolderAndCopyDocument() { connection.setRootFolderId("root-folder-id"); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); - when(applicationRepository.findByIdAndUserIdForUpdate(APPLICATION_ID, USER_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.findByIdAndUserId(APPLICATION_ID, USER_ID)).thenReturn(Optional.of(application)); when(baseResumeRepository.findByIdAndConnectionUserId(RESUME_ID, USER_ID)).thenReturn(Optional.of(baseResume)); when(googleDriveApiClient.getFileMetadata("access-token", "root-folder-id")) .thenReturn(new GoogleDriveApiClient.DriveFileMetadata( From 4a4108ce9e286313986f4a847617d1df4204ad41 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Tue, 5 May 2026 19:06:01 -0300 Subject: [PATCH 5/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../service/GoogleDriveOAuthService.java | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java index c3f92f3..eea337d 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java @@ -72,20 +72,23 @@ public String handleCallback(String state, String code, String error) { validateServerConfigured(); oauthStateRepository.deleteByExpiresAtBefore(LocalDateTime.now()); - if (error != null && !error.isBlank()) { - return buildFrontendRedirect("error", "Google returned an OAuth error: " + error); - } - if (state == null || state.isBlank()) { - return buildFrontendRedirect("error", "Missing OAuth state"); - } - if (code == null || code.isBlank()) { - return buildFrontendRedirect("error", "Missing authorization code"); - } - GoogleDriveOAuthState oauthState = null; try { - oauthState = oauthStateRepository.findByStateToken(state) - .orElseThrow(() -> new BadRequestException("Invalid or expired Google OAuth state")); + if (state != null && !state.isBlank()) { + oauthState = oauthStateRepository.findByStateToken(state).orElse(null); + } + if (error != null && !error.isBlank()) { + return buildFrontendRedirect("error", "Google returned an OAuth error: " + error); + } + if (state == null || state.isBlank()) { + return buildFrontendRedirect("error", "Missing OAuth state"); + } + if (code == null || code.isBlank()) { + return buildFrontendRedirect("error", "Missing authorization code"); + } + if (oauthState == null) { + throw new BadRequestException("Invalid or expired Google OAuth state"); + } if (oauthState.getExpiresAt().isBefore(LocalDateTime.now())) { throw new BadRequestException("Google OAuth state expired"); } From 5aa7202109b8b04c34cd03aac790e34919a787f2 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Tue, 5 May 2026 19:06:17 -0300 Subject: [PATCH 6/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/com/jobtracker/service/SdkGoogleDriveApiClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java index 4151a2b..13b1615 100644 --- a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java @@ -259,7 +259,7 @@ private OAuthTokens toOAuthTokens(OAuth2AccessTokenResponse response, String ref Instant expiresAt = accessToken.getExpiresAt(); LocalDateTime expiresAtLdt = expiresAt != null ? LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC) - : LocalDateTime.now().plusHours(1); + : LocalDateTime.now(ZoneOffset.UTC).plusHours(1); Set scopes = accessToken.getScopes(); String scopeStr = (scopes == null || scopes.isEmpty()) ? null : String.join(" ", scopes); return new OAuthTokens(accessToken.getTokenValue(), refreshTokenValue, expiresAtLdt, scopeStr); From ba3770cc798d46918a6e9d244030b13012f1ba99 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Tue, 5 May 2026 19:06:30 -0300 Subject: [PATCH 7/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 685c9e5..b7e3361 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use | `GOOGLE_DRIVE_CLIENT_ID` | *(empty)* | Google OAuth client ID for Drive integration | | `GOOGLE_DRIVE_CLIENT_SECRET` | *(empty)* | Google OAuth client secret for Drive integration | | `GOOGLE_DRIVE_REDIRECT_URI` | `http://localhost:8080/api/v1/google-drive/oauth/callback` | OAuth callback URL registered in Google Cloud | -| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | `http://localhost:5173/settings/google-drive/callback` | Frontend URL that receives OAuth completion redirects | +| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | *(empty)* | Frontend URL that receives OAuth completion redirects | | `RATE_LIMIT_AUTH_LOGIN_LIMIT_FOR_PERIOD` | `10` | Max login requests allowed per refresh period | | `RATE_LIMIT_AUTH_LOGIN_REFRESH_PERIOD` | `1m` | Window used by the login rate limiter | | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | OTLP gRPC endpoint (Jaeger/OpenTelemetry collector) | From 11e03d424a943c705190025599e44e1873ebd766 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 23:01:29 +0000 Subject: [PATCH 8/9] fix: apply review c503702 - RestTemplate converters, state cleanup ordering, stored vacancy folder ID Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/6181ecfd-c2b7-4755-b2cc-2e3f30002198 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --- .../config/GoogleDriveClientConfig.java | 24 ++++++++- .../com/jobtracker/entity/JobApplication.java | 6 +++ .../repository/ApplicationRepository.java | 13 +++++ .../service/GoogleDriveOAuthService.java | 6 +-- .../service/GoogleDriveService.java | 51 +++++++++++++++++-- ...rive_vacancy_folder_id_to_applications.sql | 2 + .../unit/GoogleDriveOAuthServiceTest.java | 3 ++ 7 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/db/migration/V12__add_drive_vacancy_folder_id_to_applications.sql diff --git a/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java index 9b39102..2a5db59 100644 --- a/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java +++ b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java @@ -3,17 +3,21 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; import org.springframework.web.client.RestTemplate; import java.time.Duration; +import java.util.List; /** * Configures the Spring OAuth2 client infrastructure used by the Google Drive integration. @@ -83,10 +87,28 @@ public OAuth2AccessTokenResponseClient refreshTo return client; } + /** + * Builds a {@link RestTemplate} configured with the message converters and error handler + * required by Spring Security's OAuth2 token endpoint clients. + *

+ * {@link FormHttpMessageConverter} encodes the token-exchange form body; without it the + * request body is empty and Google returns {@code invalid_grant}. + * {@link OAuth2AccessTokenResponseHttpMessageConverter} parses Google's JSON token response; + * without it the response body cannot be deserialized and the exchange fails. + * {@link OAuth2ErrorResponseErrorHandler} translates Google error responses into typed + * {@link org.springframework.security.oauth2.core.OAuth2AuthorizationException}s rather than + * raw {@link org.springframework.web.client.HttpClientErrorException}s. + */ private RestTemplate buildRestTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout((int) Duration.ofMillis(CONNECT_TIMEOUT_MS).toMillis()); factory.setReadTimeout((int) Duration.ofMillis(READ_TIMEOUT_MS).toMillis()); - return new RestTemplate(factory); + RestTemplate restTemplate = new RestTemplate(factory); + restTemplate.setMessageConverters(List.of( + new FormHttpMessageConverter(), + new OAuth2AccessTokenResponseHttpMessageConverter() + )); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + return restTemplate; } } diff --git a/src/main/java/com/jobtracker/entity/JobApplication.java b/src/main/java/com/jobtracker/entity/JobApplication.java index 14bcb64..0f43616 100644 --- a/src/main/java/com/jobtracker/entity/JobApplication.java +++ b/src/main/java/com/jobtracker/entity/JobApplication.java @@ -84,6 +84,9 @@ public class JobApplication { @Column(name = "archived_at") private LocalDateTime archivedAt; + @Column(name = "drive_vacancy_folder_id", length = 255) + private String driveVacancyFolderId; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @@ -168,6 +171,9 @@ protected void onUpdate() { public LocalDateTime getArchivedAt() { return archivedAt; } public void setArchivedAt(LocalDateTime archivedAt) { this.archivedAt = archivedAt; } + public String getDriveVacancyFolderId() { return driveVacancyFolderId; } + public void setDriveVacancyFolderId(String driveVacancyFolderId) { this.driveVacancyFolderId = driveVacancyFolderId; } + public User getUser() { return user; } public void setUser(User user) { this.user = user; } diff --git a/src/main/java/com/jobtracker/repository/ApplicationRepository.java b/src/main/java/com/jobtracker/repository/ApplicationRepository.java index 9bf1e2f..25319bd 100644 --- a/src/main/java/com/jobtracker/repository/ApplicationRepository.java +++ b/src/main/java/com/jobtracker/repository/ApplicationRepository.java @@ -6,8 +6,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -52,4 +54,15 @@ public interface ApplicationRepository extends JpaRepository findByStatusIsNullAndUpdatedAtBefore(LocalDateTime updatedAt); List findByStatusIsNotNullAndStatusNotAndUpdatedAtBefore(ApplicationStatus status, LocalDateTime updatedAt); + + /** + * Atomically sets {@code driveVacancyFolderId} on an application only when the column is + * currently {@code NULL}. Returns the number of rows updated (1 on success, 0 if another + * concurrent request already stored a folder ID). Callers should re-read the entity to + * obtain the winning folder ID when this method returns 0. + */ + @Modifying + @Transactional + @Query("UPDATE JobApplication a SET a.driveVacancyFolderId = :folderId WHERE a.id = :id AND a.driveVacancyFolderId IS NULL") + int setDriveVacancyFolderIdIfAbsent(@Param("id") UUID id, @Param("folderId") String folderId); } diff --git a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java index eea337d..6d3d34d 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveOAuthService.java @@ -83,15 +83,15 @@ public String handleCallback(String state, String code, String error) { if (state == null || state.isBlank()) { return buildFrontendRedirect("error", "Missing OAuth state"); } - if (code == null || code.isBlank()) { - return buildFrontendRedirect("error", "Missing authorization code"); - } if (oauthState == null) { throw new BadRequestException("Invalid or expired Google OAuth state"); } if (oauthState.getExpiresAt().isBefore(LocalDateTime.now())) { throw new BadRequestException("Google OAuth state expired"); } + if (code == null || code.isBlank()) { + return buildFrontendRedirect("error", "Missing authorization code"); + } GoogleDriveApiClient.OAuthTokens tokens = googleDriveApiClient.exchangeAuthorizationCode(code); GoogleDriveApiClient.GoogleDriveAccountProfile accountProfile = diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java index 9b800f0..aff30b9 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -139,10 +139,10 @@ public GoogleDriveResumeCopyResponse copyBaseResumeToApplication(UUID applicatio } connection.setRootFolderName(rootFolder.name()); - String vacancyFolderName = buildVacancyFolderName(application); - GoogleDriveApiClient.DriveFileMetadata vacancyFolder = googleDriveApiClient - .findFolderByName(connection.getAccessToken(), rootFolder.id(), vacancyFolderName) - .orElseGet(() -> googleDriveApiClient.createFolder(connection.getAccessToken(), rootFolder.id(), vacancyFolderName)); + // Use the stored Drive folder ID for this application when available, so that renaming + // the vacancy/organization after the first copy still resolves to the correct folder. + GoogleDriveApiClient.DriveFileMetadata vacancyFolder = + resolveOrCreateVacancyFolder(connection, application, rootFolder.id(), userId); String copiedFileName = buildCopiedDocumentName(application, baseResume.getDocumentName()); GoogleDriveApiClient.DriveFileMetadata copiedFile = googleDriveApiClient.copyGoogleDoc( @@ -165,6 +165,49 @@ public GoogleDriveResumeCopyResponse copyBaseResumeToApplication(UUID applicatio ); } + /** + * Resolves the Drive vacancy folder for an application, creating it if needed. + * + *

The folder ID is stored on the {@link JobApplication} row after the first copy so that + * subsequent copies continue to use the same folder even if the vacancy name or organisation + * is later edited. The ID is stored with a conditional UPDATE ({@code WHERE + * drive_vacancy_folder_id IS NULL}) so that concurrent copy requests for the same application + * race to store their folder ID; the loser discards the orphan folder it created and uses the + * winner's ID instead. + */ + private GoogleDriveApiClient.DriveFileMetadata resolveOrCreateVacancyFolder( + GoogleDriveConnection connection, + JobApplication application, + String rootFolderId, + UUID userId) { + + // Fast path: use the stored folder ID (handles renames and removes the need for a name search) + if (StringUtils.hasText(application.getDriveVacancyFolderId())) { + return googleDriveApiClient.getFileMetadata(connection.getAccessToken(), application.getDriveVacancyFolderId()); + } + + // Slow path: search by name then create if not found + String vacancyFolderName = buildVacancyFolderName(application); + GoogleDriveApiClient.DriveFileMetadata folder = googleDriveApiClient + .findFolderByName(connection.getAccessToken(), rootFolderId, vacancyFolderName) + .orElseGet(() -> googleDriveApiClient.createFolder(connection.getAccessToken(), rootFolderId, vacancyFolderName)); + + // Attempt to atomically record the resolved folder ID; if a concurrent request beat us, + // fetch the winning ID from the DB and validate/use that folder instead. + int updated = applicationRepository.setDriveVacancyFolderIdIfAbsent(application.getId(), folder.id()); + if (updated == 0) { + String winningFolderId = applicationRepository.findByIdAndUserId(application.getId(), userId) + .map(JobApplication::getDriveVacancyFolderId) + .filter(StringUtils::hasText) + .orElse(folder.id()); // fallback: use our own newly created folder + if (!winningFolderId.equals(folder.id())) { + folder = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), winningFolderId); + } + } + + return folder; + } + private GoogleDriveConnection getConnectionWithFreshAccessToken() { requireServerConfigured(); GoogleDriveConnection connection = connectionRepository.findByUserId(securityUtils.getCurrentUserId()) diff --git a/src/main/resources/db/migration/V12__add_drive_vacancy_folder_id_to_applications.sql b/src/main/resources/db/migration/V12__add_drive_vacancy_folder_id_to_applications.sql new file mode 100644 index 0000000..d2f5d48 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_drive_vacancy_folder_id_to_applications.sql @@ -0,0 +1,2 @@ +ALTER TABLE job_applications + ADD COLUMN drive_vacancy_folder_id VARCHAR(255) NULL; diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java index ead99ef..2c62d9c 100644 --- a/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java +++ b/src/test/java/com/jobtracker/unit/GoogleDriveOAuthServiceTest.java @@ -121,12 +121,15 @@ void handleCallback_shouldRedirectWithError_whenMissingState() { @Test void handleCallback_shouldRedirectWithError_whenMissingCode() { + when(oauthStateRepository.findByStateToken(VALID_STATE)).thenReturn(Optional.of(validOAuthState)); + String result = oauthService.handleCallback(VALID_STATE, null, null); assertThat(result).contains("status=error"); assertThat(result).contains("Missing"); verifyNoInteractions(googleDriveApiClient); verifyNoInteractions(connectionRepository); + verify(oauthStateRepository).delete(validOAuthState); } @Test From 7bb6e7ad5d949326eac32d0a88d9da79c8f976f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 23:04:14 +0000 Subject: [PATCH 9/9] fix: address code review feedback on RestTemplate javadoc and concurrent fallback comment Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/6181ecfd-c2b7-4755-b2cc-2e3f30002198 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --- .../java/com/jobtracker/config/GoogleDriveClientConfig.java | 5 +++++ src/main/java/com/jobtracker/service/GoogleDriveService.java | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java index 2a5db59..3296758 100644 --- a/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java +++ b/src/main/java/com/jobtracker/config/GoogleDriveClientConfig.java @@ -98,6 +98,11 @@ public OAuth2AccessTokenResponseClient refreshTo * {@link OAuth2ErrorResponseErrorHandler} translates Google error responses into typed * {@link org.springframework.security.oauth2.core.OAuth2AuthorizationException}s rather than * raw {@link org.springframework.web.client.HttpClientErrorException}s. + *

+ * This {@link RestTemplate} is only used for OAuth2 token-endpoint exchanges. + * It intentionally registers only the two converters required for that operation, which is + * why the default message converters ({@code StringHttpMessageConverter}, etc.) are not + * present. */ private RestTemplate buildRestTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java index aff30b9..192a84f 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -193,13 +193,13 @@ private GoogleDriveApiClient.DriveFileMetadata resolveOrCreateVacancyFolder( .orElseGet(() -> googleDriveApiClient.createFolder(connection.getAccessToken(), rootFolderId, vacancyFolderName)); // Attempt to atomically record the resolved folder ID; if a concurrent request beat us, - // fetch the winning ID from the DB and validate/use that folder instead. + // fetch the winning ID from the DB and use that folder instead. int updated = applicationRepository.setDriveVacancyFolderIdIfAbsent(application.getId(), folder.id()); if (updated == 0) { String winningFolderId = applicationRepository.findByIdAndUserId(application.getId(), userId) .map(JobApplication::getDriveVacancyFolderId) .filter(StringUtils::hasText) - .orElse(folder.id()); // fallback: use our own newly created folder + .orElse(folder.id()); // concurrent tx hasn't committed yet; use our own folder this time if (!winningFolderId.equals(folder.id())) { folder = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), winningFolderId); }