From 7b32dc22eb39d351ee773e8b06181195667b1a33 Mon Sep 17 00:00:00 2001 From: vitorhugo-java Date: Mon, 4 May 2026 22:30:20 -0300 Subject: [PATCH 1/6] feat: add google drive resume workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/api/googleDrive.js | 106 +++++ src/pages/account/AccountSettings.jsx | 489 ++++++++++++++++++++- src/pages/applications/ApplicationForm.jsx | 347 ++++++++++++--- src/utils/externalLinks.js | 40 ++ src/utils/googleDrive.js | 39 ++ 5 files changed, 952 insertions(+), 69 deletions(-) create mode 100644 src/api/googleDrive.js create mode 100644 src/utils/externalLinks.js create mode 100644 src/utils/googleDrive.js diff --git a/src/api/googleDrive.js b/src/api/googleDrive.js new file mode 100644 index 0000000..e6fcd10 --- /dev/null +++ b/src/api/googleDrive.js @@ -0,0 +1,106 @@ +import api from './axios' +import { buildGoogleDocUrl, buildGoogleDriveFolderUrl } from '../utils/googleDrive' + +const normalizeBaseResume = (resume, index) => { + const documentId = resume?.documentId ?? resume?.googleDocId ?? resume?.googleFileId ?? '' + + return { + id: resume?.id ?? `resume-${index + 1}`, + name: resume?.name ?? resume?.label ?? resume?.documentName ?? `Resume ${index + 1}`, + documentId, + documentUrl: resume?.documentUrl ?? resume?.googleDocUrl ?? resume?.webViewLink ?? buildGoogleDocUrl(documentId), + isDefault: Boolean(resume?.isDefault), + } +} + +export const normalizeGoogleDriveSettings = (data = {}) => { + const baseFolderId = data.baseFolderId ?? data.baseDriveFolderId ?? data.folderId ?? data.rootFolderId ?? '' + const connection = data.connection ?? {} + + return { + connected: Boolean( + data.connected ?? + data.isConnected ?? + connection.connected ?? + connection.isConnected + ), + accountEmail: data.accountEmail ?? data.googleEmail ?? connection.accountEmail ?? connection.email ?? '', + accountDisplayName: data.accountDisplayName ?? data.googleDisplayName ?? connection.accountDisplayName ?? connection.displayName ?? '', + baseFolderId, + baseFolderUrl: data.baseFolderUrl ?? data.folderUrl ?? buildGoogleDriveFolderUrl(baseFolderId), + baseResumes: (data.baseResumes ?? data.resumes ?? []).map(normalizeBaseResume), + } +} + +export const getGoogleDriveSettings = async () => { + const response = await api.get('/google-drive/status') + return { + ...response, + data: normalizeGoogleDriveSettings(response.data), + } +} + +export const updateGoogleDriveSettings = async (payload) => { + await api.put('/google-drive/root-folder', { + folderIdOrUrl: payload.baseFolderId ?? payload.baseFolderInput ?? '', + }) + + const currentStatusResponse = await api.get('/google-drive/status') + const currentSettings = normalizeGoogleDriveSettings(currentStatusResponse.data) + const desiredResumes = payload.baseResumes ?? [] + + const existingByDocumentId = new Map( + currentSettings.baseResumes.map((resume) => [resume.documentId, resume]) + ) + const desiredDocumentIds = new Set(desiredResumes.map((resume) => resume.documentId)) + + await Promise.all( + currentSettings.baseResumes + .filter((resume) => !desiredDocumentIds.has(resume.documentId)) + .map((resume) => api.delete(`/google-drive/base-resumes/${resume.id}`)) + ) + + for (const resume of desiredResumes) { + if (!existingByDocumentId.has(resume.documentId)) { + await api.post('/google-drive/base-resumes', { + documentIdOrUrl: resume.documentId, + }) + } + } + + const response = await api.get('/google-drive/status') + return { + ...response, + data: normalizeGoogleDriveSettings(response.data), + } +} + +export const startGoogleDriveConnection = async () => { + const response = await api.post('/google-drive/oauth/start', {}) + return { + ...response, + data: { + ...response.data, + authorizationUrl: response.data?.authorizationUrl ?? response.data?.url ?? '', + }, + } +} + +export const disconnectGoogleDriveConnection = () => + api.delete('/google-drive/connection') + +export const createGoogleDriveResume = async (payload) => { + const response = await api.post( + `/google-drive/applications/${payload.applicationId}/resume-copies`, + { + baseResumeId: payload.baseResumeId, + } + ) + return { + ...response, + data: { + ...response.data, + googleDocUrl: response.data?.googleDocUrl ?? response.data?.documentWebViewLink ?? response.data?.documentUrl ?? response.data?.url ?? '', + }, + } +} diff --git a/src/pages/account/AccountSettings.jsx b/src/pages/account/AccountSettings.jsx index 1dc1705..258ebe7 100644 --- a/src/pages/account/AccountSettings.jsx +++ b/src/pages/account/AccountSettings.jsx @@ -1,15 +1,77 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { InputText } from 'primereact/inputtext' import { Password } from 'primereact/password' import { Button } from 'primereact/button' import { Toast } from 'primereact/toast' +import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog' import useAuthStore from '../../store/authStore' import { changePassword as changePasswordApi, sendTestEmail as sendTestEmailApi, updateProfile as updateProfileApi, } from '../../api/auth' +import { + disconnectGoogleDriveConnection, + getGoogleDriveSettings, + startGoogleDriveConnection, + updateGoogleDriveSettings, +} from '../../api/googleDrive' import { usePageTitle } from '../../hooks/usePageTitle' +import { + buildGoogleDocUrl, + buildGoogleDriveFolderUrl, + extractGoogleDocId, + extractGoogleDriveFolderId, +} from '../../utils/googleDrive' +import { openExternalUrl } from '../../utils/externalLinks' + +const createResumeRowId = () => + `resume-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + +const createResumeRow = (resume = {}) => ({ + clientId: createResumeRowId(), + id: resume.id ?? null, + name: resume.name ?? '', + documentInput: resume.documentUrl ?? buildGoogleDocUrl(resume.documentId), + isDefault: Boolean(resume.isDefault), +}) + +const createEmptyGoogleDriveForm = () => ({ + connected: false, + accountEmail: '', + accountDisplayName: '', + baseFolderInput: '', + resumes: [createResumeRow({ isDefault: true })], +}) + +const mapGoogleDriveSettingsToForm = (settings = {}) => { + const resumes = (settings.baseResumes ?? []).map(createResumeRow) + + if (!resumes.length) { + resumes.push(createResumeRow({ isDefault: true })) + } else if (!resumes.some((resume) => resume.isDefault)) { + resumes[0].isDefault = true + } + + return { + connected: Boolean(settings.connected), + accountEmail: settings.accountEmail ?? '', + accountDisplayName: settings.accountDisplayName ?? '', + baseFolderInput: settings.baseFolderUrl ?? buildGoogleDriveFolderUrl(settings.baseFolderId), + resumes, + } +} + +const serializeGoogleDriveForm = (form) => + JSON.stringify({ + baseFolderInput: form.baseFolderInput.trim(), + resumes: form.resumes.map((resume) => ({ + id: resume.id ?? null, + name: resume.name.trim(), + documentInput: resume.documentInput.trim(), + isDefault: Boolean(resume.isDefault), + })), + }) const AccountSettings = () => { usePageTitle('Configurações') @@ -29,28 +91,71 @@ const AccountSettings = () => { confirmPassword: '', }) const [savingPassword, setSavingPassword] = useState(false) - - // Track initial values for dirty form detection - const initialValuesRef = useRef({ + const [initialProfile, setInitialProfile] = useState({ name: user?.name || '', reminderTime: (user?.reminderTime || '19:00:00').slice(0, 5), - passwordForm: { - currentPassword: '', - newPassword: '', - confirmPassword: '', - }, }) - // Check if profile form is dirty - const isProfileDirty = name !== initialValuesRef.current.name || reminderTime !== initialValuesRef.current.reminderTime + const [googleDriveForm, setGoogleDriveForm] = useState(() => createEmptyGoogleDriveForm()) + const [loadingGoogleDrive, setLoadingGoogleDrive] = useState(true) + const [savingGoogleDrive, setSavingGoogleDrive] = useState(false) + const [connectingGoogleDrive, setConnectingGoogleDrive] = useState(false) + const [disconnectingGoogleDrive, setDisconnectingGoogleDrive] = useState(false) + const [initialGoogleDriveSnapshot, setInitialGoogleDriveSnapshot] = useState( + serializeGoogleDriveForm(createEmptyGoogleDriveForm()) + ) - // Check if password form has any input + const isProfileDirty = name !== initialProfile.name || reminderTime !== initialProfile.reminderTime const isPasswordDirty = passwordForm.currentPassword !== '' || passwordForm.newPassword !== '' || passwordForm.confirmPassword !== '' + const isGoogleDriveDirty = serializeGoogleDriveForm(googleDriveForm) !== initialGoogleDriveSnapshot + + const applyGoogleDriveSettings = useCallback((settings) => { + const nextForm = mapGoogleDriveSettingsToForm(settings) + setGoogleDriveForm(nextForm) + setInitialGoogleDriveSnapshot(serializeGoogleDriveForm(nextForm)) + }, []) + + const loadGoogleDriveSettings = useCallback(async (showSuccessMessage = false, showLoadingSpinner = true) => { + if (showLoadingSpinner) { + setLoadingGoogleDrive(true) + } + + try { + const response = await getGoogleDriveSettings() + applyGoogleDriveSettings(response.data) + + if (showSuccessMessage) { + toast.current?.show({ + severity: 'success', + summary: 'Success', + detail: 'Google Drive status refreshed.', + }) + } + } catch (err) { + applyGoogleDriveSettings(createEmptyGoogleDriveForm()) + + if (![404, 501].includes(err.response?.status)) { + const detail = err.response?.data?.message || 'Could not load your Google Drive settings right now.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } + } finally { + setLoadingGoogleDrive(false) + } + }, [applyGoogleDriveSettings]) + + useEffect(() => { + const loadTimer = window.setTimeout(() => { + loadGoogleDriveSettings(false, false).catch(() => null) + }, 0) + + return () => { + window.clearTimeout(loadTimer) + } + }, [loadGoogleDriveSettings]) - // Track form dirty state and warn on page leave useEffect(() => { - const isDirty = isProfileDirty || isPasswordDirty + const isDirty = isProfileDirty || isPasswordDirty || isGoogleDriveDirty const handleBeforeUnload = (e) => { if (isDirty) { @@ -64,7 +169,7 @@ const AccountSettings = () => { return () => { window.removeEventListener('beforeunload', handleBeforeUnload) } - }, [isProfileDirty, isPasswordDirty]) + }, [isProfileDirty, isPasswordDirty, isGoogleDriveDirty]) const handleProfileSubmit = async (e) => { e.preventDefault() @@ -82,6 +187,10 @@ const AccountSettings = () => { setUser(res.data) setName(res.data.name) setReminderTime((res.data.reminderTime || '19:00:00').slice(0, 5)) + setInitialProfile({ + name: res.data.name, + reminderTime: (res.data.reminderTime || '19:00:00').slice(0, 5), + }) toast.current.show({ severity: 'success', summary: 'Success', detail: 'Profile updated successfully.' }) } catch (err) { const detail = err.response?.data?.message || 'Could not update your profile. Please check your information and try again.' @@ -152,13 +261,212 @@ const AccountSettings = () => { } } + const setGoogleDriveField = (key, value) => { + setGoogleDriveForm((current) => ({ ...current, [key]: value })) + } + + const updateResumeRow = (clientId, key, value) => { + setGoogleDriveForm((current) => ({ + ...current, + resumes: current.resumes.map((resume) => + resume.clientId === clientId ? { ...resume, [key]: value } : resume + ), + })) + } + + const addResumeRow = () => { + setGoogleDriveForm((current) => ({ + ...current, + resumes: [...current.resumes, createResumeRow()], + })) + } + + const removeResumeRow = (clientId) => { + setGoogleDriveForm((current) => { + if (current.resumes.length === 1) { + return current + } + + const resumes = current.resumes.filter((resume) => resume.clientId !== clientId) + + if (!resumes.some((resume) => resume.isDefault) && resumes[0]) { + resumes[0] = { ...resumes[0], isDefault: true } + } + + return { + ...current, + resumes, + } + }) + } + + const setDefaultResume = (clientId) => { + setGoogleDriveForm((current) => ({ + ...current, + resumes: current.resumes.map((resume) => ({ + ...resume, + isDefault: resume.clientId === clientId, + })), + })) + } + + const handleConnectGoogleDrive = async () => { + setConnectingGoogleDrive(true) + + try { + const response = await startGoogleDriveConnection() + const authorizationUrl = response.data?.authorizationUrl + + if (!authorizationUrl) { + throw new Error('No authorization URL was returned.') + } + + openExternalUrl(authorizationUrl) + toast.current?.show({ + severity: 'info', + summary: 'Continue in Google', + detail: 'Finish the Google authorization flow in the new tab, then refresh the status here.', + }) + } catch (err) { + const detail = err.response?.data?.message || err.message || 'Could not start the Google Drive connection.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } finally { + setConnectingGoogleDrive(false) + } + } + + const handleDisconnectGoogleDrive = () => { + confirmDialog({ + message: 'Disconnect your Google Drive account from Job Tracker?', + header: 'Disconnect Google Drive', + icon: 'pi pi-exclamation-triangle', + acceptClassName: 'p-button-danger', + accept: async () => { + setDisconnectingGoogleDrive(true) + + try { + await disconnectGoogleDriveConnection() + setGoogleDriveForm((current) => ({ + ...current, + connected: false, + accountEmail: '', + accountDisplayName: '', + })) + toast.current?.show({ + severity: 'success', + summary: 'Success', + detail: 'Google Drive disconnected successfully.', + }) + await loadGoogleDriveSettings().catch(() => null) + } catch (err) { + const detail = err.response?.data?.message || 'Could not disconnect your Google Drive account.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } finally { + setDisconnectingGoogleDrive(false) + } + }, + }) + } + + const handleGoogleDriveSubmit = async (e) => { + e.preventDefault() + + const baseFolderInput = googleDriveForm.baseFolderInput.trim() + const baseFolderId = extractGoogleDriveFolderId(baseFolderInput) + + if (!baseFolderInput || !baseFolderId) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'Please provide a valid Google Drive folder URL or folder ID.', + }) + return + } + + const enteredResumes = googleDriveForm.resumes.filter( + (resume) => resume.name.trim() || resume.documentInput.trim() + ) + + if (!enteredResumes.length) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'Add at least one base resume before saving.', + }) + return + } + + const normalizedResumes = [] + + for (const resume of enteredResumes) { + const name = resume.name.trim() + const documentInput = resume.documentInput.trim() + const documentId = extractGoogleDocId(documentInput) + + if (!name || !documentInput) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'Each base resume needs a name and a Google Docs URL or document ID.', + }) + return + } + + if (!documentId) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'One of the base resumes has an invalid Google Docs URL or document ID.', + }) + return + } + + normalizedResumes.push({ + id: resume.id ?? undefined, + name, + documentId, + isDefault: resume.isDefault, + }) + } + + const defaultIndex = normalizedResumes.findIndex((resume) => resume.isDefault) + const selectedDefaultIndex = defaultIndex >= 0 ? defaultIndex : 0 + const payload = { + baseFolderId, + baseResumes: normalizedResumes.map((resume, index) => ({ + ...(resume.id ? { id: resume.id } : {}), + name: resume.name, + documentId: resume.documentId, + isDefault: index === selectedDefaultIndex, + })), + } + + setSavingGoogleDrive(true) + + try { + const response = await updateGoogleDriveSettings(payload) + applyGoogleDriveSettings(response.data) + toast.current?.show({ + severity: 'success', + summary: 'Success', + detail: 'Google Drive settings saved successfully.', + }) + } catch (err) { + const detail = err.response?.data?.message || 'Could not save your Google Drive settings.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } finally { + setSavingGoogleDrive(false) + } + } + return (
+

