diff --git a/pom.xml b/pom.xml index cf7093f3..797e0bf9 100644 --- a/pom.xml +++ b/pom.xml @@ -234,6 +234,13 @@ 1.25.0 + + + org.apache.pdfbox + pdfbox + 3.0.4 + + org.springframework.boot @@ -299,4 +306,4 @@ - \ No newline at end of file + diff --git a/src/main/java/com/jobtracker/dto/gdrive/BaseResumeContentResponse.java b/src/main/java/com/jobtracker/dto/gdrive/BaseResumeContentResponse.java index 4f014c17..172b13b1 100644 --- a/src/main/java/com/jobtracker/dto/gdrive/BaseResumeContentResponse.java +++ b/src/main/java/com/jobtracker/dto/gdrive/BaseResumeContentResponse.java @@ -4,7 +4,7 @@ import java.util.UUID; -@Schema(description = "Base resume metadata and plain text content extracted from Google Docs. " + +@Schema(description = "Base resume metadata and plain text content extracted from Google Docs or PDF. " + "Template placeholders such as {{SUMMARY}} and {{SKILLS}} are preserved as-is for AI analysis.") public record BaseResumeContentResponse( @Schema(description = "UUID of the base resume. Use this ID in API calls — filenames and Google file IDs are NOT valid here.", @@ -20,7 +20,10 @@ public record BaseResumeContentResponse( @Schema(description = "Whether this resume is a reusable template", example = "true") boolean template, - @Schema(description = "Plain text content extracted from the Google Docs document. " + + @Schema(description = "Whether this resume is read-only (PDF file — content extracted from the PDF)", example = "false") + boolean readOnly, + + @Schema(description = "Plain text content extracted from the Google Docs document or PDF. " + "Template placeholders such as {{SUMMARY}} and {{SKILLS}} are preserved.") String content ) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/BaseResumeResponse.java b/src/main/java/com/jobtracker/dto/gdrive/BaseResumeResponse.java index 019df169..b3617fd7 100644 --- a/src/main/java/com/jobtracker/dto/gdrive/BaseResumeResponse.java +++ b/src/main/java/com/jobtracker/dto/gdrive/BaseResumeResponse.java @@ -20,6 +20,9 @@ public record BaseResumeResponse( @Schema(description = "Whether this resume is a reusable template", example = "true") boolean template, + @Schema(description = "Whether this resume is read-only (PDF file — cannot be used for generation, placeholder detection, or copying)", example = "false") + boolean readOnly, + @Schema(description = "Timestamp when this base resume was registered") LocalDateTime createdAt ) {} diff --git a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java index 792bfc8a..dc7e8a9e 100644 --- a/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java +++ b/src/main/java/com/jobtracker/dto/gdrive/GoogleDriveBaseResumeResponse.java @@ -11,5 +11,6 @@ public record GoogleDriveBaseResumeResponse( String googleFileId, String documentName, String webViewLink, + boolean readOnly, LocalDateTime createdAt ) {} diff --git a/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java index a2faa745..7103059f 100644 --- a/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java +++ b/src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java @@ -35,6 +35,9 @@ public class GoogleDriveBaseResume { @Column(name = "is_template", nullable = false) private boolean template; + @Column(name = "read_only", nullable = false) + private boolean readOnly; + @Column(name = "web_view_link", length = 2048) private String webViewLink; @@ -103,6 +106,14 @@ public void setTemplate(boolean template) { this.template = template; } + public boolean isReadOnly() { + return readOnly; + } + + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + } + public String getWebViewLink() { return webViewLink; } diff --git a/src/main/java/com/jobtracker/mcp/tools/McpGoogleDriveTools.java b/src/main/java/com/jobtracker/mcp/tools/McpGoogleDriveTools.java index 8f50e519..466c9d7c 100644 --- a/src/main/java/com/jobtracker/mcp/tools/McpGoogleDriveTools.java +++ b/src/main/java/com/jobtracker/mcp/tools/McpGoogleDriveTools.java @@ -47,7 +47,7 @@ public McpGoogleDriveTools(GoogleDriveService googleDriveService, name = "List-Base-Resumes", title = "List Base Resumes", description = """ - List all Google Docs base resume templates configured by the current user. + List all base resume templates configured by the current user. Each entry contains: - id (UUID): the baseResumeId required by Copy-Resume-To-Application, Generate-Resume, \ @@ -56,6 +56,9 @@ public McpGoogleDriveTools(GoogleDriveService googleDriveService, - language: language code of the resume (e.g. "PT", "EN"). Use to select the correct \ template for the vacancy language (PT → PT-BR template, EN → EN-US template). - template: true if this is a reusable placeholder template. + - readOnly: true if this is a read-only PDF resume (cannot be used for template \ + generation, placeholder detection, or copying to applications — only content \ + reading is supported). - createdAt: registration timestamp. Call this tool before any resume operation to obtain a valid baseResumeId.""", diff --git a/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java index 937b79f2..b7f0a9b0 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveApiClient.java @@ -8,6 +8,7 @@ public interface GoogleDriveApiClient { String GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; String GOOGLE_FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + String PDF_MIME_TYPE = "application/pdf"; String buildAuthorizationUrl(String state); @@ -31,6 +32,8 @@ public interface GoogleDriveApiClient { DriveFileMetadata exportGoogleDocAsPdf(String accessToken, String documentId, String targetFolderId, String pdfName); + byte[] downloadFileBytes(String accessToken, String fileId); + record OAuthTokens(String accessToken, String refreshToken, LocalDateTime accessTokenExpiresAt, String scope) {} record GoogleDriveAccountProfile(String accountId, String emailAddress, String displayName) {} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveGeneratedResumeDownloadService.java b/src/main/java/com/jobtracker/service/GoogleDriveGeneratedResumeDownloadService.java index c1e10db2..f2a7009e 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveGeneratedResumeDownloadService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveGeneratedResumeDownloadService.java @@ -101,7 +101,16 @@ private DownloadedFile downloadBaseResume(UUID baseResumeId, String exportMimeTy .findByIdAndConnectionUserId(baseResumeId, userId) .orElseThrow(() -> new ResourceNotFoundException("Base resume not found with id: " + baseResumeId)); - byte[] content = exportDocument(connection.getAccessToken(), baseResume.getGoogleFileId(), exportMimeType); + if (baseResume.isReadOnly() && DOCX_MIME_TYPE.equals(exportMimeType)) { + throw new BadRequestException("Cannot download a PDF resume as DOCX"); + } + + byte[] content; + if (baseResume.isReadOnly()) { + content = googleDriveApiClient.downloadFileBytes(connection.getAccessToken(), baseResume.getGoogleFileId()); + } else { + content = exportDocument(connection.getAccessToken(), baseResume.getGoogleFileId(), exportMimeType); + } String fileName = buildDownloadFileName(baseResume.getDocumentName(), extension); return new DownloadedFile(fileName, exportMimeType, content); @@ -168,4 +177,4 @@ private String firstNonBlank(String... values) { } public record DownloadedFile(String fileName, String contentType, byte[] content) {} -} \ No newline at end of file +} diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java index cad4d280..17f29928 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -87,10 +87,13 @@ public GoogleDriveBaseResumeResponse addBaseResume(GoogleDriveBaseResumeRequest 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"); + if (!GoogleDriveApiClient.GOOGLE_DOC_MIME_TYPE.equals(file.mimeType()) && + !GoogleDriveApiClient.PDF_MIME_TYPE.equals(file.mimeType())) { + throw new BadRequestException("Only Google Docs documents and PDF files are supported as base resumes"); } + boolean isReadOnly = GoogleDriveApiClient.PDF_MIME_TYPE.equals(file.mimeType()); + GoogleDriveBaseResume resume = baseResumeRepository .findByConnectionIdAndGoogleFileId(connection.getId(), file.id()) .orElseGet(GoogleDriveBaseResume::new); @@ -100,7 +103,8 @@ public GoogleDriveBaseResumeResponse addBaseResume(GoogleDriveBaseResumeRequest resume.setDocumentName(file.name()); resume.setWebViewLink(resolveDocumentLink(file)); resume.setLanguage(request.language()); - resume.setTemplate(request.template()); + resume.setTemplate(isReadOnly ? false : request.template()); + resume.setReadOnly(isReadOnly); try { GoogleDriveBaseResume saved = baseResumeRepository.save(resume); return toBaseResumeResponse(saved); @@ -143,6 +147,10 @@ public GoogleDriveResumeCopyResponse copyBaseResumeToApplication(UUID applicatio GoogleDriveBaseResume baseResume = baseResumeRepository.findByIdAndConnectionUserId(request.baseResumeId(), userId) .orElseThrow(() -> new ResourceNotFoundException("Base resume not found with id: " + request.baseResumeId())); + if (baseResume.isReadOnly()) { + throw new BadRequestException("Cannot copy a read-only PDF resume to an application. Use a Google Docs template instead."); + } + GoogleDriveApiClient.DriveFileMetadata rootFolder = googleDriveApiClient.getFileMetadata(connection.getAccessToken(), connection.getRootFolderId()); if (!GoogleDriveApiClient.GOOGLE_FOLDER_MIME_TYPE.equals(rootFolder.mimeType())) { @@ -275,6 +283,7 @@ private GoogleDriveBaseResumeResponse toBaseResumeResponse(GoogleDriveBaseResume resume.getGoogleFileId(), resume.getDocumentName(), resume.getWebViewLink(), + resume.isReadOnly(), resume.getCreatedAt() ); } @@ -285,6 +294,7 @@ private BaseResumeResponse toBaseResumeListingResponse(GoogleDriveBaseResume res resume.getDocumentName(), resume.getLanguage(), resume.isTemplate(), + resume.isReadOnly(), resume.getCreatedAt() ); } diff --git a/src/main/java/com/jobtracker/service/ResumeGenerationService.java b/src/main/java/com/jobtracker/service/ResumeGenerationService.java index a11b940e..c76bacab 100644 --- a/src/main/java/com/jobtracker/service/ResumeGenerationService.java +++ b/src/main/java/com/jobtracker/service/ResumeGenerationService.java @@ -14,10 +14,14 @@ import com.jobtracker.repository.GoogleDriveBaseResumeRepository; import com.jobtracker.repository.GoogleDriveConnectionRepository; import com.jobtracker.util.SecurityUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.io.IOException; import java.time.LocalDateTime; import java.util.LinkedHashSet; import java.util.List; @@ -57,6 +61,11 @@ public ResumePlaceholderDetectionResponse detectPlaceholders(UUID baseResumeId) UUID userId = securityUtils.getCurrentUserId(); GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); GoogleDriveBaseResume baseResume = getBaseResume(baseResumeId, userId); + + if (baseResume.isReadOnly()) { + throw new BadRequestException("Cannot detect placeholders in a read-only PDF resume"); + } + String documentText = googleDriveApiClient.readGoogleDocText(connection.getAccessToken(), baseResume.getGoogleFileId()); return new ResumePlaceholderDetectionResponse( @@ -70,13 +79,22 @@ public BaseResumeContentResponse getBaseResumeContent(UUID resumeId) { UUID userId = securityUtils.getCurrentUserId(); GoogleDriveConnection connection = getConnectionWithFreshAccessToken(); GoogleDriveBaseResume baseResume = getBaseResume(resumeId, userId); - String content = googleDriveApiClient.readGoogleDocText(connection.getAccessToken(), baseResume.getGoogleFileId()); + + String content; + if (baseResume.isReadOnly()) { + byte[] pdfBytes = googleDriveApiClient.downloadFileBytes(connection.getAccessToken(), baseResume.getGoogleFileId()); + content = extractTextFromPdf(pdfBytes); + } else { + content = googleDriveApiClient.readGoogleDocText(connection.getAccessToken(), baseResume.getGoogleFileId()); + } + connectionRepository.save(connection); return new BaseResumeContentResponse( baseResume.getId(), baseResume.getDocumentName(), baseResume.getLanguage(), baseResume.isTemplate(), + baseResume.isReadOnly(), content ); } @@ -110,6 +128,10 @@ public ResumePlaceholderResponse generateTemplateResume(UUID applicationId, Resu JobApplication application = applicationRepository.findByIdAndUserId(applicationId, userId).orElseThrow(() -> new ResourceNotFoundException("Application not found with id: " + applicationId)); GoogleDriveBaseResume baseResume = getBaseResume(request.baseResumeId(), userId); + if (baseResume.isReadOnly()) { + throw new BadRequestException("Cannot generate a resume from a read-only PDF resume. Use a Google Docs template instead."); + } + if (!StringUtils.hasText(connection.getRootFolderId())) { throw new BadRequestException("Configure a Google Drive root folder before generating resumes"); } @@ -183,6 +205,15 @@ public List detectPlaceholders(String text) { return List.copyOf(placeholders); } + private String extractTextFromPdf(byte[] pdfBytes) { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + PDFTextStripper stripper = new PDFTextStripper(); + return stripper.getText(document); + } catch (IOException ex) { + throw new BadRequestException("Failed to extract text from PDF resume: " + ex.getMessage()); + } + } + private GoogleDriveBaseResume getBaseResume(UUID baseResumeId, UUID userId) { return baseResumeRepository.findByIdAndConnectionUserId(baseResumeId, userId) .orElseThrow(() -> new ResourceNotFoundException("Base resume not found with id: " + baseResumeId)); diff --git a/src/main/java/com/jobtracker/service/RetryingGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/RetryingGoogleDriveApiClient.java index 3c43dbc7..b88c692d 100644 --- a/src/main/java/com/jobtracker/service/RetryingGoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/RetryingGoogleDriveApiClient.java @@ -104,6 +104,11 @@ public DriveFileMetadata exportGoogleDocAsPdf(String accessToken, String documen return delegate.exportGoogleDocAsPdf(accessToken, documentId, targetFolderId, pdfName); } + @Override + public byte[] downloadFileBytes(String accessToken, String fileId) { + return delegate.downloadFileBytes(accessToken, fileId); + } + @Transactional private GoogleDriveConnection refreshCurrentUserConnection() { if (!googleDriveProperties.isConfigured()) { diff --git a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java index 00f1869a..1d5641a7 100644 --- a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java @@ -283,6 +283,17 @@ public DriveFileMetadata exportGoogleDocAsPdf(String accessToken, String documen }); } + @Override + public byte[] downloadFileBytes(String accessToken, String fileId) { + return executeDriveOp(accessToken, "download file", drive -> { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + drive.files().get(fileId) + .setSupportsAllDrives(true) + .executeMediaAndDownloadTo(outputStream); + return outputStream.toByteArray(); + }); + } + // ── internal helpers ───────────────────────────────────────────────────── @FunctionalInterface diff --git a/src/main/resources/db/migration/V27__add_read_only_to_base_resumes.sql b/src/main/resources/db/migration/V27__add_read_only_to_base_resumes.sql new file mode 100644 index 00000000..6c2ffe23 --- /dev/null +++ b/src/main/resources/db/migration/V27__add_read_only_to_base_resumes.sql @@ -0,0 +1,2 @@ +ALTER TABLE google_drive_base_resumes + ADD COLUMN read_only TINYINT(1) NOT NULL DEFAULT 0; diff --git a/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java b/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java index b40dfcc3..7c2871a1 100644 --- a/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java +++ b/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java @@ -8,6 +8,8 @@ import com.jobtracker.dto.application.UpdateStatusRequest; import com.jobtracker.mcp.tools.McpApplicationTools; import com.jobtracker.service.ApplicationService; +import com.jobtracker.service.ToolMetricsCollector; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -19,10 +21,12 @@ import java.time.LocalDateTime; import java.util.List; import java.util.UUID; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,9 +36,18 @@ class McpApplicationToolsTest { @Mock private ApplicationService applicationService; + @Mock + private ToolMetricsCollector metricsCollector; + @InjectMocks private McpApplicationTools tools; + @BeforeEach + void setUp() { + lenient().doAnswer(inv -> inv.>getArgument(2).get()) + .when(metricsCollector).measure(any(), any(), any()); + } + @Test void listApplications_allNullParams_usesDefaults() { ApplicationPageResponse expected = new ApplicationPageResponse(List.of(), 0, 20, 0, 0);