diff --git a/backend/.gitignore b/backend/.gitignore index e43b503..e1d67da 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -39,3 +39,6 @@ build/ ### upload files ### uploads/ + +# Local run script +run-backend.ps1 diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/config/StartupInitializer.java b/backend/src/main/java/com/thughari/jobtrackerpro/config/StartupInitializer.java new file mode 100644 index 0000000..cbfbe52 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/config/StartupInitializer.java @@ -0,0 +1,23 @@ +package com.thughari.jobtrackerpro.config; + +import com.thughari.jobtrackerpro.repo.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class StartupInitializer implements CommandLineRunner { + + private final UserRepository userRepository; + + public StartupInitializer(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public void run(String... args) throws Exception { + log.info("System Startup: Resetting all active Gmail sync locks."); + userRepository.resetAllSyncLocks(); + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailIntegrationController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailIntegrationController.java index 8148512..4ab6fb6 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailIntegrationController.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailIntegrationController.java @@ -35,6 +35,7 @@ public ResponseEntity connectGmail(@RequestBody Map body try { gmailAutomationService.connectAndSetupPush(authCode, email); + gmailAutomationService.initiateManualSync(email); return ResponseEntity.ok("Gmail Automation enabled successfully."); } catch (Exception e) { log.error("Failed to setup Gmail for user {}: {}", email, e.getMessage()); diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/DashboardResponse.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/DashboardResponse.java index ab10cdc..1752925 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/dto/DashboardResponse.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/DashboardResponse.java @@ -8,4 +8,6 @@ public class DashboardResponse { private List statusChart; private List monthlyChart; private List interviewChart; + private boolean gmailSyncInProgress; + private String gmailSyncStatus; } \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/entity/User.java b/backend/src/main/java/com/thughari/jobtrackerpro/entity/User.java index b13d4c5..9c99f84 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/entity/User.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/entity/User.java @@ -53,6 +53,9 @@ public class User { @Column(name = "gmail_sync_started_at") private LocalDateTime gmailSyncStartedAt; + @Column(name = "gmail_sync_status") + private String gmailSyncStatus; + @Column(name = "gmail_connected") private Boolean gmailConnected = false; diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/UserRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/UserRepository.java index f4761b6..f5560c2 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/repo/UserRepository.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/UserRepository.java @@ -30,6 +30,16 @@ public interface UserRepository extends JpaRepository { @Query("UPDATE User u SET u.gmailSyncInProgress = false WHERE u.email = :email") void releaseSyncLock(@Param("email") String email); + @Modifying + @Transactional + @Query("UPDATE User u SET u.gmailSyncInProgress = false, u.gmailSyncStatus = null") + void resetAllSyncLocks(); + + @Modifying + @Transactional + @Query("UPDATE User u SET u.gmailSyncStatus = :status WHERE u.email = :email") + void updateSyncStatus(@Param("email") String email, @Param("status") String status); + List findByGmailConnectedTrue(); @Modifying diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java index 9776987..6efe40e 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java @@ -14,6 +14,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; import java.time.LocalDateTime; @@ -63,6 +64,13 @@ public JobDTO extractJobFromEmail(String from, String subject, String body) { return parseGeminiResponse(response); + } catch (HttpClientErrorException e) { + if (e.getStatusCode().value() == 429) { + log.error("Gemini API quota/rate limit exceeded: {}", e.getResponseBodyAsString()); + throw new RuntimeException("GEMINI_QUOTA_EXCEEDED", e); + } + log.error("AI Extraction failed or timed out: {}", e.getMessage()); + return null; } catch (Exception e) { log.error("AI Extraction failed or timed out", e); return null; @@ -91,6 +99,13 @@ public List extractJobsFromBatch(List items) { return parseBulkGeminiResponse(response); + } catch (HttpClientErrorException e) { + if (e.getStatusCode().value() == 429) { + log.error("Gemini API quota/rate limit exceeded: {}", e.getResponseBodyAsString()); + throw new RuntimeException("GEMINI_QUOTA_EXCEEDED", e); + } + log.error("Bulk AI Extraction failed: {}", e.getMessage()); + return List.of(); } catch (Exception e) { log.error("Bulk AI Extraction failed", e); return List.of(); diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailIntegrationService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailIntegrationService.java index ffb9d30..be85076 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailIntegrationService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailIntegrationService.java @@ -24,14 +24,19 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Caching; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; +import java.lang.Thread; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; @Service @Slf4j @@ -42,11 +47,15 @@ public class GmailIntegrationService { private final JobService jobService; private final RestClient restClient; private final CacheEvictService cacheEvictService; + private final Executor taskExecutor; private final String APPLICATION_NAME = "JobTrackerPro"; - + + @Value("${app.gmail.sync-range:30d}") + private String syncRange; + private static final NetHttpTransport HTTP_TRANSPORT; private static final GsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); - + static { try { HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport(); @@ -58,34 +67,36 @@ public class GmailIntegrationService { private static final String ATS_FILTER = "from:(myworkday.com OR greenhouse.io OR lever.co OR smartrecruiters.com OR icims.com OR jobvite.com OR bamboo.hr OR workablemail.com OR successfactors.com OR taleo.net OR avature.net OR jobs2careers.com OR ziprecruiter.com OR monster.com OR careerbuilder.com OR wellfound.com OR lu.ma OR breezy.hr OR jazzhr.com OR comeet.com OR recruitee.com OR teamtailor.com OR applytojob.com OR jobs.github.com OR hackerrankforwork.com OR hackerrank.com OR hackerearth.com OR codility.com OR testgorilla.com OR hirevue.com OR vidcruiter.com OR codemetry.com OR pymetrics.com OR hired.com OR triplebyte.com OR newtonsoftware.com OR jobadder.com OR jobscore.com OR jobsoid.com OR jobplex.com OR jobdroid.com OR jobiak.com OR jobg8.com OR jobisjob.com OR jobrapido.com OR simplyhired.com OR glassdoor.com OR indeed.com OR remoteok.io OR weworkremotely.com OR remote.co OR angel.co OR stackoverflow.com)"; private static final String SUBJECT_FILTER = "subject:(Application OR Applied OR Applying OR Invited OR Candidate OR Thanks OR \"Thank You\" OR Received OR Confirmation OR Interview OR Status OR Sollicitatie OR Engineer OR Developer OR Analyst OR Scientist OR Specialist OR Invitation OR Invite OR Assessment OR Challenge OR Test OR Screened OR Position OR Declaration OR Talent OR Opportunity OR Role OR Job OR Opening OR Opened OR Lead OR Recruiter OR HR OR \"Human Resources\" OR Hiring OR Resume OR CV)"; private static final String EXCLUSIONS = " -\"payment\" -\"invoice\" -\"otp\" -\"transaction\" -\"statement\" -\"bank\" -\"security alert\" -\"verification code\""; - + @Value("${spring.security.oauth2.client.registration.google.client-id}") private String clientId; - + @Value("${spring.security.oauth2.client.registration.google.client-secret}") private String clientSecret; @Value("${app.google.pubsub-topic}") private String pubsubTopic; - public GmailIntegrationService(UserRepository userRepository, GeminiService geminiService, JobService jobService, CacheEvictService cacheEvictService) { + public GmailIntegrationService(UserRepository userRepository, GeminiService geminiService, JobService jobService, + CacheEvictService cacheEvictService, Executor taskExecutor) { this.userRepository = userRepository; this.geminiService = geminiService; this.jobService = jobService; this.restClient = RestClient.create(); this.cacheEvictService = cacheEvictService; + this.taskExecutor = taskExecutor; } @Transactional @Caching(evict = { - @CacheEvict(value = "users", key = "#email"), + @CacheEvict(value = "users", key = "#email"), @CacheEvict(value = "userEntities", key = "#email") - }) + }) public void connectAndSetupPush(String authCode, String email) throws Exception { - - User user = userRepository.findByEmail(email) + + User user = userRepository.findByEmail(email) .orElseThrow(() -> new RuntimeException("User not found")); - + NetHttpTransport transport = HTTP_TRANSPORT; GsonFactory jsonFactory = JSON_FACTORY; @@ -96,62 +107,92 @@ public void connectAndSetupPush(String authCode, String email) throws Exception String refreshToken = tokenResponse.getRefreshToken(); String accessToken = tokenResponse.getAccessToken(); - Gmail service = new Gmail.Builder(transport, jsonFactory, request -> - request.getHeaders().setAuthorization("Bearer " + accessToken)) + Gmail service = new Gmail.Builder(transport, jsonFactory, + request -> request.getHeaders().setAuthorization("Bearer " + accessToken)) .setApplicationName(APPLICATION_NAME).build(); String labelId = getOrCreateLabel(service); createJobFilter(service, labelId); - WatchRequest watchRequest = new WatchRequest() - .setTopicName(pubsubTopic) - .setLabelIds(List.of(labelId)); - - WatchResponse watchResponse = service.users().watch("me", watchRequest).execute(); + String watchHistoryId = null; + Long watchExpiration = null; + try { + WatchRequest watchRequest = new WatchRequest() + .setTopicName(pubsubTopic) + .setLabelIds(List.of(labelId)); + + WatchResponse watchResponse = service.users().watch("me", watchRequest).execute(); + watchHistoryId = watchResponse.getHistoryId().toString(); + watchExpiration = watchResponse.getExpiration(); + log.info("Gmail Push Watch registered successfully."); + } catch (Exception e) { + log.warn("Failed to set up Gmail Push Watch (OIDC PubSub): {}. Falling back to manual pull-sync mode.", + e.getMessage()); + try { + watchHistoryId = service.users().getProfile("me").execute().getHistoryId().toString(); + } catch (Exception ex) { + log.error("Failed to retrieve initial Gmail profile history ID", ex); + } + } user.setGmailConnected(true); if (refreshToken != null) { user.setGmailRefreshToken(refreshToken); } user.setGmailLabelId(labelId); - user.setGmailHistoryId(watchResponse.getHistoryId().toString()); - user.setGmailWatchExpiration(watchResponse.getExpiration()); + if (watchHistoryId != null) { + user.setGmailHistoryId(watchHistoryId); + } + user.setGmailWatchExpiration(watchExpiration); userRepository.saveAndFlush(user); - + log.info("User {} successfully connected Gmail. Watch set with label ID: {}", email, labelId); } public void initiateManualSync(String email) { - - LocalDateTime now = LocalDateTime.now(); + log.info("Manual sync requested for user: {}", email); + LocalDateTime now = LocalDateTime.now(); LocalDateTime expiryThreshold = now.minusMinutes(15); - - int updatedRows = userRepository.claimSyncLock(email, now, expiryThreshold); - + + int updatedRows = userRepository.claimSyncLock(email, now, expiryThreshold); if (updatedRows == 0) { - return; + log.info("Manual sync already in progress for user: {}. Request ignored.", email); + return; } - cacheEvictService.evictAllForUser(email); + + updateSyncStatus(email, "Initializing sync..."); + taskExecutor.execute(() -> runManualSync(email)); + } + + private void runManualSync(String email) { try { User user = userRepository.findByEmail(email) .orElseThrow(() -> new RuntimeException("User not connected to Gmail")); - + if (!user.getGmailConnected() || user.getGmailRefreshToken() == null) { log.warn("User {} attempted sync without valid Gmail connection", email); return; } String accessToken = getFreshAccessToken(user.getGmailRefreshToken()); - int found = scanInbox(accessToken, email); - + Gmail service = createGmailClient(accessToken); String currentHistoryId = service.users().getProfile("me").execute().getHistoryId().toString(); - + jobService.finalizeManualSync(email, currentHistoryId); + log.info("Manual sync successfully completed for user: {}. New jobs found: {}", email, found); + updateSyncStatus(email, "Sync completed. Found " + found + " new jobs."); } catch (Exception e) { - log.error("Manual sync failed for {}: {}", email, e.getMessage()); + if ((e.getMessage() != null && e.getMessage().contains("GEMINI_QUOTA_EXCEEDED")) || + (e.getCause() != null && e.getCause().getMessage() != null && e.getCause().getMessage().contains("GEMINI_QUOTA_EXCEEDED"))) { + log.error("Manual sync paused: Gemini API daily quota exceeded."); + updateSyncStatus(email, "Sync paused: Gemini API daily quota exceeded. Please try again tomorrow."); + } else { + log.error("Manual sync failed for {}: {}", email, e.getMessage()); + updateSyncStatus(email, "Sync failed: " + e.getMessage()); + } } finally { userRepository.releaseSyncLock(email); cacheEvictService.evictAllForUser(email); @@ -182,88 +223,160 @@ public void renewWatch(User user) { } public int scanInbox(String accessToken, String userEmail) { - List batchItems = new ArrayList<>(); - int totalFound = 0; - + updateSyncStatus(userEmail, "Scanning inbox..."); try { Gmail service = createGmailClient(accessToken); - String query = "newer_than:7d (" + ATS_FILTER + " OR " + SUBJECT_FILTER + ")" + EXCLUSIONS; - - String pageToken = null; - do { - ListMessagesResponse response = service.users().messages().list("me") - .setQ(query) - .setPageToken(pageToken) - .execute(); - - if (response.getMessages() != null) { - for (Message msg : response.getMessages()) { - Message fullMsg = service.users().messages().get("me", msg.getId()).setFormat("full").execute(); - long millisecondTimestamp = fullMsg.getInternalDate(); - LocalDateTime emailDate = LocalDateTime.ofInstant( - Instant.ofEpochMilli(millisecondTimestamp), ZoneOffset.UTC); - - String from = "", subj = "", replyTo=""; - if (fullMsg.getPayload().getHeaders() != null) { - for (var h : fullMsg.getPayload().getHeaders()) { - if ("From".equalsIgnoreCase(h.getName())) from = h.getValue(); - if ("Subject".equalsIgnoreCase(h.getName())) subj = h.getValue(); - if ("Reply-To".equalsIgnoreCase(h.getName())) replyTo = h.getValue(); - } - } - - if (!isSystemNoise(subj)) { - String body = extractTextFromBody(fullMsg.getPayload()); - batchItems.add(new EmailBatchItem(from, subj, replyTo, body, emailDate)); - } - } + String query = "newer_than:" + syncRange + " (" + ATS_FILTER + " OR " + SUBJECT_FILTER + ")" + EXCLUSIONS; + log.info("Scanning inbox with Gmail query: {}", query); + + List messageList = fetchMessageList(service, query); + log.info("Found {} emails matching filters.", messageList.size()); + + updateSyncStatus(userEmail, "Downloading details (0/" + messageList.size() + " - 0%)..."); + List fullMessages = fetchMessageDetailsInParallel(service, messageList, userEmail); + List batchItems = processMessagesToBatchItems(fullMessages); + log.info("Processed {} valid job-related batch items.", batchItems.size()); + + return extractAndSaveJobs(batchItems, userEmail); + } catch (Exception e) { + if ((e.getMessage() != null && e.getMessage().contains("GEMINI_QUOTA_EXCEEDED")) || + (e.getCause() != null && e.getCause().getMessage() != null && e.getCause().getMessage().contains("GEMINI_QUOTA_EXCEEDED"))) { + throw new RuntimeException("GEMINI_QUOTA_EXCEEDED", e); + } + log.error("Historical batch scan failed for {}: {}", userEmail, e.getMessage()); + return 0; + } + } + + private List fetchMessageList(Gmail service, String query) throws Exception { + List messages = new ArrayList<>(); + String pageToken = null; + do { + ListMessagesResponse response = service.users().messages().list("me") + .setQ(query) + .setPageToken(pageToken) + .execute(); + if (response.getMessages() != null) { + messages.addAll(response.getMessages()); + } + pageToken = response.getNextPageToken(); + } while (pageToken != null); + return messages; + } + + private List fetchMessageDetailsInParallel(Gmail service, List messageList, String userEmail) { + List fullMessages = new ArrayList<>(); + int chunkSize = 15; + for (int i = 0; i < messageList.size(); i += chunkSize) { + int end = Math.min(i + chunkSize, messageList.size()); + int percent = (messageList.isEmpty()) ? 100 : (int) Math.round(((double) end / messageList.size()) * 100); + String progressMsg = String.format("Downloading details (%d/%d - %d%%)...", end, messageList.size(), percent); + updateSyncStatus(userEmail, progressMsg); + log.info("Fetching details for messages {} to {} of {}...", i + 1, end, messageList.size()); + + List chunk = messageList.subList(i, end); + List> futures = chunk.stream() + .map(msg -> CompletableFuture.supplyAsync(() -> fetchMessageWithRetry(service, msg.getId()), + taskExecutor)) + .toList(); + + fullMessages.addAll(futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList()); + throttleChunkExecution(); + } + return fullMessages; + } + + private Message fetchMessageWithRetry(Gmail service, String messageId) { + try { + return service.users().messages().get("me", messageId).setFormat("full").execute(); + } catch (GoogleJsonResponseException e) { + if (e.getStatusCode() == 429) { + log.warn("Gmail API Rate Limit (429) hit. Retrying message {} after delay...", messageId); + backoffDelay(); + try { + return service.users().messages().get("me", messageId).setFormat("full").execute(); + } catch (Exception ex) { + log.error("Retry failed for message details: id {}", messageId, ex); } - pageToken = response.getNextPageToken(); - } while (pageToken != null); - - if (!batchItems.isEmpty()) { - - List> urlMaps = batchItems.parallelStream() - .map(item -> UrlParser.extractAndCleanUrls(item.body())) - .toList(); - - List extractedJobs = geminiService.extractJobsFromBatch(batchItems); - - for (JobDTO job : extractedJobs) { - hydrateJobUrl(job, urlMaps); - jobService.createOrUpdateJob(job, userEmail); - totalFound++; + } else { + log.error("Failed to fetch message details for id " + messageId, e); + } + } catch (Exception e) { + log.error("Failed to fetch message details for id " + messageId, e); + } + return null; + } + + private void backoffDelay() { + try { + Thread.sleep(1000); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + + private void throttleChunkExecution() { + try { + Thread.sleep(100); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + + private List processMessagesToBatchItems(List fullMessages) { + List batchItems = new ArrayList<>(); + for (Message fullMsg : fullMessages) { + String from = "", subj = "", replyTo = ""; + if (fullMsg.getPayload().getHeaders() != null) { + for (var h : fullMsg.getPayload().getHeaders()) { + if ("From".equalsIgnoreCase(h.getName())) + from = h.getValue(); + if ("Subject".equalsIgnoreCase(h.getName())) + subj = h.getValue(); + if ("Reply-To".equalsIgnoreCase(h.getName())) + replyTo = h.getValue(); } - -// for (JobDTO job : extractedJobs) { -// Integer inputIdx = job.getInputIndex(); -// -// if (inputIdx != null && inputIdx >= 0 && inputIdx < batchUrlLists.size()) { -// List urlsForThisEmail = batchUrlLists.get(inputIdx); -// -// if (job.getUrlIndex() != null && job.getUrlIndex() >= 0 && job.getUrlIndex() < urlsForThisEmail.size()) { -// job.setUrl(urlsForThisEmail.get(job.getUrlIndex())); -// } -// else if (job.getUrl() == null || job.getUrl().isEmpty()) { -// job.setUrl(urlsForThisEmail.stream() -// .filter(u -> u.toLowerCase().contains("career") || u.toLowerCase().contains("job") || u.toLowerCase().contains("apply")) -// .findFirst().orElse("")); -// } -// } -// -// sanitizeUrl(job); -// -// jobService.createOrUpdateJob(job, userEmail); -// totalFound++; -// } } + if (!isSystemNoise(subj)) { + String body = extractTextFromBody(fullMsg.getPayload()); + long millisecondTimestamp = fullMsg.getInternalDate(); + LocalDateTime emailDate = LocalDateTime.ofInstant( + Instant.ofEpochMilli(millisecondTimestamp), ZoneOffset.UTC); + batchItems.add(new EmailBatchItem(from, subj, replyTo, body, emailDate)); + } + } + return batchItems; + } - } catch (Exception e) { - log.error("Historical batch scan failed for {}: {}", userEmail, e.getMessage()); + private int extractAndSaveJobs(List batchItems, String userEmail) { + int totalFound = 0; + int batchSize = 25; + int totalBatches = (int) Math.ceil((double) batchItems.size() / batchSize); + for (int i = 0; i < batchItems.size(); i += batchSize) { + int batchNum = (i / batchSize) + 1; + int percent = (totalBatches == 0) ? 100 : (int) Math.round(((double) batchNum / totalBatches) * 100); + String statusMsg = String.format("Extracting jobs with Gemini AI (batch %d/%d - %d%%)...", batchNum, totalBatches, percent); + updateSyncStatus(userEmail, statusMsg); + log.info("Running Gemini AI extraction: batch {} of {}...", batchNum, totalBatches); + + List subList = batchItems.subList(i, Math.min(i + batchSize, batchItems.size())); + List> urlMaps = subList.stream() + .map(item -> UrlParser.extractAndCleanUrls(item.body())) + .toList(); + + List extractedJobs = geminiService.extractJobsFromBatch(subList); + for (JobDTO job : extractedJobs) { + hydrateJobUrl(job, urlMaps); + jobService.createOrUpdateJob(job, userEmail); + totalFound++; + } } return totalFound; } - + private void hydrateJobUrl(JobDTO job, List> urlMaps) { Integer idx = job.getInputIndex(); if (idx != null && idx >= 0 && idx < urlMaps.size()) { @@ -285,40 +398,44 @@ private void sanitizeUrl(JobDTO job) { } } } - + private String extractTextFromBody(MessagePart part) { if (part.getBody() != null && part.getBody().getData() != null) { byte[] decodedBytes = java.util.Base64.getUrlDecoder().decode(part.getBody().getData()); String content = new String(decodedBytes); - if (part.getMimeType().contains("text/plain")) return content; - if (part.getMimeType().contains("text/html")) return content.replaceAll("<[^>]*>", " "); + if (part.getMimeType().contains("text/plain")) + return content; + if (part.getMimeType().contains("text/html")) + return content.replaceAll("<[^>]*>", " "); } if (part.getParts() != null) { for (MessagePart subPart : part.getParts()) { String text = extractTextFromBody(subPart); - if (text != null && !text.isBlank()) return text; + if (text != null && !text.isBlank()) + return text; } } return ""; } - + private boolean isSystemNoise(String subject) { - if (subject == null) return true; + if (subject == null) + return true; String s = subject.toLowerCase(); return s.contains("security alert") || s.contains("sign-in") || s.contains("verification code") || s.contains("payment") || s.contains("otp"); } private Gmail createGmailClient(String token) throws Exception { - return new Gmail.Builder(HTTP_TRANSPORT, JSON_FACTORY, + return new Gmail.Builder(HTTP_TRANSPORT, JSON_FACTORY, request -> request.getHeaders().setAuthorization("Bearer " + token)) .setApplicationName(APPLICATION_NAME).build(); } @Transactional @Caching(evict = { - @CacheEvict(value = "users", key = "#email"), - @CacheEvict(value = "userEntities", key = "#email") + @CacheEvict(value = "users", key = "#email"), + @CacheEvict(value = "userEntities", key = "#email") }) public void disconnectGmail(String email) { User user = userRepository.findByEmail(email.toLowerCase()) @@ -333,7 +450,7 @@ public void disconnectGmail(String email) { user.setGmailLabelId(null); user.setGmailWatchExpiration(null); user.setGmailSyncInProgress(false); - + userRepository.saveAndFlush(user); cleanupGoogleResourcesAsync(refreshToken, labelId); @@ -342,16 +459,17 @@ public void disconnectGmail(String email) { protected void cleanupGoogleResourcesAsync(String refreshToken, String labelId) { try { String accessToken = getFreshAccessToken(refreshToken); - if (refreshToken == null) return; - + if (refreshToken == null) + return; + Gmail service = createGmailClient(accessToken); service.users().stop("me").execute(); - + restClient.post() .uri("https://oauth2.googleapis.com/revoke?token=" + refreshToken) .retrieve(); - + } catch (Exception e) { log.warn("Non-critical: Google resource cleanup failed: {}", e.getMessage()); } @@ -359,8 +477,8 @@ protected void cleanupGoogleResourcesAsync(String refreshToken, String labelId) public String getFreshAccessToken(String refreshToken) throws Exception { GoogleTokenResponse response = new GoogleRefreshTokenRequest( - HTTP_TRANSPORT, - JSON_FACTORY, + HTTP_TRANSPORT, + JSON_FACTORY, refreshToken, clientId, clientSecret).execute(); return response.getAccessToken(); } @@ -369,7 +487,8 @@ private String getOrCreateLabel(Gmail service) throws Exception { ListLabelsResponse list = service.users().labels().list("me").execute(); if (list.getLabels() != null) { for (Label l : list.getLabels()) { - if ("JobTrackerPro".equalsIgnoreCase(l.getName())) return l.getId(); + if ("JobTrackerPro".equalsIgnoreCase(l.getName())) + return l.getId(); } } Label newLabel = new Label().setName("JobTrackerPro") @@ -379,42 +498,48 @@ private String getOrCreateLabel(Gmail service) throws Exception { } private void createJobFilter(Gmail service, String labelId) throws Exception { - - String finalQuery = "(" + ATS_FILTER + " OR " + SUBJECT_FILTER + ")" + EXCLUSIONS; - - ListFiltersResponse listResponse = service.users().settings().filters().list("me").execute(); + + String finalQuery = "(" + ATS_FILTER + " OR " + SUBJECT_FILTER + ")" + EXCLUSIONS; + + ListFiltersResponse listResponse = service.users().settings().filters().list("me").execute(); if (listResponse != null && listResponse.getFilter() != null) { - - List existingFilters = listResponse.getFilter(); - + + List existingFilters = listResponse.getFilter(); + for (Filter existingFilter : existingFilters) { - if (existingFilter.getAction() != null && - existingFilter.getAction().getAddLabelIds() != null && - existingFilter.getAction().getAddLabelIds().contains(labelId)) { - - log.info("Found outdated JobTrackerPro filter (ID: {}). Deleting for update...", existingFilter.getId()); + if (existingFilter.getAction() != null && + existingFilter.getAction().getAddLabelIds() != null && + existingFilter.getAction().getAddLabelIds().contains(labelId)) { + + log.info("Found outdated JobTrackerPro filter (ID: {}). Deleting for update...", + existingFilter.getId()); service.users().settings().filters().delete("me", existingFilter.getId()).execute(); } } } - - Filter newFilter = new Filter() + + Filter newFilter = new Filter() .setCriteria(new FilterCriteria().setQuery(finalQuery)) .setAction(new FilterAction().setAddLabelIds(List.of(labelId))); - - try { - service.users().settings().filters().create("me", newFilter).execute(); + + try { + service.users().settings().filters().create("me", newFilter).execute(); log.info("Gmail Filter created successfully."); - } catch (GoogleJsonResponseException e) { - if (e.getStatusCode() == 409 || - (e.getStatusCode() == 400 && e.getDetails().getMessage().contains("Filter already exists"))) { + } catch (GoogleJsonResponseException e) { + if (e.getStatusCode() == 409 || + (e.getStatusCode() == 400 && e.getDetails().getMessage().contains("Filter already exists"))) { log.info("Gmail filter already exists, skipping creation."); } else { log.error("Failed to create Gmail filter: {}", e.getDetails().getMessage()); - throw e; + throw e; } } } + + private void updateSyncStatus(String email, String status) { + userRepository.updateSyncStatus(email, status); + cacheEvictService.evictAllForUser(email); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/JobService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/JobService.java index 01977dd..4fb1b41 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/JobService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/JobService.java @@ -74,7 +74,26 @@ public DashboardResponse getDashboardData(String email) { List jobs = jobRepository.findByUserEmailOrderByUpdatedAtDesc(email); DashboardResponse response = new DashboardResponse(); + response.setStats(calculateStats(jobs)); + response.setStatusChart(calculateStatusDistribution(jobs)); + response.setMonthlyChart(calculateMonthlyDistribution(jobs)); + response.setInterviewChart(calculateInterviewProgress(jobs)); + + Optional userOpt = userRepository.findByEmail(email); + boolean syncInProgress = userOpt + .map(User::getGmailSyncInProgress) + .orElse(false); + response.setGmailSyncInProgress(syncInProgress); + + String syncStatus = userOpt + .map(User::getGmailSyncStatus) + .orElse(null); + response.setGmailSyncStatus(syncStatus); + return response; + } + + private DashboardStatsDTO calculateStats(List jobs) { long total = jobs.size(); long active = jobs.parallelStream().filter(j -> j.getStatus() != null && !j.getStatus().equals("Rejected") && !j.getStatus().equals("Offer Received")).count(); @@ -85,14 +104,17 @@ public DashboardResponse getDashboardData(String email) { long offers = jobs.parallelStream().filter(j -> "Offer Received".equals(j.getStatus())).count(); long activeInterviews = jobs.parallelStream().filter(j -> "Interview Scheduled".equals(j.getStatus())).count(); - response.setStats(new DashboardStatsDTO(total, active, interviews, activeInterviews, offers)); + return new DashboardStatsDTO(total, active, interviews, activeInterviews, offers); + } + private List calculateStatusDistribution(List jobs) { Map statusMap = jobs.parallelStream() .collect(Collectors.groupingBy(Job::getStatus, Collectors.counting())); - response.setStatusChart(mapToChartData(statusMap)); + return mapToChartData(statusMap); + } + private List calculateMonthlyDistribution(List jobs) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM yy"); - List last6MonthKeys = new ArrayList<>(); LocalDateTime temp = LocalDateTime.now(); for (int i = 5; i >= 0; i--) { @@ -112,16 +134,18 @@ public DashboardResponse getDashboardData(String email) { } } } - response.setMonthlyChart(mapToChartData(monthMap)); + return mapToChartData(monthMap); + } + private List calculateInterviewProgress(List jobs) { + long total = jobs.size(); long interviewCount = jobs.parallelStream() .filter(j -> j.getStage() != null && j.getStage() >= 3) - .count(); response.setInterviewChart(List.of( + .count(); + return List.of( new ChartData("Interviewed", interviewCount), new ChartData("Not Interviewed", total > 0 ? total - interviewCount : 0) - )); - - return response; + ); } @Caching(evict = { diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index 03b5be0..df71e7b 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -21,7 +21,8 @@ spring.jpa.show-sql=true # Gemini AI app.gemini.enabled=true gemini.api.key=${GEMINI_API_KEY} -gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent +gemini.api.url=${GEMINI_API_URL:https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent} +app.gmail.sync-range=${APP_GMAIL_SYNC_RANGE:30d} # UI url diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 34b2225..b1b7e74 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -13,13 +13,13 @@ spring.mail.port=1025 spring.mail.properties.mail.smtp.auth=false spring.mail.properties.mail.smtp.starttls.enable=false -# Default for open-source -app.gemini.enabled=false - -# app.gemini.enabled=true - -# gemini.api.key=${GEMINI_API_KEY} -# gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent +# Gemini AI Integration (Google AI Studio) +# To enable, set app.gemini.enabled=true and provide your GEMINI_API_KEY +app.gemini.enabled=${APP_GEMINI_ENABLED:false} +gemini.api.key=${GEMINI_API_KEY:} +# Standard Google AI Studio Gemini API Endpoint +gemini.api.url=${GEMINI_API_URL:https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent} +app.gmail.sync-range=${APP_GMAIL_SYNC_RANGE:30d} app.storage.type=local diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties index 0fe8f1a..ab22030 100644 --- a/backend/src/main/resources/application-prod.properties +++ b/backend/src/main/resources/application-prod.properties @@ -36,7 +36,8 @@ logging.level.com.thughari.jobtrackerpro.scheduler=INFO # Gemini AI app.gemini.enabled=true gemini.api.key=${GEMINI_API_KEY} -gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent +gemini.api.url=${GEMINI_API_URL:https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent} +app.gmail.sync-range=${APP_GMAIL_SYNC_RANGE:30d} # UI url app.ui.url=https://jobtrackerpro.in diff --git a/docker-compose.yml b/docker-compose.yml index 9600863..d081931 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,8 @@ services: ports: - "5432:5432" - mailhog: - image: mailhog/mailhog + mailpit: + image: axllent/mailpit ports: - "1025:1025" - "8025:8025" \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cdc22d4..02e522f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -283,7 +283,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.1", @@ -403,7 +402,6 @@ "version": "18.2.14", "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.14.tgz", "integrity": "sha512-Kp/MWShoYYO+R3lrrZbZgszbbLGVXHB+39mdJZwnIuZMDkeL3JsIBlSOzyJRTnpS1vITc+9jgHvP/6uKbMrW1Q==", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -576,7 +574,6 @@ "version": "18.2.14", "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.14.tgz", "integrity": "sha512-ZPRswzaVRiqcfZoowuAM22Hr2/z10ajWOUoFDoQ9tWqz/fH/773kJv2F9VvePIekgNPCzaizqv9gF6tGNqaAwg==", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -592,7 +589,6 @@ "version": "18.2.14", "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.14.tgz", "integrity": "sha512-Mpq3v/mztQzGAQAAFV+wAI1hlXxZ0m8eDBgaN2kD3Ue+r4S6bLm1Vlryw0iyUnt05PcFIdxPT6xkcphq5pl6lw==", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -613,7 +609,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.14.tgz", "integrity": "sha512-BmmjyrFSBSYkm0tBSqpu4cwnJX/b/XvhM36mj2k8jah3tNS5zLDDx5w6tyHmaPJa/1D95MlXx2h6u7K9D+Mhew==", "dev": true, - "peer": true, "dependencies": { "@babel/core": "7.25.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -714,7 +709,6 @@ "version": "18.2.14", "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.14.tgz", "integrity": "sha512-BIPrCs93ZZTY9ym7yfoTgAQ5rs706yoYeAdrgc8kh/bDbM9DawxKlgeKBx2FLt09Y0YQ1bFhKVp0cV4gDEaMxQ==", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -747,7 +741,6 @@ "version": "18.2.14", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.14.tgz", "integrity": "sha512-W+JTxI25su3RiZVZT3Yrw6KNUCmOIy7OZIZ+612skPgYK2f2qil7VclnW1oCwG896h50cMJU/lnAfxZxefQgyQ==", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -827,7 +820,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -2971,7 +2963,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-5.3.8.tgz", "integrity": "sha512-b2BudQY/Si4Y2a0PdZZL6BeJtl8llgeZa7U2j47aaJSCeAl1e4UI7y8a9bSkO3o/ZbZrgT5muy/34JbsjfIWxA==", "dev": true, - "peer": true, "dependencies": { "@inquirer/checkbox": "^2.4.7", "@inquirer/confirm": "^3.1.22", @@ -4549,7 +4540,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4856,7 +4846,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4927,7 +4916,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5357,7 +5345,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6497,7 +6484,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "peer": true, "engines": { "node": ">=12" } @@ -8596,8 +8582,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz", "integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/jest-worker": { "version": "27.5.1", @@ -8723,7 +8708,6 @@ "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -8975,7 +8959,6 @@ "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", "dev": true, - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -10837,7 +10820,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11587,7 +11569,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11639,7 +11620,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==", "dev": true, - "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -12714,7 +12694,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12846,7 +12825,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", "dev": true, - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -13032,8 +13010,7 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "peer": true + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tuf-js": { "version": "2.2.1", @@ -13085,7 +13062,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13325,7 +13301,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13837,7 +13812,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -13913,7 +13887,6 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -14060,7 +14033,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14367,8 +14339,7 @@ "node_modules/zone.js": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.10.tgz", - "integrity": "sha512-YGAhaO7J5ywOXW6InXNlLmfU194F8lVgu7bRntUF3TiG8Y3nBK0x1UJJuHUP/e8IyihkjCYqhCScpSwnlaSRkQ==", - "peer": true + "integrity": "sha512-YGAhaO7J5ywOXW6InXNlLmfU194F8lVgu7bRntUF3TiG8Y3nBK0x1UJJuHUP/e8IyihkjCYqhCScpSwnlaSRkQ==" } } } diff --git a/frontend/src/app/components/application-list/application-list.component.html b/frontend/src/app/components/application-list/application-list.component.html index d3a64fd..4c3d003 100644 --- a/frontend/src/app/components/application-list/application-list.component.html +++ b/frontend/src/app/components/application-list/application-list.component.html @@ -20,16 +20,24 @@