Account Settings

-

Manage your personal information and account password

+

Manage your personal information, Google Drive resumes and account password

@@ -205,6 +513,155 @@ const AccountSettings = () => {
+
+
+
+

Google Drive Resumes

+

+ Connect Google Drive, set your base folder and manage one or more base Google Docs resumes. +

+
+ +
+
+
+ +
+
+
+

Connection status

+

+ {loadingGoogleDrive + ? 'Checking your Google Drive connection...' + : googleDriveForm.connected + ? `${googleDriveForm.accountDisplayName || 'Google account'}${googleDriveForm.accountEmail ? ` • ${googleDriveForm.accountEmail}` : ''}` + : 'No Google Drive account connected yet.'} +

+
+ + {googleDriveForm.connected ? 'Connected' : 'Disconnected'} + +
+
+ +
+
+ + setGoogleDriveField('baseFolderInput', e.target.value)} + className="w-full" + placeholder="Paste a Google Drive folder URL or folder ID" + /> +

+ Resume copies will be created inside this Drive folder. +

+
+ +
+
+
+

Base resumes

+

+ Add one or more Google Docs templates and choose which one is the default. +

+
+
+ + {googleDriveForm.resumes.map((resume, index) => ( +
+
+
+ + updateResumeRow(resume.clientId, 'name', e.target.value)} + className="w-full" + placeholder={`Base Resume ${index + 1}`} + /> +
+ +
+ + updateResumeRow(resume.clientId, 'documentInput', e.target.value)} + className="w-full" + placeholder="https://docs.google.com/document/d/..." + /> +
+
+ +
+
+
+ ))} +
+ +
+

