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);