Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ public ResponseEntity<GoogleDriveResumeCopyResponse> copyBaseResume(
@Operation(summary = "Detect placeholders in a configured base resume")
@PreAuthorize("hasRole('BETA')")
@PostMapping("/resume-placeholders")
public ResponseEntity<ResumePlaceholderResponse> detectResumePlaceholders(
@Valid @RequestBody ResumePlaceholderRequest request) {
public ResponseEntity<ResumePlaceholderDetectionResponse> detectResumePlaceholders(
@Valid @RequestBody ResumePlaceholderDetectionRequest request) {
return ResponseEntity.ok(resumeGenerationService.detectPlaceholders(request));
}

Expand All @@ -124,6 +124,6 @@ public ResponseEntity<ResumePlaceholderResponse> generateResume(
@PathVariable UUID applicationId,
@Valid @RequestBody ResumePlaceholderRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(resumeGenerationService.generateResume(applicationId, request));
.body(resumeGenerationService.generateTemplateResume(applicationId, request));
}
}
Original file line number Diff line number Diff line change
@@ -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 for resume placeholder detection")
public record ResumePlaceholderDetectionRequest(
@Schema(description = "Configured base resume identifier")
@NotNull(message = "baseResumeId is required")
UUID baseResumeId
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.jobtracker.dto.gdrive;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;
import java.util.UUID;

@Schema(description = "Resume template placeholder detection result")
public record ResumePlaceholderDetectionResponse(
UUID baseResumeId,
List<String> placeholders
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,15 @@
import java.util.Map;
import java.util.UUID;

@Schema(description = "Resume placeholder detection or generation result")
@Schema(description = "Generated resume from template placeholders")
public record ResumePlaceholderResponse(
UUID applicationId,
UUID baseResumeId,
List<String> placeholders,
Map<String, String> values,
String copiedFileId,
String copiedFileName,
String documentWebViewLink,
String pdfFileId,
String pdfFileName,
String pdfWebViewLink,
String vacancyFolderId,
String vacancyFolderName,
String vacancyFolderWebViewLink,
String documentUrl,
String pdfUrl,
Map<String, String> values,
List<String> placeholders,
LocalDateTime generatedAt
) {}
38 changes: 9 additions & 29 deletions src/main/java/com/jobtracker/service/ResumeGenerationService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.jobtracker.service;

import com.jobtracker.config.GoogleDriveProperties;
import com.jobtracker.dto.gdrive.ResumePlaceholderDetectionRequest;
import com.jobtracker.dto.gdrive.ResumePlaceholderDetectionResponse;
import com.jobtracker.dto.gdrive.ResumePlaceholderRequest;
import com.jobtracker.dto.gdrive.ResumePlaceholderResponse;
import com.jobtracker.entity.GoogleDriveBaseResume;
Expand Down Expand Up @@ -51,32 +53,20 @@ public ResumeGenerationService(GoogleDriveApiClient googleDriveApiClient,
}

@Transactional
public ResumePlaceholderResponse detectPlaceholders(ResumePlaceholderRequest request) {
public ResumePlaceholderDetectionResponse detectPlaceholders(ResumePlaceholderDetectionRequest request) {
UUID userId = securityUtils.getCurrentUserId();
GoogleDriveConnection connection = getConnectionWithFreshAccessToken();
GoogleDriveBaseResume baseResume = getBaseResume(request.baseResumeId(), userId);
String documentText = googleDriveApiClient.readGoogleDocText(connection.getAccessToken(), baseResume.getGoogleFileId());

return new ResumePlaceholderResponse(
null,
return new ResumePlaceholderDetectionResponse(
baseResume.getId(),
detectPlaceholders(documentText),
Map.of(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
detectPlaceholders(documentText)
);
}

@Transactional
public ResumePlaceholderResponse generateResume(UUID applicationId, ResumePlaceholderRequest request) {
public ResumePlaceholderResponse generateTemplateResume(UUID applicationId, ResumePlaceholderRequest request) {
UUID userId = securityUtils.getCurrentUserId();
GoogleDriveConnection connection = getConnectionWithFreshAccessToken();
JobApplication application = applicationRepository.findByIdAndUserId(applicationId, userId)
Expand Down Expand Up @@ -131,17 +121,12 @@ public ResumePlaceholderResponse generateResume(UUID applicationId, ResumePlaceh
return new ResumePlaceholderResponse(
application.getId(),
baseResume.getId(),
remainingPlaceholders,
values,
copiedFile.id(),
copiedFile.name(),
copiedDocumentUrl,
pdfFile.id(),
pdfFile.name(),
copiedDocumentUrl,
resolveDocumentLink(pdfFile),
vacancyFolder.id(),
vacancyFolder.name(),
resolveFolderLink(vacancyFolder.id(), vacancyFolder.webViewLink()),
values,
remainingPlaceholders,
generatedAt
);
}
Expand Down Expand Up @@ -260,9 +245,4 @@ private String resolveDocumentLink(GoogleDriveApiClient.DriveFileMetadata file)
: "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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,16 @@ public void replaceGoogleDocPlaceholders(String accessToken, String documentId,
}

List<Request> requests = values.entrySet().stream()
.filter(entry -> entry.getKey() != null && !entry.getKey().isBlank())
.map(entry -> new Request().setReplaceAllText(new ReplaceAllTextRequest()
.setContainsText(new SubstringMatchCriteria()
.setText("{{" + entry.getKey() + "}}")
.setText(toPlaceholderToken(entry.getKey()))
.setMatchCase(true))
.setReplaceText(entry.getValue() == null ? "" : entry.getValue())))
.toList();
if (requests.isEmpty()) {
return;
}

executeDocsOp(accessToken, "replace placeholders", docs -> {
docs.documents().batchUpdate(documentId, new BatchUpdateDocumentRequest().setRequests(requests)).execute();
Expand Down Expand Up @@ -355,4 +359,8 @@ private DriveFileMetadata toDriveFileMetadata(File file) {
file.getMimeType(),
file.getWebViewLink());
}

private String toPlaceholderToken(String key) {
return "{{" + key.trim() + "}}";
}
}
144 changes: 143 additions & 1 deletion src/test/java/com/jobtracker/integration/GoogleDriveControllerIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class GoogleDriveControllerIT extends AbstractIntegrationTest {

@BeforeEach
void setUp() throws Exception {
googleDriveApiClient.reset();
googleDriveOAuthStateRepository.deleteAll();
googleDriveBaseResumeRepository.deleteAll();
googleDriveConnectionRepository.deleteAll();
Expand Down Expand Up @@ -331,6 +332,116 @@ void copyResume_shouldReturn400WhenNoRootFolderConfigured() throws Exception {
.andExpect(status().isBadRequest());
}

@Test
void detectResumePlaceholders_shouldReturnTemplatePlaceholders() throws Exception {
GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnection());
GoogleDriveBaseResume resume = buildBaseResume(connection);
googleDriveBaseResumeRepository.save(resume);

mockMvc.perform(post("/api/v1/google-drive/resume-placeholders")
.header("Authorization", "Bearer " + betaAccessToken)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"baseResumeId\":\"" + resume.getId() + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.baseResumeId").value(resume.getId().toString()))
.andExpect(jsonPath("$.applicationId").doesNotExist())
.andExpect(jsonPath("$.placeholders[0]").value("SUMMARY"))
.andExpect(jsonPath("$.placeholders[1]").value("SKILLS"));
}

@Test
void generateResume_shouldReplaceTemplatePlaceholdersAndReturnGeneratedResume() 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() + "/generated-resumes")
.header("Authorization", "Bearer " + betaAccessToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"baseResumeId": "%s",
"values": {
"SUMMARY": "Senior Java Engineer",
"SKILLS": "Spring Boot, PostgreSQL"
}
}
""".formatted(resume.getId())))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.applicationId").value(application.getId().toString()))
.andExpect(jsonPath("$.baseResumeId").value(resume.getId().toString()))
.andExpect(jsonPath("$.values.SUMMARY").value("Senior Java Engineer"))
.andExpect(jsonPath("$.values.SKILLS").value("Spring Boot, PostgreSQL"))
.andExpect(jsonPath("$.placeholders").isEmpty())
.andExpect(jsonPath("$.copiedFileId").value("copied-file"))
.andExpect(jsonPath("$.pdfFileId").value("pdf-file"))
.andExpect(jsonPath("$.documentUrl").value("https://docs.google.com/document/d/copied-file/edit"))
.andExpect(jsonPath("$.pdfUrl").value("https://docs.google.com/document/d/pdf-file/edit"))
.andExpect(jsonPath("$.generatedAt").isNotEmpty());

JobApplication savedApplication = applicationRepository.findById(application.getId()).orElseThrow();
assertThat(savedApplication.getDriveResumeFileId()).isEqualTo("copied-file");
assertThat(savedApplication.getDriveResumeDocumentUrl()).contains("copied-file");
}

@Test
void generateResume_shouldKeepUnresolvedPlaceholdersInResponse() throws Exception {
GoogleDriveConnection connection = googleDriveConnectionRepository.save(buildConnectionWithRootFolder());
GoogleDriveBaseResume resume = buildBaseResume(connection);
googleDriveBaseResumeRepository.save(resume);
googleDriveApiClient.setDocumentText("resume-file-id", "{{SUMMARY}}\n{{SKILLS}}\n{{UNKNOWN}}");

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() + "/generated-resumes")
.header("Authorization", "Bearer " + betaAccessToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"baseResumeId": "%s",
"values": {
"SUMMARY": "Senior Java Engineer",
"SKILLS": "Spring Boot, PostgreSQL"
}
}
""".formatted(resume.getId())))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.values.SUMMARY").value("Senior Java Engineer"))
.andExpect(jsonPath("$.values.SKILLS").value("Spring Boot, PostgreSQL"))
.andExpect(jsonPath("$.placeholders[0]").value("UNKNOWN"));

assertThat(googleDriveApiClient.readGoogleDocText(connection.getAccessToken(), "copied-file"))
.contains("{{UNKNOWN}}");
}

private GoogleDriveConnection buildConnectionWithRootFolder() {
GoogleDriveConnection connection = buildConnection();
connection.setRootFolderId("root-folder-id");
Expand Down Expand Up @@ -371,6 +482,8 @@ FakeGoogleDriveApiClient googleDriveApiClient() {
}

static class FakeGoogleDriveApiClient implements GoogleDriveApiClient {
private static final String DEFAULT_TEMPLATE_TEXT = "{{SUMMARY}}\n{{SKILLS}}";

private OAuthTokens tokens = new OAuthTokens(
"drive-access",
"drive-refresh",
Expand All @@ -380,6 +493,18 @@ static class FakeGoogleDriveApiClient implements GoogleDriveApiClient {
private GoogleDriveAccountProfile accountProfile =
new GoogleDriveAccountProfile("perm-123", "connected@example.com", "Drive User");
private final Map<String, DriveFileMetadata> fileMetadataById = new HashMap<>();
private final Map<String, String> documentTextById = new HashMap<>();

void reset() {
fileMetadataById.clear();
documentTextById.clear();
documentTextById.put("resume-file-id", DEFAULT_TEMPLATE_TEXT);
documentTextById.put("copied-file", DEFAULT_TEMPLATE_TEXT);
}

void setDocumentText(String documentId, String text) {
documentTextById.put(documentId, text);
}

@Override
public String buildAuthorizationUrl(String state) {
Expand Down Expand Up @@ -418,16 +543,33 @@ public DriveFileMetadata createFolder(String accessToken, String parentFolderId,

@Override
public DriveFileMetadata copyGoogleDoc(String accessToken, String sourceFileId, String targetFolderId, String newName) {
String sourceText = documentTextById.getOrDefault(sourceFileId, DEFAULT_TEMPLATE_TEXT);
documentTextById.put("copied-file", sourceText);
return new DriveFileMetadata("copied-file", newName, GOOGLE_DOC_MIME_TYPE, null);
}

@Override
public String readGoogleDocText(String accessToken, String documentId) {
return "{{SUMMARY}}\n{{SKILLS}}";
return documentTextById.getOrDefault(documentId, DEFAULT_TEMPLATE_TEXT);
}

@Override
public void replaceGoogleDocPlaceholders(String accessToken, String documentId, Map<String, String> values) {
if (values == null || values.isEmpty()) {
return;
}
String currentText = documentTextById.getOrDefault(documentId, DEFAULT_TEMPLATE_TEXT);
String updatedText = currentText;
for (Map.Entry<String, String> entry : values.entrySet()) {
String replacement = entry.getValue() == null ? "" : entry.getValue();
String token = entry.getKey();
if (token == null || token.isBlank()) {
continue;
}
String placeholderToken = "{{" + token.trim() + "}}";
updatedText = updatedText.replace(placeholderToken, replacement);
}
documentTextById.put(documentId, updatedText);
}

@Override
Expand Down
Loading