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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,13 @@
<version>1.25.0</version>
</dependency>

<!-- Apache PDFBox — PDF text extraction for read-only PDF base resumes -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.4</version>
</dependency>

<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down Expand Up @@ -299,4 +306,4 @@
</plugin>
</plugins>
</build>
</project>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public record GoogleDriveBaseResumeResponse(
String googleFileId,
String documentName,
String webViewLink,
boolean readOnly,
LocalDateTime createdAt
) {}
11 changes: 11 additions & 0 deletions src/main/java/com/jobtracker/entity/GoogleDriveBaseResume.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand All @@ -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.""",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -168,4 +177,4 @@ private String firstNonBlank(String... values) {
}

public record DownloadedFile(String fileName, String contentType, byte[] content) {}
}
}
16 changes: 13 additions & 3 deletions src/main/java/com/jobtracker/service/GoogleDriveService.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,13 @@
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);
Expand All @@ -100,7 +103,8 @@
resume.setDocumentName(file.name());
resume.setWebViewLink(resolveDocumentLink(file));
resume.setLanguage(request.language());
resume.setTemplate(request.template());
resume.setTemplate(isReadOnly ? false : request.template());

Check warning on line 106 in src/main/java/com/jobtracker/service/GoogleDriveService.java

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Simplifiable conditional expression

`isReadOnly ? false : request.template()` can be simplified to '!isReadOnly \&\& request.template()'
resume.setReadOnly(isReadOnly);
try {
GoogleDriveBaseResume saved = baseResumeRepository.save(resume);
return toBaseResumeResponse(saved);
Expand Down Expand Up @@ -143,6 +147,10 @@
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())) {
Expand Down Expand Up @@ -275,6 +283,7 @@
resume.getGoogleFileId(),
resume.getDocumentName(),
resume.getWebViewLink(),
resume.isReadOnly(),
resume.getCreatedAt()
);
}
Expand All @@ -285,6 +294,7 @@
resume.getDocumentName(),
resume.getLanguage(),
resume.isTemplate(),
resume.isReadOnly(),
resume.getCreatedAt()
);
}
Expand Down Expand Up @@ -320,7 +330,7 @@
private String buildVacancyFolderName(JobApplication application) {
String suffix = " - APP-" + application.getId().toString();
String rawBase = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application");
String truncatedBase = truncateFileName(sanitizeFileName(rawBase), 180 - suffix.length());

Check warning on line 333 in src/main/java/com/jobtracker/service/GoogleDriveService.java

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Nullability and data flow problems

Argument `rawBase` might be null
return truncatedBase + suffix;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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
);
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -183,6 +205,15 @@ public List<String> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@
return executeDocsOp(accessToken, "read document", docs -> {
com.google.api.services.docs.v1.model.Document document = docs.documents().get(documentId).execute();

StringBuilder text = new StringBuilder();

Check warning on line 225 in src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Mismatched query and update of 'StringBuilder'

Contents of `StringBuilder text` are updated, but never queried
if (document.getBody() == null || document.getBody().getContent() == null) {
return "";
}
Expand Down Expand Up @@ -283,6 +283,17 @@
});
}

@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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE google_drive_base_resumes
ADD COLUMN read_only TINYINT(1) NOT NULL DEFAULT 0;
13 changes: 13 additions & 0 deletions src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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.<Supplier<?>>getArgument(2).get())
.when(metricsCollector).measure(any(), any(), any());
}

@Test
void listApplications_allNullParams_usesDefaults() {
ApplicationPageResponse expected = new ApplicationPageResponse(List.of(), 0, 20, 0, 0);
Expand Down
Loading