- - - - - +
+ @if (syncStatus(); as status) { + + {{ status }} + + } + +
} diff --git a/frontend/src/app/components/application-list/application-list.component.ts b/frontend/src/app/components/application-list/application-list.component.ts index 2bd209b..00de927 100644 --- a/frontend/src/app/components/application-list/application-list.component.ts +++ b/frontend/src/app/components/application-list/application-list.component.ts @@ -1,6 +1,7 @@ import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; import { Component, + computed, effect, inject, OnDestroy, @@ -38,7 +39,9 @@ export class ApplicationListComponent implements OnInit, OnDestroy { sortDirection = signal('desc'); currentPage = signal(0); pageSize = signal(8); - isSyncing = signal(false); + isLocalSyncing = signal(false); + isSyncing = computed(() => this.isLocalSyncing() || this.jobService.gmailSyncInProgress()); + syncStatus = this.jobService.gmailSyncStatus; successMessage = signal(''); errorMessage = signal(''); @@ -79,15 +82,15 @@ export class ApplicationListComponent implements OnInit, OnDestroy { async onGmailSync() { if (this.isSyncing() || !this.authService.userProfile()?.gmailConnected) return; - this.isSyncing.set(true); + this.isLocalSyncing.set(true); try { await firstValueFrom(this.authService.syncGmail()); this.showMessage('success', 'Gmail sync started in background.'); - setTimeout(() => this.isSyncing.set(false), 30000); + await this.jobService.loadDashboard(true); } catch (err) { this.showMessage('error', 'Failed to start sync.'); } finally { - this.isSyncing.set(false); + this.isLocalSyncing.set(false); } } diff --git a/frontend/src/app/components/dashboard/dashboard.component.html b/frontend/src/app/components/dashboard/dashboard.component.html index 97b0f5c..c981f27 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.html +++ b/frontend/src/app/components/dashboard/dashboard.component.html @@ -1,172 +1,174 @@ -@if (successMessage()) { -
-
-
- -
-
-

Success

-

{{ successMessage() }}

-
- -
-
-} - -
- -
-
-

- All Applications -

-

- Tracking {{ stats().activePipeline }} active entries -

-
- - @if (authService.userProfile()?.gmailConnected) { - - } -
- - @if (stats().totalApplications === 0) { -
-
-
-
-
- -
-

Your dashboard is empty. Let's fix that!

-
-

- You don't need to enter data manually. Our AI can extract job details directly from your emails. -

-
- - -
-
-
- } @else { - -
-
-
- Total Applications -
- -
-
-
{{ stats().totalApplications}}
-
- -
-
- Interviews -
- -
-
-
- {{ stats().interviews}}({{ stats().activeInterviews }} active) -
-
{{ interviewRate() }}% interview rate
-
- -
-
- Offers Received -
- -
-
-
{{ stats().offers }}
-
{{ offerRate() }}% conversion
-
- -
-
- Active Pipeline -
- -
-
-
{{ stats().activePipeline }}
-
-
- -
- -
-

Applications by Status

-
- - -
-
- @for (item of statusData(); track item.name) { -
- - {{ item.name }} -
- } -
-
- -
-

Monthly Applications

-
- -
-
-
-
- -
-

Interview Progress

-
- -
-
-
- - Interviewed: {{ interviewData()[0].value}} -
-
- - Not Interviewed: {{ interviewData()[1].value }} -
-
-
- -
- } - -
- -@if (showHelpModal()) { - - -} \ No newline at end of file +@if (successMessage()) { +
+
+
+ +
+
+

Success

+

{{ successMessage() }}

+
+ +
+
+} + +
+ +
+
+

+ All Applications +

+

+ Tracking {{ stats().activePipeline }} active entries +

+
+ + @if (authService.userProfile()?.gmailConnected) { +
+ @if (syncStatus(); as status) { + + {{ status }} + + } + +
+ } +
+ + @if (stats().totalApplications === 0) { +
+
+
+
+
+ +
+

Your dashboard is empty. Let's fix that!

+
+

+ You don't need to enter data manually. Our AI can extract job details directly from your emails. +

+
+ + +
+
+
+ } @else { + +
+
+
+ Total Applications +
+ +
+
+
{{ stats().totalApplications}}
+
+ +
+
+ Interviews +
+ +
+
+
+ {{ stats().interviews}}({{ stats().activeInterviews }} active) +
+
{{ interviewRate() }}% interview rate
+
+ +
+
+ Offers Received +
+ +
+
+
{{ stats().offers }}
+
{{ offerRate() }}% conversion
+
+ +
+
+ Active Pipeline +
+ +
+
+
{{ stats().activePipeline }}
+
+
+ +
+ +
+

Applications by Status

+
+ + +
+
+ @for (item of statusData(); track item.name) { +
+ + {{ item.name }} +
+ } +
+
+ +
+

Monthly Applications

+
+ +
+
+
+
+ +
+

Interview Progress

+
+ +
+
+
+ + Interviewed: {{ interviewData()[0].value}} +
+
+ + Not Interviewed: {{ interviewData()[1].value }} +
+
+
+ +
+ } + +
+ \ No newline at end of file diff --git a/frontend/src/app/components/dashboard/dashboard.component.ts b/frontend/src/app/components/dashboard/dashboard.component.ts index 21860c1..d782c0e 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.ts +++ b/frontend/src/app/components/dashboard/dashboard.component.ts @@ -4,7 +4,6 @@ import { JobService } from '../../services/job.service'; import { CommonModule } from '@angular/common'; import { DonutChartComponent } from '../donut-chart/donut-chart.component'; import { BarChartComponent } from '../bar-chart/bar-chart.component'; -import { GmailSetupModalComponent } from '../gmail-setup-modal/gmail-setup-modal.component'; import { AuthService } from '../../services/auth.service'; import { firstValueFrom } from 'rxjs'; @@ -15,7 +14,6 @@ import { firstValueFrom } from 'rxjs'; CommonModule, DonutChartComponent, BarChartComponent, - GmailSetupModalComponent, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.css', @@ -27,8 +25,8 @@ export class DashboardComponent implements OnInit, OnDestroy { private themeService = inject(ThemeService); isRefreshing = signal(false); - showHelpModal = signal(false); - isSyncing = signal(false); + isLocalSyncing = signal(false); + isSyncing = computed(() => this.isLocalSyncing() || this.jobService.gmailSyncInProgress()); isGmailConnected = computed(() => !!this.authService.userProfile()?.gmailConnected); @@ -40,6 +38,7 @@ export class DashboardComponent implements OnInit, OnDestroy { statusData = this.jobService.statusDistribution; monthlyData = this.jobService.monthlyApplications; interviewData = this.jobService.interviewProgress; + syncStatus = this.jobService.gmailSyncStatus; ngOnInit() { this.jobService.loadDashboard(); @@ -62,31 +61,21 @@ export class DashboardComponent implements OnInit, OnDestroy { async onGmailSync() { if (this.isSyncing() || !this.authService.userProfile()?.gmailConnected) return; - this.isSyncing.set(true); + this.isLocalSyncing.set(true); try { await firstValueFrom(this.authService.syncGmail()); this.showMessage('success', 'Syncing started! Your dashboard will update as jobs are found.'); - setTimeout(() => this.isSyncing.set(false), 30000); - + // Immediately reload dashboard to capture the syncInProgress state + await this.jobService.loadDashboard(true); } catch (err) { this.showMessage('error', 'Sync failed. Please check your Gmail connection.'); } finally { - this.isSyncing.set(false); + this.isLocalSyncing.set(false); } } - handleModalMessage(event: { type: 'success' | 'error'; text: string }) { - this.showMessage(event.type, event.text); - } - openHelpModal() { - this.showHelpModal.set(true); - } - - closeHelpModal() { - this.showHelpModal.set(false); - } showMessage(type: 'success' | 'error', message: string) { this.clearMessages(); @@ -161,7 +150,6 @@ export class DashboardComponent implements OnInit, OnDestroy { await this.authService.initiateGmailConnection(); this.showMessage('success', 'Gmail Auto-Tracking Enabled!'); - this.closeHelpModal(); this.jobService.loadDashboard(true); } catch (err) { diff --git a/frontend/src/app/components/profile/profile.component.html b/frontend/src/app/components/profile/profile.component.html index 081caac..c813905 100644 --- a/frontend/src/app/components/profile/profile.component.html +++ b/frontend/src/app/components/profile/profile.component.html @@ -209,60 +209,6 @@

-
-

- Automation -

-
- -
-
-
- -
-
-

- Auto-import from Email -

- -

- Automatically pull job applications, interview invites, and updates from your inbox. - - One-time setup - no manual tracking. - -

- - -
-
- - -
-
- - {{ inboundEmailAddress }} - -
- -
-
- - - }
@@ -473,11 +419,7 @@

Update Profile Photo

} -@if (showHelpModal()) { - - -} + @if (showDisconnectConfirm()) { diff --git a/frontend/src/app/components/profile/profile.component.ts b/frontend/src/app/components/profile/profile.component.ts index 52748f8..6c4b1be 100644 --- a/frontend/src/app/components/profile/profile.component.ts +++ b/frontend/src/app/components/profile/profile.component.ts @@ -3,7 +3,6 @@ import { AuthService } from '../../services/auth.service'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { environment } from '../../../environments/environment'; -import { GmailSetupModalComponent } from '../gmail-setup-modal/gmail-setup-modal.component'; import { firstValueFrom } from 'rxjs'; declare var google: any; @@ -11,7 +10,7 @@ declare var google: any; @Component({ selector: 'app-profile', standalone: true, - imports: [CommonModule, FormsModule, GmailSetupModalComponent], + imports: [CommonModule, FormsModule], templateUrl: './profile.component.html', styleUrl: './profile.component.css', }) @@ -47,8 +46,6 @@ export class ProfileComponent { pendingFile: File | null = null; localPreviewUrl = signal(null); - showHelpModal = signal(false); - // Deletion related signals showDeleteConfirm = signal(false); isRequestingDeletion = signal(false); @@ -260,26 +257,7 @@ export class ProfileComponent { } } - openHelpModal() { - this.showHelpModal.set(true); - } - closeHelpModal() { - this.showHelpModal.set(false); - } - - handleModalMessage(event: { type: 'success' | 'error'; text: string }) { - this.showMessage(event.type, event.text); - } - - get inboundEmailAddress(): string { - return environment.inboundEmail; - } - - copyEmail() { - navigator.clipboard.writeText(this.inboundEmailAddress); - this.showMessage('success', 'Forwarding address copied to clipboard!'); - } connectGmail() { @@ -291,7 +269,7 @@ export class ProfileComponent { try{ const client = google.accounts.oauth2.initCodeClient({ - client_id: '963261513098-j8u29ce8g5v0r9p3q3a1nqnpcg669a46.apps.googleusercontent.com', + client_id: environment.googleClientId, scope: 'openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.labels https://www.googleapis.com/auth/gmail.settings.basic', ux_mode: 'popup', login_hint: userEmail, diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 47ed3e2..8efe713 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -236,7 +236,7 @@ export class AuthService { const userEmail = this.userProfile()?.email; const client = google.accounts.oauth2.initCodeClient({ - client_id: '963261513098-j8u29ce8g5v0r9p3q3a1nqnpcg669a46.apps.googleusercontent.com', + client_id: environment.googleClientId, scope: 'openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.labels https://www.googleapis.com/auth/gmail.settings.basic', ux_mode: 'popup', login_hint: userEmail, diff --git a/frontend/src/app/services/job.service.ts b/frontend/src/app/services/job.service.ts index 6665368..9dadda7 100644 --- a/frontend/src/app/services/job.service.ts +++ b/frontend/src/app/services/job.service.ts @@ -1,6 +1,6 @@ -import { Injectable, signal, inject, OnDestroy } from '@angular/core'; +import { Injectable, signal, inject, OnDestroy, effect } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { firstValueFrom, interval, Subscription } from 'rxjs'; +import { firstValueFrom } from 'rxjs'; import { environment } from '../../environments/environment'; import { Router } from '@angular/router'; @@ -38,6 +38,8 @@ export interface DashboardResponse { statusChart: ChartData[]; monthlyChart: ChartData[]; interviewChart: ChartData[]; + gmailSyncInProgress: boolean; + gmailSyncStatus?: string; } export interface PagedResponse { @@ -57,7 +59,7 @@ export class JobService implements OnDestroy { private router = inject(Router); private apiUrl = `${this.API}/api/jobs`; - private refreshSubscription: Subscription | null = null; + private refreshTimeout: any = null; private jobsSignal = signal([]); readonly jobs = this.jobsSignal.asReadonly(); @@ -72,6 +74,8 @@ export class JobService implements OnDestroy { readonly statusDistribution = signal([]); readonly monthlyApplications = signal([]); + readonly gmailSyncInProgress = signal(false); + readonly gmailSyncStatus = signal(null); readonly interviewProgress = signal([ { name: 'Interviewed', value: 0 }, @@ -91,7 +95,15 @@ export class JobService implements OnDestroy { private currentSearchState = ''; private currentStatusState = 'All Statuses'; - constructor() {} + constructor() { + effect(() => { + // Access the signal to track it reactive-ly + const inProgress = this.gmailSyncInProgress(); + if (this.refreshTimeout) { + this.scheduleNextRefresh(); + } + }); + } private refreshActiveView() { const currentUrl = this.router.url; @@ -110,21 +122,32 @@ export class JobService implements OnDestroy { } startAutoRefresh() { - if (this.refreshSubscription) return; - this.refreshSubscription = interval(30000).subscribe(() => { - if (document.visibilityState === 'visible') { - this.performSilentRefresh(); - } - }); + this.scheduleNextRefresh(); } stopAutoRefresh() { - if (this.refreshSubscription) { - this.refreshSubscription.unsubscribe(); - this.refreshSubscription = null; + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = null; } } + private scheduleNextRefresh() { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + } + const delay = this.gmailSyncInProgress() ? 2000 : 30000; + this.refreshTimeout = setTimeout(() => { + if (document.visibilityState === 'visible') { + this.performSilentRefresh().finally(() => { + this.scheduleNextRefresh(); + }); + } else { + this.scheduleNextRefresh(); + } + }, delay); + } + private async performSilentRefresh() { const currentUrl = this.router.url; @@ -161,6 +184,9 @@ export class JobService implements OnDestroy { this.http.get(`${this.apiUrl}/dashboard`), ); + this.gmailSyncInProgress.set(data.gmailSyncInProgress); + this.gmailSyncStatus.set(data.gmailSyncStatus || null); + // Only update if data has changed if (!this.isDashboardDataEqual(data)) { this.dashboardStats.set(data.stats); @@ -255,6 +281,8 @@ export class JobService implements OnDestroy { this.statusDistribution.set([]); this.monthlyApplications.set([]); + this.gmailSyncInProgress.set(false); + this.gmailSyncStatus.set(null); this.interviewProgress.set([ { name: 'Interviewed', value: 0 }, { name: 'Not Interviewed', value: 0 }, diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index 121c51a..c6e8fbc 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,5 +1,6 @@ export const environment = { production: true, apiBaseUrl: 'https://jobtracker-service-963261513098.asia-south1.run.app', - inboundEmail: 'ddf07c22d76aa3c896b6@cloudmailin.net' + inboundEmail: 'ddf07c22d76aa3c896b6@cloudmailin.net', + googleClientId: '963261513098-j8u29ce8g5v0r9p3q3a1nqnpcg669a46.apps.googleusercontent.com' }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 74866dd..64f4980 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,5 +1,6 @@ export const environment = { production: false, apiBaseUrl: 'http://localhost:8080', - inboundEmail: 'ddf07c22d76aa3c896b6@cloudmailin.net' + inboundEmail: 'ddf07c22d76aa3c896b6@cloudmailin.net', + googleClientId: '963261513098-j8u29ce8g5v0r9p3q3a1nqnpcg669a46.apps.googleusercontent.com' }; diff --git a/frontend/src/index.html b/frontend/src/index.html index 6f47602..55734d5 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -76,16 +76,7 @@ - - -