Change Password

diff --git a/src/pages/applications/ApplicationForm.jsx b/src/pages/applications/ApplicationForm.jsx index fd62a3b..3d07931 100644 --- a/src/pages/applications/ApplicationForm.jsx +++ b/src/pages/applications/ApplicationForm.jsx @@ -17,9 +17,21 @@ import { markDmSent, APPLICATION_STATUSES, } from '../../api/applications' +import { + createGoogleDriveResume, + getGoogleDriveSettings, +} from '../../api/googleDrive' import { GAMIFICATION_EVENT_TYPES } from '../../api/gamification' import { usePageTitle } from '../../hooks/usePageTitle' import useGamificationStore from '../../store/gamificationStore' +import { + GOOGLE_DRIVE_GEMINI_URL, +} from '../../utils/googleDrive' +import { + navigateOpenedTab, + openExternalUrl, + openPendingTab, +} from '../../utils/externalLinks' const defaultForm = { vacancyName: '', @@ -37,6 +49,12 @@ const defaultForm = { note: '', } +const defaultGoogleDriveSettings = { + connected: false, + baseFolderId: '', + baseResumes: [], +} + const getDraftKey = (id) => `jobtracker:application-form-draft:${id || 'new'}` const toStoragePayload = (form) => ({ @@ -79,6 +97,21 @@ const formatLocalDateTime = (date) => { return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}` } +const buildApplicationPayload = (form) => { + const payload = { + ...form, + vacancyName: form.vacancyName.trim() || null, + applicationDate: formatDateOnly(form.applicationDate), + nextStepDateTime: formatLocalDateTime(form.nextStepDateTime), + status: form.toSendLater ? null : form.status, + note: form.note?.trim() || null, + } + + delete payload.markDmSent + + return payload +} + const ApplicationForm = () => { const { id } = useParams() const navigate = useNavigate() @@ -98,6 +131,10 @@ const ApplicationForm = () => { const [loading, setLoading] = useState(false) const [fetching, setFetching] = useState(isEdit) const [draftReady, setDraftReady] = useState(false) + const [googleDriveSettings, setGoogleDriveSettings] = useState(defaultGoogleDriveSettings) + const [loadingGoogleDriveSettings, setLoadingGoogleDriveSettings] = useState(true) + const [creatingResume, setCreatingResume] = useState(false) + const [selectedBaseResumeId, setSelectedBaseResumeId] = useState('') const draftRef = useRef(null) const initialFormRef = useRef(null) const draftKey = getDraftKey(id) @@ -114,6 +151,54 @@ const ApplicationForm = () => { setDraftReady(true) }, [draftKey, isEdit]) + useEffect(() => { + let active = true + + const loadGoogleDriveSettings = async () => { + setLoadingGoogleDriveSettings(true) + + try { + const response = await getGoogleDriveSettings() + + if (!active) { + return + } + + const settings = response.data ?? defaultGoogleDriveSettings + const defaultResume = settings.baseResumes.find((resume) => resume.isDefault) ?? settings.baseResumes[0] + + setGoogleDriveSettings(settings) + setSelectedBaseResumeId((current) => ( + current && settings.baseResumes.some((resume) => resume.id === current) + ? current + : defaultResume?.id ?? '' + )) + } catch (err) { + if (!active) { + return + } + + setGoogleDriveSettings(defaultGoogleDriveSettings) + setSelectedBaseResumeId('') + + if (![404, 501].includes(err.response?.status)) { + const detail = err.response?.data?.message || 'Google Drive settings could not be loaded.' + toast.current?.show({ severity: 'warn', summary: 'Resume tools unavailable', detail }) + } + } finally { + if (active) { + setLoadingGoogleDriveSettings(false) + } + } + } + + loadGoogleDriveSettings().catch(() => null) + + return () => { + active = false + } + }, []) + useEffect(() => { if (!isEdit) return const fetchApp = async () => { @@ -147,10 +232,9 @@ const ApplicationForm = () => { fetchApp() }, [id, isEdit]) - // Set initial form state after all data is loaded or draft is restored useEffect(() => { - if (!draftReady) return // Wait for draft to be checked - if (isEdit && fetching) return // For edit forms, wait for server data + if (!draftReady) return + if (isEdit && fetching) return if (!initialFormRef.current) { initialFormRef.current = { ...form } } @@ -163,14 +247,12 @@ const ApplicationForm = () => { const setField = (key, val) => setForm((f) => ({ ...f, [key]: val })) - // Helper function to compare dates handling null values const areDatesEqual = (date1, date2) => { if (!date1 && !date2) return true if (!date1 || !date2) return false return date1.getTime() === date2.getTime() } - // Check if form is dirty (has unsaved changes) const isFormDirty = () => { if (!initialFormRef.current) return false @@ -203,20 +285,137 @@ const ApplicationForm = () => { window.localStorage.removeItem(draftKey) navigate(isEdit ? `/applications/${id}` : '/applications') }, - reject: () => { - // User cancelled, stay on form - }, }) } else { navigate(isEdit ? `/applications/${id}` : '/applications') } } + const handleOpenGemini = () => { + openExternalUrl(GOOGLE_DRIVE_GEMINI_URL) + } + + const recordCreatedApplicationEvents = async (applicationId, payload) => { + try { + await recordEvent(GAMIFICATION_EVENT_TYPES.APPLICATION_CREATED, { + applicationId, + }) + } catch (eventError) { + const detail = eventError.response?.data?.message || 'Application saved, but the XP event could not be recorded.' + toast.current.show({ severity: 'warn', summary: 'Gamification pending', detail }) + } + + if (payload.note) { + try { + await recordEvent(GAMIFICATION_EVENT_TYPES.NOTE_ADDED, { + applicationId, + }) + } catch (eventError) { + const detail = eventError.response?.data?.message || 'Application saved, but the XP event for note tracking could not be recorded.' + toast.current.show({ severity: 'warn', summary: 'Gamification pending', detail }) + } + } + + if (payload.interviewScheduled) { + try { + await recordEvent(GAMIFICATION_EVENT_TYPES.INTERVIEW_PROGRESS, { + applicationId, + }) + } catch (eventError) { + const detail = eventError.response?.data?.message || 'Application saved, but the XP event for interview progress could not be recorded.' + toast.current.show({ severity: 'warn', summary: 'Gamification pending', detail }) + } + } + } + + const handleCreateResume = async () => { + const selectedResume = googleDriveSettings.baseResumes.find((resume) => resume.id === selectedBaseResumeId) + ?? googleDriveSettings.baseResumes[0] + + if (!googleDriveSettings.connected) { + toast.current?.show({ + severity: 'warn', + summary: 'Resume tools unavailable', + detail: 'Connect your Google account in Account Settings before creating a resume.', + }) + return + } + + if (!googleDriveSettings.baseFolderId || !selectedResume?.id) { + toast.current?.show({ + severity: 'warn', + summary: 'Resume tools unavailable', + detail: 'Configure a base Drive folder and at least one base resume in Account Settings.', + }) + return + } + + if (!isEdit && !form.toSendLater && !form.applicationDate) { + toast.current?.show({ + severity: 'error', + summary: 'Validation', + detail: 'Application date is required unless "To send later" is enabled.', + }) + return + } + + const pendingTab = openPendingTab() + setCreatingResume(true) + + try { + let applicationId = id + let createdApplicationId = null + + if (!isEdit) { + const payload = buildApplicationPayload(form) + const createResponse = await createApplication(payload) + + if (createResponse.data?.queuedOffline || !createResponse.data?.id) { + throw new Error('The application must be saved online before creating a Google Docs resume.') + } + + createdApplicationId = createResponse.data.id + applicationId = createResponse.data.id + window.localStorage.removeItem(draftKey) + await recordCreatedApplicationEvents(applicationId, payload) + } + + const response = await createGoogleDriveResume({ + applicationId, + baseResumeId: selectedResume.id, + }) + const googleDocUrl = response.data?.googleDocUrl + + if (!googleDocUrl) { + throw new Error('No Google Docs URL was returned by the server.') + } + + navigateOpenedTab(pendingTab, googleDocUrl) + toast.current?.show({ + severity: 'success', + summary: 'Resume created', + detail: 'Your Google Docs resume was created and opened in a new tab.', + }) + + if (createdApplicationId) { + navigate(`/applications/${createdApplicationId}`) + } + } catch (err) { + if (pendingTab && !pendingTab.closed) { + pendingTab.close() + } + + const detail = err.response?.data?.message || err.message || 'Could not create the Google Docs resume.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } finally { + setCreatingResume(false) + } + } + const handleSubmit = async (e) => { e.preventDefault() setLoading(true) - // Validation: require applicationDate when not sending later if (!form.toSendLater && !form.applicationDate) { toast.current?.show({ severity: 'error', summary: 'Validation', detail: 'Application date is required unless "To send later" is enabled.' }) setLoading(false) @@ -225,22 +424,12 @@ const ApplicationForm = () => { try { const previousForm = initialFormRef.current - const payload = { - ...form, - vacancyName: form.vacancyName.trim() || null, - applicationDate: formatDateOnly(form.applicationDate), - nextStepDateTime: formatLocalDateTime(form.nextStepDateTime), - status: form.toSendLater ? null : form.status, - note: form.note?.trim() || null, - } - // Remove markDmSent from payload as it's not a backend field - delete payload.markDmSent - + const payload = buildApplicationPayload(form) + if (isEdit) { const response = await updateApplication(id, payload) window.localStorage.removeItem(draftKey) - - // If markDmSent is true, call the API + if (form.markDmSent) { try { await markDmSent(id) @@ -274,7 +463,7 @@ const ApplicationForm = () => { toast.current.show({ severity: 'warn', summary: 'Gamification pending', detail }) } } - + if (response.data?.queuedOffline) { toast.current.show({ severity: 'info', @@ -286,35 +475,8 @@ const ApplicationForm = () => { } else { const response = await createApplication(payload) window.localStorage.removeItem(draftKey) - try { - await recordEvent(GAMIFICATION_EVENT_TYPES.APPLICATION_CREATED, { - applicationId: response.data?.id, - }) - } catch (eventError) { - const detail = eventError.response?.data?.message || 'Application saved, but the XP event could not be recorded.' - toast.current.show({ severity: 'warn', summary: 'Gamification pending', detail }) - } - - if (payload.note) { - try { - await recordEvent(GAMIFICATION_EVENT_TYPES.NOTE_ADDED, { - applicationId: response.data?.id, - }) - } catch (eventError) { - const detail = eventError.response?.data?.message || 'Application saved, but the XP event for note tracking could not be recorded.' - toast.current.show({ severity: 'warn', summary: 'Gamification pending', detail }) - } - } - - if (payload.interviewScheduled) { - try { - await recordEvent(GAMIFICATION_EVENT_TYPES.INTERVIEW_PROGRESS, { - applicationId: response.data?.id, - }) - } catch (eventError) { - const detail = eventError.response?.data?.message || 'Application saved, but the XP event for interview progress could not be recorded.' - toast.current.show({ severity: 'warn', summary: 'Gamification pending', detail }) - } + if (response.data?.id) { + await recordCreatedApplicationEvents(response.data.id, payload) } if (response.data?.queuedOffline) { @@ -334,7 +496,17 @@ const ApplicationForm = () => { } } - const statusOptions = APPLICATION_STATUSES.map((s) => ({ label: s, value: s })) + const statusOptions = APPLICATION_STATUSES.map((status) => ({ label: status, value: status })) + const resumeTemplateOptions = googleDriveSettings.baseResumes.map((resume) => ({ + label: resume.name, + value: resume.id, + })) + const selectedBaseResume = googleDriveSettings.baseResumes.find((resume) => resume.id === selectedBaseResumeId) + ?? googleDriveSettings.baseResumes[0] + const hasResumeIntegration = + googleDriveSettings.connected && + Boolean(googleDriveSettings.baseFolderId) && + googleDriveSettings.baseResumes.length > 0 if (fetching) { return ( @@ -387,6 +559,75 @@ const ApplicationForm = () => {
+
+
+
+

Resume tools

+

+ Open Gemini or create a Google Docs resume copy from one of your configured base resumes. +

+
+
+
+
+ + {loadingGoogleDriveSettings ? ( +

Loading your Google Drive resume settings...

+ ) : hasResumeIntegration ? ( +
+ {googleDriveSettings.baseResumes.length > 1 ? ( +
+ + setSelectedBaseResumeId(e.value)} + className="w-full sm:max-w-sm" + /> +
+ ) : ( +

+ Using base resume: {selectedBaseResume?.name} +

+ )} +

+ Your resume copy will open in a new Google Docs tab after the backend finishes creating it. +

+
+ ) : ( +
+

+ Connect Google Drive and save at least one base resume in Account Settings to enable one-click resume creation. +

+
+ )} +
+
setField('applicationDate', e.value)} className="w-full" dateFormat="dd/mm/yy" /> diff --git a/src/utils/externalLinks.js b/src/utils/externalLinks.js new file mode 100644 index 0000000..b660df9 --- /dev/null +++ b/src/utils/externalLinks.js @@ -0,0 +1,40 @@ +export const openExternalUrl = (url) => { + if (typeof window === 'undefined' || !url) { + return null + } + + const openedWindow = window.open(url, '_blank', 'noopener,noreferrer') + + if (openedWindow) { + openedWindow.opener = null + } + + return openedWindow +} + +export const openPendingTab = () => { + if (typeof window === 'undefined') { + return null + } + + const openedWindow = window.open('', '_blank', 'noopener,noreferrer') + + if (openedWindow) { + openedWindow.opener = null + } + + return openedWindow +} + +export const navigateOpenedTab = (openedWindow, url) => { + if (!url) { + return + } + + if (openedWindow && !openedWindow.closed) { + openedWindow.location.replace(url) + return + } + + openExternalUrl(url) +} diff --git a/src/utils/googleDrive.js b/src/utils/googleDrive.js new file mode 100644 index 0000000..76e724a --- /dev/null +++ b/src/utils/googleDrive.js @@ -0,0 +1,39 @@ +export const GOOGLE_DRIVE_GEMINI_URL = 'https://gemini.google.com/gem/f8ed7c14b062' + +const GOOGLE_DOC_URL_PREFIX = 'https://docs.google.com/document/d/' +const GOOGLE_DRIVE_FOLDER_URL_PREFIX = 'https://drive.google.com/drive/folders/' +const ID_PATTERN = /^[A-Za-z0-9_-]{20,}$/ + +const extractGoogleResourceId = (value, matchers) => { + const trimmedValue = value?.trim() + + if (!trimmedValue) { + return null + } + + for (const matcher of matchers) { + const match = trimmedValue.match(matcher) + if (match?.[1]) { + return match[1] + } + } + + return ID_PATTERN.test(trimmedValue) ? trimmedValue : null +} + +export const extractGoogleDocId = (value) => + extractGoogleResourceId(value, [ + /docs\.google\.com\/document\/u\/\d+\/d\/([A-Za-z0-9_-]+)/i, + /docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)/i, + ]) + +export const extractGoogleDriveFolderId = (value) => + extractGoogleResourceId(value, [ + /drive\.google\.com\/drive\/folders\/([A-Za-z0-9_-]+)/i, + ]) + +export const buildGoogleDocUrl = (documentId) => + documentId ? `${GOOGLE_DOC_URL_PREFIX}${documentId}/edit` : '' + +export const buildGoogleDriveFolderUrl = (folderId) => + folderId ? `${GOOGLE_DRIVE_FOLDER_URL_PREFIX}${folderId}` : '' From 704644a663ac9a40c8ea865b273de75596c2210a Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Tue, 5 May 2026 15:30:39 -0300 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/api/googleDrive.js | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/api/googleDrive.js b/src/api/googleDrive.js index e6fcd10..a387678 100644 --- a/src/api/googleDrive.js +++ b/src/api/googleDrive.js @@ -47,24 +47,52 @@ export const updateGoogleDriveSettings = async (payload) => { const currentStatusResponse = await api.get('/google-drive/status') const currentSettings = normalizeGoogleDriveSettings(currentStatusResponse.data) - const desiredResumes = payload.baseResumes ?? [] + const desiredResumes = (payload.baseResumes ?? []).map(normalizeBaseResume) + const existingById = new Map( + currentSettings.baseResumes.map((resume) => [resume.id, resume]) + ) const existingByDocumentId = new Map( currentSettings.baseResumes.map((resume) => [resume.documentId, resume]) ) - const desiredDocumentIds = new Set(desiredResumes.map((resume) => resume.documentId)) + const desiredIds = new Set( + desiredResumes.map((resume) => resume.id).filter(Boolean) + ) + const desiredDocumentIds = new Set( + desiredResumes.map((resume) => resume.documentId).filter(Boolean) + ) await Promise.all( currentSettings.baseResumes - .filter((resume) => !desiredDocumentIds.has(resume.documentId)) + .filter( + (resume) => + !desiredIds.has(resume.id) && !desiredDocumentIds.has(resume.documentId) + ) .map((resume) => api.delete(`/google-drive/base-resumes/${resume.id}`)) ) for (const resume of desiredResumes) { - if (!existingByDocumentId.has(resume.documentId)) { + const existingResume = + existingById.get(resume.id) ?? existingByDocumentId.get(resume.documentId) + + if (!existingResume) { await api.post('/google-drive/base-resumes', { documentIdOrUrl: resume.documentId, }) + continue + } + + const hasChanged = + existingResume.documentId !== resume.documentId || + existingResume.name !== resume.name || + existingResume.isDefault !== Boolean(resume.isDefault) + + if (hasChanged) { + await api.put(`/google-drive/base-resumes/${existingResume.id}`, { + documentIdOrUrl: resume.documentId, + name: resume.name, + isDefault: Boolean(resume.isDefault), + }) } } From 21ea2f63f9fa843d199beb0a2af54aea053fc86c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 18:40:20 +0000 Subject: [PATCH 3/6] fix: guard normalizeBaseResume synthetic ids and add google drive resume E2E tests Agent-Logs-Url: https://github.com/vitorhugo-java/React-JobApplyTracker/sessions/001ac48e-c554-48ee-adc3-439096089cb2 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --- package-lock.json | 39 ------- src/api/googleDrive.js | 5 +- tests/google-drive-resume.spec.ts | 182 ++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 41 deletions(-) create mode 100644 tests/google-drive-resume.spec.ts diff --git a/package-lock.json b/package-lock.json index e2fcaee..345a87c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2709,9 +2709,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2726,9 +2723,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2743,9 +2737,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2760,9 +2751,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2777,9 +2765,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2794,9 +2779,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2811,9 +2793,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2828,9 +2807,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2845,9 +2821,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2862,9 +2835,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2879,9 +2849,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2896,9 +2863,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2913,9 +2877,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/api/googleDrive.js b/src/api/googleDrive.js index a387678..5ad9d43 100644 --- a/src/api/googleDrive.js +++ b/src/api/googleDrive.js @@ -5,7 +5,7 @@ const normalizeBaseResume = (resume, index) => { const documentId = resume?.documentId ?? resume?.googleDocId ?? resume?.googleFileId ?? '' return { - id: resume?.id ?? `resume-${index + 1}`, + id: resume?.id ?? null, name: resume?.name ?? resume?.label ?? resume?.documentName ?? `Resume ${index + 1}`, documentId, documentUrl: resume?.documentUrl ?? resume?.googleDocUrl ?? resume?.webViewLink ?? buildGoogleDocUrl(documentId), @@ -66,6 +66,7 @@ export const updateGoogleDriveSettings = async (payload) => { currentSettings.baseResumes .filter( (resume) => + Boolean(resume.id) && !desiredIds.has(resume.id) && !desiredDocumentIds.has(resume.documentId) ) .map((resume) => api.delete(`/google-drive/base-resumes/${resume.id}`)) @@ -87,7 +88,7 @@ export const updateGoogleDriveSettings = async (payload) => { existingResume.name !== resume.name || existingResume.isDefault !== Boolean(resume.isDefault) - if (hasChanged) { + if (hasChanged && existingResume.id) { await api.put(`/google-drive/base-resumes/${existingResume.id}`, { documentIdOrUrl: resume.documentId, name: resume.name, diff --git a/tests/google-drive-resume.spec.ts b/tests/google-drive-resume.spec.ts new file mode 100644 index 0000000..b4542b2 --- /dev/null +++ b/tests/google-drive-resume.spec.ts @@ -0,0 +1,182 @@ +import { test, expect, type Page } from '@playwright/test' +import { setupMockApplicationsApi } from './helpers/appApi' +import { setupMockAuth } from './helpers/auth' + +const API_BASE = '**/api/v1' + +type BaseResume = { + id: string | null + name: string + documentId: string + isDefault?: boolean +} + +type GoogleDriveStatusPayload = { + connected: boolean + baseFolderId?: string + baseResumes?: BaseResume[] +} + +function setupPage(page: Page): Promise { + setupMockAuth(page, 'test@example.com', 'Test User') + setupMockApplicationsApi(page) + return page.addInitScript(() => { + window.localStorage.setItem( + 'auth-storage', + JSON.stringify({ + state: { + accessToken: 'pw-access-token', + user: { id: 'pw-user-1', name: 'Test User', email: 'test@example.com' }, + theme: 'light', + }, + version: 0, + }) + ) + }) +} + +function mockGoogleDriveStatus(page: Page, payload: GoogleDriveStatusPayload): Promise { + return page.route(`${API_BASE}/google-drive/status`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(payload), + }) + }) +} + +function mockGamificationEvents(page: Page): Promise { + return page.route(`${API_BASE}/gamification/events`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ eventType: 'APPLICATION_CREATED', xpAwarded: 10 }), + }) + }) +} + +/** Intercept window.open to return a fake window object, preventing real popups. */ +function mockWindowOpen(page: Page): Promise { + return page.addInitScript(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).open = () => ({ + location: { href: 'about:blank', replace: () => {} }, + closed: false, + close: () => {}, + opener: null, + }) + }) +} + +const connectedSettings: GoogleDriveStatusPayload = { + connected: true, + baseFolderId: 'folder-123', + baseResumes: [ + { id: 'resume-abc', name: 'Base Resume', documentId: 'doc-abc', isDefault: true }, + ], +} + +test.describe('Google Drive Resume Workflow', () => { + test('Create Resume button is disabled when Google Drive is not connected', async ({ page }) => { + await setupPage(page) + await mockGoogleDriveStatus(page, { connected: false, baseFolderId: '', baseResumes: [] }) + await page.goto('/applications/new') + + const createResumeBtn = page.getByRole('button', { name: 'Create Resume' }) + await expect(createResumeBtn).toBeVisible({ timeout: 10_000 }) + await expect(createResumeBtn).toBeDisabled() + }) + + test('Create Resume button is disabled while Google Drive settings are loading', async ({ page }) => { + await setupPage(page) + await page.route(`${API_BASE}/google-drive/status`, async (route) => { + // Introduce a long delay so the button is checked while settings are still loading + await new Promise((resolve) => setTimeout(resolve, 3_000)) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(connectedSettings), + }) + }) + await page.goto('/applications/new') + + const createResumeBtn = page.getByRole('button', { name: 'Create Resume' }) + await expect(createResumeBtn).toBeVisible({ timeout: 5_000 }) + // Settings have not yet returned, so the button should still be disabled + await expect(createResumeBtn).toBeDisabled() + }) + + test('Create Resume button is enabled once Google Drive is connected with settings', async ({ page }) => { + await setupPage(page) + await mockGoogleDriveStatus(page, connectedSettings) + await page.goto('/applications/new') + + const createResumeBtn = page.getByRole('button', { name: 'Create Resume' }) + await expect(createResumeBtn).toBeEnabled({ timeout: 10_000 }) + }) + + test('sends resume creation request with the selected base resume id', async ({ page }) => { + const baseResumeId = 'resume-abc' + + await mockWindowOpen(page) + await setupPage(page) + await mockGoogleDriveStatus(page, connectedSettings) + await mockGamificationEvents(page) + + let resumeCopyBody: Record | null = null + await page.route(`${API_BASE}/google-drive/applications/*/resume-copies`, async (route) => { + resumeCopyBody = route.request().postDataJSON() as Record + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + googleDocUrl: 'https://docs.google.com/document/d/copied-doc-id/edit', + }), + }) + }) + + await page.goto('/applications/new') + + // Fill required vacancy name so the application can be created + await page.locator('[data-testid="app-vacancy-name"]').fill('Test Vacancy') + + const createResumeBtn = page.getByRole('button', { name: 'Create Resume' }) + await expect(createResumeBtn).toBeEnabled({ timeout: 10_000 }) + await createResumeBtn.click() + + // Wait until the resume-copies endpoint was called + await expect.poll(() => resumeCopyBody, { timeout: 10_000 }).not.toBeNull() + expect(resumeCopyBody?.baseResumeId).toBe(baseResumeId) + }) + + test('shows an error toast when the application cannot be saved online before creating a resume', async ({ page }) => { + await mockWindowOpen(page) + await setupPage(page) + await mockGoogleDriveStatus(page, connectedSettings) + + // Override the POST /applications route to simulate an offline-queued response + await page.route(`${API_BASE}/applications`, async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ queuedOffline: true }), + }) + return + } + await route.continue() + }) + + await page.goto('/applications/new') + await page.locator('[data-testid="app-vacancy-name"]').fill('Test Vacancy') + + const createResumeBtn = page.getByRole('button', { name: 'Create Resume' }) + await expect(createResumeBtn).toBeEnabled({ timeout: 10_000 }) + await createResumeBtn.click() + + await expect(page.locator('.p-toast')).toContainText( + 'The application must be saved online before creating a Google Docs resume.', + { timeout: 10_000 } + ) + }) +}) From d8406408668104f7d470d7df9152ae08592052cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 18:43:29 +0000 Subject: [PATCH 4/6] fix: address code review nits on allowResponse type and Boolean guard Agent-Logs-Url: https://github.com/vitorhugo-java/React-JobApplyTracker/sessions/001ac48e-c554-48ee-adc3-439096089cb2 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --- src/api/googleDrive.js | 2 +- tests/google-drive-resume.spec.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/api/googleDrive.js b/src/api/googleDrive.js index 5ad9d43..ae3f3eb 100644 --- a/src/api/googleDrive.js +++ b/src/api/googleDrive.js @@ -66,7 +66,7 @@ export const updateGoogleDriveSettings = async (payload) => { currentSettings.baseResumes .filter( (resume) => - Boolean(resume.id) && + resume.id && !desiredIds.has(resume.id) && !desiredDocumentIds.has(resume.documentId) ) .map((resume) => api.delete(`/google-drive/base-resumes/${resume.id}`)) diff --git a/tests/google-drive-resume.spec.ts b/tests/google-drive-resume.spec.ts index b4542b2..e0be914 100644 --- a/tests/google-drive-resume.spec.ts +++ b/tests/google-drive-resume.spec.ts @@ -79,7 +79,7 @@ const connectedSettings: GoogleDriveStatusPayload = { test.describe('Google Drive Resume Workflow', () => { test('Create Resume button is disabled when Google Drive is not connected', async ({ page }) => { await setupPage(page) - await mockGoogleDriveStatus(page, { connected: false, baseFolderId: '', baseResumes: [] }) + await mockGoogleDriveStatus(page, { connected: false }) await page.goto('/applications/new') const createResumeBtn = page.getByRole('button', { name: 'Create Resume' }) @@ -89,21 +89,30 @@ test.describe('Google Drive Resume Workflow', () => { test('Create Resume button is disabled while Google Drive settings are loading', async ({ page }) => { await setupPage(page) + + // Use a deferred latch so the route never responds during the assertion, + // avoiding an arbitrary fixed delay while still simulating indefinite loading. + let allowResponse: () => void + const responseLatch = new Promise((resolve) => { allowResponse = resolve }) + await page.route(`${API_BASE}/google-drive/status`, async (route) => { - // Introduce a long delay so the button is checked while settings are still loading - await new Promise((resolve) => setTimeout(resolve, 3_000)) + await responseLatch await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(connectedSettings), }) }) + await page.goto('/applications/new') const createResumeBtn = page.getByRole('button', { name: 'Create Resume' }) await expect(createResumeBtn).toBeVisible({ timeout: 5_000 }) // Settings have not yet returned, so the button should still be disabled await expect(createResumeBtn).toBeDisabled() + + // Unblock the route so Playwright can cleanly close the page + allowResponse() }) test('Create Resume button is enabled once Google Drive is connected with settings', async ({ page }) => { From db1e1c836d27b421ab79b235ccf7eb02bd928b8c Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Tue, 5 May 2026 17:18:46 -0300 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/api/googleDrive.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/googleDrive.js b/src/api/googleDrive.js index ae3f3eb..c9df294 100644 --- a/src/api/googleDrive.js +++ b/src/api/googleDrive.js @@ -79,6 +79,8 @@ export const updateGoogleDriveSettings = async (payload) => { if (!existingResume) { await api.post('/google-drive/base-resumes', { documentIdOrUrl: resume.documentId, + name: resume.name, + isDefault: Boolean(resume.isDefault), }) continue } From 0c25d9375e9f0c6a7615c8d3ed2d85a469efb208 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 20:20:53 +0000 Subject: [PATCH 6/6] fix: filter null ids from existingById map and guard id-based lookup in updateGoogleDriveSettings Agent-Logs-Url: https://github.com/vitorhugo-java/React-JobApplyTracker/sessions/5955c1c5-0b96-4202-8780-07a9e8b8da20 Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --- src/api/googleDrive.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/api/googleDrive.js b/src/api/googleDrive.js index c9df294..8c6747c 100644 --- a/src/api/googleDrive.js +++ b/src/api/googleDrive.js @@ -50,10 +50,14 @@ export const updateGoogleDriveSettings = async (payload) => { const desiredResumes = (payload.baseResumes ?? []).map(normalizeBaseResume) const existingById = new Map( - currentSettings.baseResumes.map((resume) => [resume.id, resume]) + currentSettings.baseResumes + .filter((resume) => resume.id) + .map((resume) => [resume.id, resume]) ) const existingByDocumentId = new Map( - currentSettings.baseResumes.map((resume) => [resume.documentId, resume]) + currentSettings.baseResumes + .filter((resume) => resume.documentId) + .map((resume) => [resume.documentId, resume]) ) const desiredIds = new Set( desiredResumes.map((resume) => resume.id).filter(Boolean) @@ -74,7 +78,7 @@ export const updateGoogleDriveSettings = async (payload) => { for (const resume of desiredResumes) { const existingResume = - existingById.get(resume.id) ?? existingByDocumentId.get(resume.documentId) + (resume.id ? existingById.get(resume.id) : undefined) ?? existingByDocumentId.get(resume.documentId) if (!existingResume) { await api.post('/google-drive/base-resumes', {