diff --git a/backend/LICENSE b/backend/LICENSE index 0de43a9..6b81b80 100644 --- a/backend/LICENSE +++ b/backend/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Hari Thatikonda +Copyright (c) 2026 Hari Thatikonda Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/backend/pom.xml b/backend/pom.xml index 20531e3..053c847 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -30,10 +30,11 @@ 21 2.25.27 0.11.5 + v1-rev20220404-2.0.0 - + org.springframework.boot spring-boot-starter-web @@ -72,6 +73,12 @@ s3 ${aws.sdk.version} + + + com.google.apis + google-api-services-gmail + ${google.apis.version} + org.springframework.boot diff --git a/backend/service.yaml b/backend/service.yaml index 3701a0a..c3a77f7 100644 --- a/backend/service.yaml +++ b/backend/service.yaml @@ -140,4 +140,14 @@ spec: valueFrom: secretKeyRef: name: email-sender-address + key: latest + - name: GOOGLE_PUBSUB_TOPIC + valueFrom: + secretKeyRef: + name: google-pubsub-topic + key: latest + - name: GOOGLE_PUBSUB_SERVICE_ACCOUNT + valueFrom: + secretKeyRef: + name: google-pubsub-service-account key: latest \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/JobTrackerProApplication.java b/backend/src/main/java/com/thughari/jobtrackerpro/JobTrackerProApplication.java index c0dfe77..119dac7 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/JobTrackerProApplication.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/JobTrackerProApplication.java @@ -7,12 +7,14 @@ import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.web.config.EnableSpringDataWebSupport; import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableCaching @EnableSpringDataWebSupport(pageSerializationMode = PageSerializationMode.VIA_DTO) @EnableScheduling +@EnableAsync public class JobTrackerProApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/config/AsyncConfig.java b/backend/src/main/java/com/thughari/jobtrackerpro/config/AsyncConfig.java index 8516242..115a18f 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/config/AsyncConfig.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/config/AsyncConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @@ -20,4 +21,16 @@ public Executor dashboardExecutor() { executor.initialize(); return executor; } + + @Primary + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(15); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("GmailSync-"); + executor.initialize(); + return executor; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/config/PasswordConfig.java b/backend/src/main/java/com/thughari/jobtrackerpro/config/PasswordConfig.java new file mode 100644 index 0000000..cbc48b8 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/config/PasswordConfig.java @@ -0,0 +1,15 @@ +package com.thughari.jobtrackerpro.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java index d8cba27..40da7bb 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java @@ -14,8 +14,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -73,12 +71,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - + @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/AuthController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/AuthController.java index 07e2dba..e39e225 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/controller/AuthController.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/AuthController.java @@ -8,6 +8,10 @@ import com.thughari.jobtrackerpro.dto.UserProfileResponse; import com.thughari.jobtrackerpro.service.AuthService; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; @@ -16,6 +20,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +@Slf4j @RestController @RequestMapping("/api/auth") public class AuthController { @@ -36,43 +41,43 @@ public AuthController(AuthService authService) { } @PostMapping("/signup") - public ResponseEntity registerUser(@RequestBody AuthRequest request, HttpServletResponse response) { - try { - AuthTokens tokens = authService.registerUser(request); - attachRefreshCookie(response, tokens.refreshToken()); - return ResponseEntity.ok(new AuthResponse(tokens.accessToken())); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - - @PostMapping("/login") - public ResponseEntity loginUser(@RequestBody AuthRequest request, HttpServletResponse response) { - try { - AuthTokens tokens = authService.loginUser(request); - attachRefreshCookie(response, tokens.refreshToken()); - return ResponseEntity.ok(new AuthResponse(tokens.accessToken())); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - - @PostMapping("/refresh") - public ResponseEntity refreshToken(@CookieValue(name = "refresh_token", required = false) String refreshToken, - HttpServletResponse response) { - if (refreshToken == null || refreshToken.isBlank()) { - return ResponseEntity.status(401).body("Missing refresh token"); - } - - try { - AuthTokens tokens = authService.refreshAccessToken(refreshToken); - attachRefreshCookie(response, tokens.refreshToken()); - return ResponseEntity.ok(new AuthResponse(tokens.accessToken())); - } catch (IllegalArgumentException e) { - clearRefreshCookie(response); - return ResponseEntity.status(401).body("Invalid refresh token"); - } - } + public ResponseEntity registerUser(@RequestBody AuthRequest request) { + authService.registerUser(request); + return ResponseEntity.ok(Map.of("message", "Registration successful. Please check your email to verify your account.")); + } + + @PostMapping("/login") + public ResponseEntity loginUser(@RequestBody AuthRequest request, HttpServletResponse response) { + AuthTokens tokens = authService.loginUser(request); + attachRefreshCookie(response, tokens.refreshToken()); + return ResponseEntity.ok(new AuthResponse(tokens.accessToken())); + } + + @GetMapping("/verify-email") + public ResponseEntity verifyEmail(@RequestParam String token, HttpServletResponse response) { + AuthTokens tokens = authService.verifyUser(token); + attachRefreshCookie(response, tokens.refreshToken()); + return ResponseEntity.ok(new AuthResponse(tokens.accessToken())); + } + + @PostMapping("/resend-verification") + public ResponseEntity resendVerification(@RequestParam String email) { + authService.resendVerificationEmail(email); + return ResponseEntity.ok(Map.of("message", "A new verification link has been sent.")); + } + + + @PostMapping("/refresh") + public ResponseEntity refreshToken(@CookieValue(name = "refresh_token", required = false) String refreshToken, + HttpServletResponse response) { + if (refreshToken == null || refreshToken.isBlank()) { + return ResponseEntity.status(401).body("Missing refresh token"); + } + + AuthTokens tokens = authService.refreshAccessToken(refreshToken); + attachRefreshCookie(response, tokens.refreshToken()); + return ResponseEntity.ok(new AuthResponse(tokens.accessToken())); + } @PostMapping("/logout") public ResponseEntity logout(HttpServletResponse response) { @@ -97,35 +102,22 @@ public ResponseEntity updateProfile( } @PutMapping("/password") - public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request) { - try { - String email = getAuthenticatedEmail(); - authService.changePassword(email, request); - return ResponseEntity.ok().body("Password set successfully."); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } + public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request) { + authService.changePassword(getAuthenticatedEmail(), request); + return ResponseEntity.ok().body(Map.of("message", "Password set successfully.")); + } @PostMapping("/forgot-password") public ResponseEntity forgotPassword(@RequestParam String email) { - try { - authService.forgotPassword(email); - return ResponseEntity.ok("If that email exists, a reset link has been sent."); - } catch (Exception e) { - return ResponseEntity.ok("If that email exists, a reset link has been sent."); - } + authService.forgotPassword(email); + return ResponseEntity.ok("If that email exists, a reset link has been sent."); } @PostMapping("/reset-password") - public ResponseEntity resetPassword(@RequestBody ResetPasswordRequest request) { - try { - authService.resetPassword(request.getToken(), request.getNewPassword()); - return ResponseEntity.ok("Password reset successfully. Please login."); - } catch (Exception e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } + public ResponseEntity resetPassword(@RequestBody ResetPasswordRequest request) { + authService.resetPassword(request.getToken(), request.getNewPassword()); + return ResponseEntity.ok(Map.of("message", "Password reset successfully.")); + } private String getAuthenticatedEmail() { return ((String) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).toLowerCase(); @@ -133,7 +125,6 @@ private String getAuthenticatedEmail() { private void attachRefreshCookie(HttpServletResponse response, String refreshToken) { response.addHeader("Set-Cookie", buildRefreshCookie(refreshToken, "/", refreshExpirationMs / 1000).toString()); - // Clear legacy cookie written by older builds to prevent duplicate refresh_token cookies. response.addHeader("Set-Cookie", buildRefreshCookie("", "/api/auth", 0).toString()); } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailIntegrationController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailIntegrationController.java new file mode 100644 index 0000000..8148512 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailIntegrationController.java @@ -0,0 +1,68 @@ +package com.thughari.jobtrackerpro.controller; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.thughari.jobtrackerpro.service.GmailIntegrationService; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping("/api/integrations") +@Slf4j +public class GmailIntegrationController { + + private final GmailIntegrationService gmailAutomationService; + + private final Cache syncThrottler = Caffeine.newBuilder() + .expireAfterWrite(10, TimeUnit.SECONDS) // Block duplicate clicks for 10s + .build(); + + public GmailIntegrationController(GmailIntegrationService gmailAutomationService) { + this.gmailAutomationService = gmailAutomationService; + } + + @PostMapping("/gmail/connect") + public ResponseEntity connectGmail(@RequestBody Map body) { + String authCode = body.get("code"); + String email = getAuthenticatedEmail(); + + try { + gmailAutomationService.connectAndSetupPush(authCode, email); + return ResponseEntity.ok("Gmail Automation enabled successfully."); + } catch (Exception e) { + log.error("Failed to setup Gmail for user {}: {}", email, e.getMessage()); + return ResponseEntity.status(500).body("Failed to setup Gmail: " + e.getMessage()); + } + } + + @PostMapping("/gmail/disconnect") + public ResponseEntity disconnectGmail() { + String email = SecurityContextHolder.getContext().getAuthentication().getName().toLowerCase(); + gmailAutomationService.disconnectGmail(email); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/gmail/sync") + public ResponseEntity syncGmail() { + String email = getAuthenticatedEmail(); + + if (syncThrottler.getIfPresent(email) != null) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body("Sync already requested. Please wait."); + } + syncThrottler.put(email, true); + gmailAutomationService.initiateManualSync(email); + return ResponseEntity.ok("Sync started in background. Your dashboard will update shortly."); + } + + private String getAuthenticatedEmail() { + return SecurityContextHolder.getContext().getAuthentication().getName().toLowerCase(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailWebhookController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailWebhookController.java new file mode 100644 index 0000000..1b6454d --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/GmailWebhookController.java @@ -0,0 +1,60 @@ +package com.thughari.jobtrackerpro.controller; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.thughari.jobtrackerpro.service.GmailWebhookService; +import com.thughari.jobtrackerpro.util.GoogleNotificationDecoder; +import com.thughari.jobtrackerpro.util.GoogleOidcVerifier; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping("/api/webhooks") +@Slf4j +public class GmailWebhookController { + + private final GmailWebhookService gmailService; + private final GoogleNotificationDecoder decoder; + private final GoogleOidcVerifier oidcVerifier; + + private final Cache pushDeduplicator = Caffeine.newBuilder() + .expireAfterWrite(20, TimeUnit.SECONDS) + .build(); + + public GmailWebhookController(GmailWebhookService gmailService, GoogleNotificationDecoder decoder, GoogleOidcVerifier oidcVerifier) { + this.gmailService = gmailService; + this.decoder = decoder; + this.oidcVerifier = oidcVerifier; + } + + @PostMapping("/gmail/push") + public ResponseEntity handleGmailPush( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @RequestBody Map body) { + + if (!oidcVerifier.verify(authHeader)) { + log.error("SECURITY ALERT: Blocked unauthorized Webhook attempt. Header: {}", + authHeader != null ? "Present (Invalid)" : "Missing"); + return ResponseEntity.ok().build(); + } + + String email = decoder.extractEmail(body); + + if (email != null) { + if (pushDeduplicator.getIfPresent(email) == null) { + pushDeduplicator.put(email, true); + gmailService.processHistorySync(email); + } else { + log.debug("Discarding redundant push notification for: {}", email); + } + } + + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/WebhookController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/WebhookController.java index 7e3e9b6..df0415b 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/controller/WebhookController.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/WebhookController.java @@ -118,6 +118,11 @@ public ResponseEntity handleInboundEmail(@RequestBody Map handleBadRequest(RuntimeException ex) { return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity handleNotFound(ResourceNotFoundException ex) { + @ExceptionHandler({ResourceNotFoundException.class, UserNotFoundException.class}) + public ResponseEntity handleNotFound(Exception ex) { return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage()); } - @ExceptionHandler(InvalidImageException.class) - public ResponseEntity handleImageError(InvalidImageException ex) { - return buildResponse(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ex.getMessage()); - } - @ExceptionHandler(MaxUploadSizeExceededException.class) public ResponseEntity handleMaxSizeException(MaxUploadSizeExceededException exc) { return buildResponse(HttpStatus.PAYLOAD_TOO_LARGE, "File size exceeds the limit (5MB)."); } - - @ExceptionHandler(UserNotFoundException.class) - public ResponseEntity userNotFoundException(UserNotFoundException exc) { - return buildResponse(HttpStatus.NOT_FOUND, "User not found."); - } @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception ex) { - log.warn(ex.getMessage()); + log.error("UNEXPECTED ERROR: ", ex); return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred."); } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleUnauthorized(IllegalStateException ex) { + return buildResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } private ResponseEntity buildResponse(HttpStatus status, String message) { ErrorResponse error = new ErrorResponse(status.value(), message, LocalDateTime.now()); diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/GeminiService.java b/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/GeminiService.java index be28c19..d0e0b38 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/GeminiService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/GeminiService.java @@ -1,9 +1,14 @@ package com.thughari.jobtrackerpro.interfaces; +import java.util.List; + +import com.thughari.jobtrackerpro.dto.EmailBatchItem; import com.thughari.jobtrackerpro.dto.JobDTO; public interface GeminiService { JobDTO extractJobFromEmail(String from, String subject, String body); + + List extractJobsFromBatch(List items); } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/PasswordResetTokenRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/PasswordResetTokenRepository.java index 9f04065..6dd9613 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/repo/PasswordResetTokenRepository.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/PasswordResetTokenRepository.java @@ -3,13 +3,22 @@ import com.thughari.jobtrackerpro.entity.PasswordResetToken; import com.thughari.jobtrackerpro.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.Optional; @Repository public interface PasswordResetTokenRepository extends JpaRepository { - Optional findByToken(String token); +Optional findByToken(String token); + void deleteByUser(User user); - Optional findByUser(User user); + + Optional findByUser(User user); + + @Modifying + @Query("DELETE FROM PasswordResetToken t WHERE t.expiryDate < :now") + void deleteAllExpired(LocalDateTime now); } \ No newline at end of file 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 0b5ce2e..bb679bb 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/repo/UserRepository.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/UserRepository.java @@ -1,10 +1,34 @@ package com.thughari.jobtrackerpro.repo; import com.thughari.jobtrackerpro.entity.User; + +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import java.util.UUID; public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); + + @Cacheable(value = "userEntities", key = "#email") + Optional findByEmail(String email); + + @Modifying + @Query("UPDATE User u SET u.gmailSyncInProgress = true WHERE u.email = :email AND u.gmailSyncInProgress = false") + int claimSyncLock(@Param("email") String email); + + @Modifying + @Query("UPDATE User u SET u.gmailSyncInProgress = false WHERE u.email = :email") + void releaseSyncLock(@Param("email") String email); + + List findByGmailConnectedTrue(); + + @Modifying + @Query("DELETE FROM User u WHERE u.enabled = false AND u.provider = 'LOCAL' AND u.createdAt < :cutoff") + void deleteUnverifiedUsers(@Param("cutoff") LocalDateTime cutoff); } \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/VerificationTokenRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/VerificationTokenRepository.java new file mode 100644 index 0000000..bc1f2f9 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/VerificationTokenRepository.java @@ -0,0 +1,25 @@ +package com.thughari.jobtrackerpro.repo; + +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.entity.VerificationToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface VerificationTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + void deleteByUser(User user); + @Modifying + @Query("DELETE FROM VerificationToken v WHERE v.expiryDate < :now") + void deleteAllExpired(@Param("now") LocalDateTime now); + +} \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/scheduler/JobScheduler.java b/backend/src/main/java/com/thughari/jobtrackerpro/scheduler/JobScheduler.java index 9a6ca50..0792fee 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/scheduler/JobScheduler.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/scheduler/JobScheduler.java @@ -1,41 +1,95 @@ package com.thughari.jobtrackerpro.scheduler; +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.repo.PasswordResetTokenRepository; +import com.thughari.jobtrackerpro.repo.UserRepository; +import com.thughari.jobtrackerpro.repo.VerificationTokenRepository; +import com.thughari.jobtrackerpro.service.GmailIntegrationService; import com.thughari.jobtrackerpro.service.JobService; + +import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -/** - * This scheduler runs a maintenance task every day at midnight to process stale - * job applications that haven't been updated in over 90 days. - * - * Instead of physical deletion, the task updates these applications to a 'Rejected' - * status and adds a system note, helping keep the user dashboard relevant while - * preserving historical data. - */ +import java.time.LocalDateTime; +import java.util.List; @Component @Slf4j public class JobScheduler { private final JobService jobService; + private final UserRepository userRepository; + private final GmailIntegrationService gmailIntegrationService; + + private final PasswordResetTokenRepository passwordTokenRepo; + private final VerificationTokenRepository verificationTokenRepo; - public JobScheduler(JobService jobService) { + public JobScheduler(JobService jobService, + UserRepository userRepository, + GmailIntegrationService gmailIntegrationService, + PasswordResetTokenRepository passwordTokenRepo, + VerificationTokenRepository verificationTokenRepo) { this.jobService = jobService; + this.userRepository = userRepository; + this.gmailIntegrationService = gmailIntegrationService; + this.passwordTokenRepo = passwordTokenRepo; + this.verificationTokenRepo = verificationTokenRepo; } /** - * Runs every day at midnight UTC (5:30 AM IST). - * Format: second, minute, hour, day of month, month, day(s) of week + * Daily Maintenance: Rejects stale applications (>60 days). + * Runs at Midnight UTC. */ @Scheduled(cron = "0 0 0 * * *") public void runStaleJobCleanup() { - log.info("Starting scheduled cleanup of stale applications..."); + log.info("Maintenance: Starting stale job cleanup..."); try { jobService.cleanupStaleApplications(); - log.info("Scheduled cleanup completed successfully."); + log.info("Maintenance: Stale job cleanup completed."); } catch (Exception e) { - log.error("Error during scheduled cleanup: {}", e.getMessage()); + log.error("Maintenance Error: Stale job cleanup failed: {}", e.getMessage()); + } + } + + /** + * Gmail Security: Renews the 7-day watch lease every 5 days. + */ + @Scheduled(cron = "0 0 0 */5 * *") + public void renewGmailWatches() { + log.info("Gmail Sync: Starting bulk watch renewal..."); + + List users = userRepository.findByGmailConnectedTrue(); + + if (users.isEmpty()) { + log.info("Gmail Sync: No connected users found for renewal."); + return; } + + users.parallelStream().forEach(user -> { + try { + gmailIntegrationService.renewWatch(user); + } catch (Exception e) { + log.error("Gmail Sync Error: Renewal failed for {}: {}", user.getEmail(), e.getMessage()); + } + }); + + log.info("Gmail Sync: Finished bulk watch renewal for {} users.", users.size()); + } + + @Scheduled(cron = "0 0 2 * * *") + @Transactional + public void runSystemCleanup() { + log.info("Starting system-wide security cleanup..."); + LocalDateTime now = LocalDateTime.now(); + + passwordTokenRepo.deleteAllExpired(now); + + verificationTokenRepo.deleteAllExpired(now); + + userRepository.deleteUnverifiedUsers(now.minusDays(3)); + + log.info("System cleanup completed. Database pruned of expired security entries."); } } \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/thughari/jobtrackerpro/security/JwtAuthenticationFilter.java index da19ebe..47fb9cf 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/security/JwtAuthenticationFilter.java @@ -27,6 +27,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String authHeader = request.getHeader("Authorization"); + if (authHeader == null && request.getRequestURI().contains("/api/notifications/stream")) { + String paramToken = request.getParameter("token"); + if (paramToken != null) { + authHeader = "Bearer " + paramToken; + } + } + if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/security/OAuth2SuccessHandler.java b/backend/src/main/java/com/thughari/jobtrackerpro/security/OAuth2SuccessHandler.java index 7edb972..ec53501 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/security/OAuth2SuccessHandler.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/security/OAuth2SuccessHandler.java @@ -1,9 +1,8 @@ package com.thughari.jobtrackerpro.security; -import com.thughari.jobtrackerpro.entity.AuthProvider; import com.thughari.jobtrackerpro.entity.User; -import com.thughari.jobtrackerpro.interfaces.StorageService; -import com.thughari.jobtrackerpro.repo.UserRepository; +import com.thughari.jobtrackerpro.service.AuthService; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; @@ -11,7 +10,6 @@ import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -22,9 +20,8 @@ @Slf4j public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final UserRepository userRepository; private final JwtUtils jwtUtils; - private final StorageService storageService; + private final AuthService authService; @Value("${app.ui.url}") private String uiUrl; @@ -38,57 +35,25 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler @Value("${app.jwt.refresh-cookie-same-site:Lax}") private String refreshCookieSameSite; - public OAuth2SuccessHandler(UserRepository userRepository, JwtUtils jwtUtils, StorageService storageService) { - this.userRepository = userRepository; + public OAuth2SuccessHandler(JwtUtils jwtUtils, AuthService authService) { this.jwtUtils = jwtUtils; - this.storageService = storageService; + this.authService = authService; } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication; - OAuth2User oAuth2User = authToken.getPrincipal(); - - String registrationId = authToken.getAuthorizedClientRegistrationId(); - UserInfo userInfo = extractUserInfo(registrationId, oAuth2User.getAttributes()); - - User user = userRepository.findByEmail(userInfo.email).orElse(new User()); - - boolean isNewUser = user.getId() == null; - boolean dataChanged = false; - - if (isNewUser) { - user.setEmail(userInfo.email()); - user.setProvider(AuthProvider.valueOf(registrationId.toUpperCase())); - user = userRepository.save(user); - } - - if (user.getName() == null || !user.getName().equals(userInfo.name())) { - user.setName(userInfo.name()); - dataChanged = true; - } - - if (user.getImageUrl() == null || user.getImageUrl().isEmpty()) { - if (userInfo.imageUrl() != null && !userInfo.imageUrl().isEmpty()) { - try { - String r2Url = storageService.uploadFromUrl(userInfo.imageUrl(), user.getId().toString()); - user.setImageUrl(r2Url); - dataChanged = true; - } catch (Exception e) { - log.error("Failed to sync social image: " + e.getMessage()); - } - } - } - - if (dataChanged) { - userRepository.save(user); - } - - String token = jwtUtils.generateAccessToken(user.getEmail()); - String refreshToken = jwtUtils.generateRefreshToken(user.getEmail()); - response.addHeader("Set-Cookie", buildRefreshCookie(refreshToken, "/", refreshExpirationMs / 1000).toString()); - response.addHeader("Set-Cookie", buildRefreshCookie("", "/api/auth", 0).toString()); - getRedirectStrategy().sendRedirect(request, response, uiUrl + "/login-success?token=" + token); + OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication; + String registrationId = authToken.getAuthorizedClientRegistrationId(); + UserInfo userInfo = extractUserInfo(registrationId, authToken.getPrincipal().getAttributes()); + + User user = authService.processOAuthUser(userInfo.email(), userInfo.name(), userInfo.imageUrl(), registrationId); + + String token = jwtUtils.generateAccessToken(user.getEmail()); + String refreshToken = jwtUtils.generateRefreshToken(user.getEmail()); + + response.addHeader("Set-Cookie", buildRefreshCookie(refreshToken, "/", refreshExpirationMs / 1000).toString()); + + getRedirectStrategy().sendRedirect(request, response, uiUrl + "/login-success?token=" + token); } private UserInfo extractUserInfo(String provider, Map attributes) { diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/AuthService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/AuthService.java index d5b456b..9833760 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/AuthService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/AuthService.java @@ -4,25 +4,33 @@ import com.thughari.jobtrackerpro.entity.AuthProvider; import com.thughari.jobtrackerpro.entity.PasswordResetToken; import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.entity.VerificationToken; import com.thughari.jobtrackerpro.exception.ResourceNotFoundException; import com.thughari.jobtrackerpro.exception.UserAlreadyExistsException; import com.thughari.jobtrackerpro.exception.UserNotFoundException; import com.thughari.jobtrackerpro.interfaces.StorageService; import com.thughari.jobtrackerpro.repo.PasswordResetTokenRepository; import com.thughari.jobtrackerpro.repo.UserRepository; +import com.thughari.jobtrackerpro.repo.VerificationTokenRepository; import com.thughari.jobtrackerpro.security.JwtUtils; +import lombok.extern.slf4j.Slf4j; + import java.time.LocalDateTime; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +@Slf4j @Service @Transactional public class AuthService { @@ -31,6 +39,7 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtUtils jwtUtils; private final StorageService storageService; + private final CacheManager cacheManager; @Value("${app.base-url}") private String baseUrl; @@ -38,21 +47,26 @@ public class AuthService { @Value("${cloudflare.r2.public-url.avatars}") private String avatarPublicUrl; - private final PasswordResetTokenRepository tokenRepository; + private final PasswordResetTokenRepository passwordResetTokenRepository; + + private final VerificationTokenRepository verificationTokenRepository; + private final EmailService emailService; public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtils jwtUtils, StorageService storageService, - PasswordResetTokenRepository tokenRepository, EmailService emailService) { + PasswordResetTokenRepository passwordResetTokenRepository, EmailService emailService, CacheManager cacheManager, VerificationTokenRepository verificationTokenRepository) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.jwtUtils = jwtUtils; this.storageService = storageService; - this.tokenRepository=tokenRepository; - this.emailService=emailService; + this.passwordResetTokenRepository = passwordResetTokenRepository; + this.emailService = emailService; + this.cacheManager = cacheManager; + this.verificationTokenRepository = verificationTokenRepository; } - public AuthTokens registerUser(AuthRequest request) { + public void registerUser(AuthRequest request) { validateUsername(request.getName()); if (userRepository.findByEmail(request.getEmail()).isPresent()) { throw new UserAlreadyExistsException("Email already in use"); @@ -60,17 +74,31 @@ public AuthTokens registerUser(AuthRequest request) { User user = new User(); user.setName(request.getName()); - user.setEmail(request.getEmail()); + user.setEmail(request.getEmail().toLowerCase()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setProvider(AuthProvider.LOCAL); - userRepository.save(user); + user.setEnabled(false); + userRepository.saveAndFlush(user); + + String token = UUID.randomUUID().toString(); + VerificationToken vToken = new VerificationToken(); + vToken.setToken(token); + vToken.setUser(user); + vToken.setExpiryDate(LocalDateTime.now().plusHours(24)); + verificationTokenRepository.save(vToken); + + emailService.sendVerificationEmail(user.getEmail(), token); - return generateAuthTokens(user.getEmail()); + log.info("User registered. Verification email sent to: {}", user.getEmail()); } public AuthTokens loginUser(AuthRequest request) { User user = userRepository.findByEmail(request.getEmail()) .orElseThrow(() -> new ResourceNotFoundException("Login failed! User not found")); + + if (!user.getEnabled()) { + throw new IllegalStateException("Please verify your email before logging in."); + } if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new IllegalArgumentException("Login failed! Invalid password"); @@ -96,19 +124,18 @@ public void forgotPassword(String email) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new ResourceNotFoundException("User not found")); - PasswordResetToken tokenEntity = tokenRepository.findByUser(user) + PasswordResetToken tokenEntity = passwordResetTokenRepository.findByUser(user) .orElse(new PasswordResetToken()); tokenEntity.setUser(user); tokenEntity.setToken(UUID.randomUUID().toString()); tokenEntity.setExpiryDate(LocalDateTime.now().plusMinutes(15)); - tokenRepository.save(tokenEntity); + passwordResetTokenRepository.save(tokenEntity); emailService.sendResetEmail(user.getEmail(), tokenEntity.getToken()); } - @CacheEvict(value = "users", key = "#result", condition = "#result != null") public void resetPassword(String token, String newPassword) { if (newPassword == null || newPassword.trim().isEmpty()) { throw new IllegalArgumentException("Password cannot be empty"); @@ -117,19 +144,67 @@ public void resetPassword(String token, String newPassword) { throw new IllegalArgumentException("Password must be at least 6 characters long"); } - PasswordResetToken resetToken = tokenRepository.findByToken(token) + PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(token) .orElseThrow(() -> new IllegalArgumentException("Invalid token")); if (resetToken.isExpired()) { - tokenRepository.delete(resetToken); + passwordResetTokenRepository.delete(resetToken); throw new IllegalArgumentException("Token has expired"); } User user = resetToken.getUser(); user.setPassword(passwordEncoder.encode(newPassword)); userRepository.save(user); + + evictAllUserCaches(user.getEmail()); + + passwordResetTokenRepository.delete(resetToken); + } + + @Transactional + public AuthTokens verifyUser(String token) { + VerificationToken vToken = verificationTokenRepository.findByToken(token) + .orElseThrow(() -> new IllegalArgumentException("Invalid verification link.")); + + if (vToken.isExpired()) { + verificationTokenRepository.delete(vToken); + throw new IllegalArgumentException("Verification link expired."); + } + + User user = vToken.getUser(); + user.setEnabled(true); + userRepository.saveAndFlush(user); + + evictAllUserCaches(user.getEmail()); + + AuthTokens tokens = generateAuthTokens(user.getEmail()); + + verificationTokenRepository.delete(vToken); + + return tokens; + } + + @Transactional + public void resendVerificationEmail(String email) { + User user = userRepository.findByEmail(email.toLowerCase()) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); - tokenRepository.delete(resetToken); + if (Boolean.TRUE.equals(user.getEnabled())) { + throw new IllegalStateException("Account is already verified. Please log in."); + } + + verificationTokenRepository.deleteByUser(user); + + String token = UUID.randomUUID().toString(); + VerificationToken vToken = new VerificationToken(); + vToken.setToken(token); + vToken.setUser(user); + vToken.setExpiryDate(LocalDateTime.now().plusHours(24)); + verificationTokenRepository.save(vToken); + + emailService.sendVerificationEmail(user.getEmail(), token); + + log.info("Verification email resent to: {}", user.getEmail()); } @Transactional(readOnly = true) @@ -140,7 +215,10 @@ public UserProfileResponse getCurrentUser(String email) { .orElseThrow(() -> new UserNotFoundException("User not found")); } - @CacheEvict(value = "users", key = "#email") + @Caching(evict = { + @CacheEvict(value = "users", key = "#email"), + @CacheEvict(value = "userEntities", key = "#email") + }) public void changePassword(String email, ChangePasswordRequest request) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new RuntimeException("User not found")); @@ -161,7 +239,10 @@ public void changePassword(String email, ChangePasswordRequest request) { userRepository.save(user); } - @CacheEvict(value = "users", key = "#email") + @Caching(evict = { + @CacheEvict(value = "users", key = "#email"), + @CacheEvict(value = "userEntities", key = "#email") + }) public UserProfileResponse updateProfileAtomic(String email, String name, String imageUrl, MultipartFile file) { validateUsername(name); User user = userRepository.findByEmail(email) @@ -193,6 +274,70 @@ else if (imageUrl != null && !imageUrl.isEmpty()) { userRepository.save(user); return mapToProfileResponse(user); } + + + /** + * OAuth User Sync + * Consolidates find, create, and profile update into one DB trip. + */ + @Caching(evict = { + @CacheEvict(value = "users", key = "#email"), + @CacheEvict(value = "userEntities", key = "#email") + }) + public User processOAuthUser(String email, String name, String imageUrl, String provider) { + User user = userRepository.findByEmail(email.toLowerCase()) + .orElseGet(() -> { + User newUser = new User(); + newUser.setEmail(email.toLowerCase()); + newUser.setProvider(AuthProvider.valueOf(provider.toUpperCase())); + newUser.setGmailConnected(false); + newUser.setEnabled(true); + return newUser; + }); + + boolean needsUpdate = false; + + if (!Boolean.TRUE.equals(user.getEnabled())) { + user.setEnabled(true); + needsUpdate = true; + log.info("User {} auto-verified via {} login.", email, provider); + } + + if (user.getName() == null || !user.getName().equals(name)) { + user.setName(name); + needsUpdate = true; + } + + if (user.getImageUrl() == null || (!user.getImageUrl().contains("r2") && !user.getImageUrl().equals(imageUrl))) { + if (imageUrl != null && !imageUrl.isBlank()) { + try { + String synchronizedUrl = storageService.uploadFromUrl(imageUrl, + user.getId() != null ? user.getId().toString() : UUID.randomUUID().toString()); + user.setImageUrl(synchronizedUrl); + needsUpdate = true; + } catch (Exception e) { + log.error("Social image sync failed: {}", e.getMessage()); + } + } + } + + if (user.getId() == null || needsUpdate) { + return userRepository.saveAndFlush(user); + } + + return user; + } + + private void evictAllUserCaches(String email) { + if (email == null) return; + String normalizedEmail = email.toLowerCase(); + + Cache users = cacheManager.getCache("users"); + Cache userEntities = cacheManager.getCache("userEntities"); + + if (users != null) users.evict(normalizedEmail); + if (userEntities != null) userEntities.evict(normalizedEmail); + } private UserProfileResponse mapToProfileResponse(User user) { UserProfileResponse response = new UserProfileResponse(); @@ -202,6 +347,9 @@ private UserProfileResponse mapToProfileResponse(User user) { response.setImageUrl(user.getImageUrl()); response.setProvider(user.getProvider().toString()); response.setHasPassword(user.getPassword() != null && !user.getPassword().isEmpty()); + response.setGmailConnected(Boolean.TRUE.equals(user.getGmailConnected())); + response.setGmailSyncInProgress(Boolean.TRUE.equals(user.getGmailSyncInProgress())); + response.setEnabled(Boolean.TRUE.equals(user.getEnabled())); return response; } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java index 63bb05c..01fa319 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java @@ -32,14 +32,12 @@ public class CloudStorageService implements StorageService { private static final long MAX_IMAGE_FILE_SIZE = 5 * 1024 * 1024; // 5MB private static final long MAX_RESOURCE_FILE_SIZE = 10 * 1024 * 1024; // 10MB - // Avatar Bucket Config @Value("${cloudflare.r2.bucket.avatars}") private String avatarBucket; @Value("${cloudflare.r2.public-url.avatars}") private String avatarPublicUrl; - // Resource Bucket Config @Value("${cloudflare.r2.bucket.resources}") private String resourceBucket; @@ -93,7 +91,6 @@ public String uploadResourceFile(MultipartFile file, String userId) { try { String extension = getExtensionFromFilename(file.getOriginalFilename()); - // Using a flat structure since we are in a dedicated resource bucket String fileName = userId + "-" + System.currentTimeMillis() + extension; uploadToS3(resourceBucket, fileName, contentType, file); @@ -153,7 +150,6 @@ public String uploadFromUrl(String externalUrl, String userId) { } catch (Exception e) { log.error("Failed to sync social image to R2: {}", e.getMessage()); - // Fallback: return original URL so the profile still has an image return externalUrl; } } @@ -167,8 +163,6 @@ public void deleteFile(String fileUrl) { return; } - // FIX: Must use AND (&&) here. If we used OR (||), a valid avatar URL would trigger - // the return because it doesn't start with the resource URL. if (!fileUrl.startsWith(avatarPublicUrl) && !fileUrl.startsWith(resourcePublicUrl)) { return; } @@ -197,7 +191,6 @@ public void deleteFile(String fileUrl) { } } - // --- Private Helpers --- private void uploadToS3(String bucket, String key, String contentType, MultipartFile file) throws Exception { PutObjectRequest putObj = PutObjectRequest.builder() diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/EmailService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/EmailService.java index 4f48b15..8d7e750 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/EmailService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/EmailService.java @@ -29,7 +29,7 @@ public class EmailService { @Value("${email.sender_name}") private String fromName; - @Async + @Async("taskExecutor") public void sendResetEmail(String to, String token) { try { MimeMessage message = mailSender.createMimeMessage(); @@ -106,4 +106,43 @@ public void sendForwardingHelper(String to, String code, String link) { log.error("Failed to send forwarding helper", e); } } + + @Async("taskExecutor") + public void sendVerificationEmail(String to, String token) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(fromEmail, fromName); + helper.setTo(to); + helper.setSubject("Verify Your Account - JobTrackerPro"); + + String verifyLink = uiUrl + "/verify-email?token=" + token; + + String htmlContent = """ +
+
+

Welcome to JobTrackerPro!

+

You're one step away from automating your job hunt. Please verify your email address to activate your account:

+ + + +

This link will expire in 24 hours.

+
+ If you didn't create an account, you can safely ignore this email. +
+
+
+ """.formatted(verifyLink); + + helper.setText(htmlContent, true); + mailSender.send(message); + log.info("Verification email sent to: {}", to); + + } catch (MessagingException | UnsupportedEncodingException e) { + log.error("Failed to send verification email to {}", to, e); + } + } } \ No newline at end of file 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 2bd724d..11a3abb 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java @@ -1,10 +1,13 @@ package com.thughari.jobtrackerpro.service; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.thughari.jobtrackerpro.dto.EmailBatchItem; import com.thughari.jobtrackerpro.dto.JobDTO; import com.thughari.jobtrackerpro.interfaces.GeminiService; +import com.thughari.jobtrackerpro.util.UrlParser; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -65,6 +68,414 @@ public JobDTO extractJobFromEmail(String from, String subject, String body) { return null; } } + + @Override + public List extractJobsFromBatch(List items) { + if (items == null || items.isEmpty()) return List.of(); + + String prompt = buildBatchPrompt(items); + + try { + Map requestBody = Map.of( + "contents", List.of( + Map.of("role", "user", "parts", List.of(Map.of("text", prompt))) + ) + ); + + String response = restClient.post() + .uri(apiUrl + "?key=" + apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(requestBody) + .retrieve() + .body(String.class); + + return parseBulkGeminiResponse(response); + + } catch (Exception e) { + log.error("Bulk AI Extraction failed", e); + return List.of(); + } + } + + private String buildBatchPrompt(List items) { + StringBuilder emailListBuilder = new StringBuilder(); + for (int i = 0; i < items.size(); i++) { + EmailBatchItem item = items.get(i); + String rawBody = item.body() == null ? "" : item.body(); + + String trimmed = UrlParser.trimNoise(rawBody); + String safeBody = trimmed.length() > 6000 + ? trimmed.substring(0, 6000) + : trimmed; + + List urls = UrlParser.extractAndCleanUrls(rawBody); + + emailListBuilder.append(""" + --- EMAIL INDEX: %d --- + FROM: %s + SUBJECT: %s + BODY: %s + + AVAILABLE_URLS: + %s + ----------------------- + """.formatted( + i, + item.from(), + item.subject(), + safeBody, + buildUrlIndexList(urls) + )); + } + + return """ + ##Act as a strict Global Data Extraction System for a Job Application Tracker. + + You analyze emails and extract structured job application data. + + -------------------------------------------------- + + ### TASK + + Analyze the list of emails provided below. + + For EACH email you must determine: + + 1. Is this email related to a REAL hiring interaction? + 2. If YES → extract structured job data. + 3. If NO → completely ignore the email. + + A valid job-related email must indicate interaction with a hiring process. + + Examples of valid hiring interactions: + - Application confirmation + - Recruiter outreach + - Interview invitation + - Coding assessment invitation + - Hiring process update + - Offer + - Rejection + + Emails that are only informational, promotional, or educational MUST be ignored. + + -------------------------------------------------- + + ### HIRING SIGNAL RULE (VERY IMPORTANT) + + A valid job email MUST contain at least ONE hiring process signal such as: + + - "applied" + - "application received" + - "thank you for applying" + - "interview" + - "assessment" + - "coding challenge" + - "recruiter" + - "hiring team" + - "offer" + - "rejected" + - "next steps" + + If NONE of these signals exist, the email MUST be ignored. + + Company name + role alone is NOT sufficient. + + -------------------------------------------------- + + ### HARD EXCLUSION RULES + + DO NOT treat the email as job-related if it is any of the following: + + - Job alerts + - Job recommendation digests + - Emails listing multiple job postings + - Developer events or workshops + - Coding contests + - Newsletters + - Marketing campaigns + - Community announcements + - Learning resources or interview prep + - General job search tips + + Examples that MUST be ignored: + + - "Your job alert" + - "Recommended jobs" + - "Jobs you may like" + - "New jobs for you" + - "Join our developer workshop" + - "LeetCode Weekly Contest" + - "Google Cloud Labs event" + - "Upcoming coding challenge" + + These emails MUST produce NO OUTPUT. + + -------------------------------------------------- + + ### MULTI-COMPANY RULE + + If an email lists multiple different companies or multiple unrelated job listings, + it is a job alert or recommendation email and MUST be ignored. + + Valid job emails normally reference ONE specific job opportunity. + + -------------------------------------------------- + + ### NON-JOB EMAIL EXCLUSIONS + + Strictly ignore emails related to: + + - banking + - OTP + - payment confirmation + - receipts + - subscriptions + - invoices + - shipping notifications + - system alerts + - account security notifications + + -------------------------------------------------- + + ### INDEXING RULE (STRICT) + + Each email is labeled: + + --- EMAIL INDEX: X --- + + You MUST: + + - Return "inputIndex" exactly equal to the EMAIL INDEX value. + - NEVER renumber indexes. + - NEVER invent indexes. + - Only include job-related emails. + - Preserve original index numbers. + + Example: + + If emails are 0,1,2,3 and only 0 and 3 are job-related: + + [ + { "inputIndex": 0, ... }, + { "inputIndex": 3, ... } + ] + + Do NOT return sequential indexes. + + -------------------------------------------------- + + ### LIST OF EMAILS TO ANALYZE + + %s + + -------------------------------------------------- + + ### EXTRACTION RULES + + Apply these only to valid job-related emails. + + 1. COMPANY + + Identify the hiring company. + + Rules: + + - Prefer the company explicitly mentioned in hiring context. + - If multiple companies appear, select the one responsible for the job. + - Fallback: extract from sender domain. + + Examples: + careers@stripe.com → Stripe + talent.wayfair.com → Wayfair + + Ignore generic domains: + gmail, yahoo, outlook, etc. + + Default: + "Unknown Company" + + -------------------------------------------------- + + 2. ROLE + + Extract the job title. + + Examples: + Software Engineer + Java Developer + Backend Engineer + + If no clear role exists: + Default to **"Software Engineer"** + + -------------------------------------------------- + + 3. STATUS + + Return EXACTLY one of: + + - "Applied" + - "Shortlisted" + - "Interview Scheduled" + - "Offer Received" + - "Rejected" + + Rules: + + Recruiter outreach -> "Applied" + Referral messages -> "Applied" + Assessment invitations -> "Shortlisted" + Interview scheduling -> "Interview Scheduled" + Application Rejected -> "Rejected" + + Never invent new status values. + + -------------------------------------------------- + + 4. LOCATION + + Extract city or country if present. + + Examples: + London + Hyderabad + United States + + If no location exists: + Return **"Remote"** + + -------------------------------------------------- + + 5. NOTES + + Write ONE concise sentence summarizing the email. + + Example: + "Application received for Software Engineer role." + + -------------------------------------------------- + + 6. URL_SELECTION + + Each email contains a list of AVAILABLE_URLS. + + You MUST: + + - choose the index of the most relevant job-related link + - never invent URLs + - ignore footer links + + Ignore links related to: + + - unsubscribe + - help + - privacy + - settings + - account management + + If no job-related link exists: + + "urlIndex": -1 + + -------------------------------------------------- + + 7. SALARY + + Extract salary if present. + + If not present: + + salaryMin = 0.0 + salaryMax = 0.0 + + -------------------------------------------------- + + ### MULTILINGUAL RULE + + Emails may be written in any language. + + Extract information normally but return ALL output values in English. + + -------------------------------------------------- + + ### OUTPUT FORMAT + + Return ONLY a raw JSON array. + + No markdown + No explanations + No text before or after JSON. + + Return [] if no job-related emails exist. + + -------------------------------------------------- + + ### Example Output + + [ + { + "inputIndex": 0, + "company": "Stripe", + "role": "Software Engineer", + "location": "Remote", + "status": "Applied", + "urlIndex": 1, + "salaryMin": 0.0, + "salaryMax": 0.0, + "notes": "Application confirmation for Software Engineer role." + } + ]""".formatted(emailListBuilder.toString()); + } + + private List parseBulkGeminiResponse(String rawResponse) { + try { + JsonNode root = objectMapper.readTree(rawResponse); + JsonNode candidates = root.path("candidates"); + + if (candidates.isMissingNode() || candidates.isEmpty()) return List.of(); + + String contentText = candidates.get(0) + .path("content").path("parts").get(0) + .path("text").asText(); + + contentText = contentText.replaceAll("```json", "").replaceAll("```", "").trim(); + + if (contentText.equals("[]") || contentText.equalsIgnoreCase("null")) { + return List.of(); + } + + List jobs = objectMapper.readValue(contentText, new TypeReference>() {}); + + LocalDateTime now = LocalDateTime.now(); + jobs.forEach(job -> { + job.setAppliedDate(now); + job.setUpdatedAt(now); + job.setStage(mapStatusToStage(job.getStatus())); + + if ("Rejected".equalsIgnoreCase(job.getStatus())) { + job.setStageStatus("failed"); + } else if ("Offer Received".equalsIgnoreCase(job.getStatus())) { + job.setStageStatus("passed"); + } else { + job.setStageStatus("active"); + } + }); + + return jobs; + + } catch (Exception e) { + log.error("Failed to parse Bulk AI response: {}", rawResponse); + return List.of(); + } + } + + private String buildUrlIndexList(List urls) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < urls.size(); i++) { + sb.append(i).append(": ").append(urls.get(i)).append("\n"); + } + return sb.toString(); + } private String buildPrompt(String from, String subject, String body) { String safeBody = (body != null) ? (body.length() > 8000 ? body.substring(0, 8000 ) : body) : ""; diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailIntegrationService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailIntegrationService.java new file mode 100644 index 0000000..e0d52a7 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailIntegrationService.java @@ -0,0 +1,430 @@ +package com.thughari.jobtrackerpro.service; + +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest; +import com.google.api.client.googleapis.auth.oauth2.GoogleRefreshTokenRequest; +import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.gmail.Gmail; +import com.google.api.services.gmail.model.*; +import com.thughari.jobtrackerpro.dto.EmailBatchItem; +import com.thughari.jobtrackerpro.dto.JobDTO; +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.exception.ResourceNotFoundException; +import com.thughari.jobtrackerpro.interfaces.GeminiService; +import com.thughari.jobtrackerpro.repo.UserRepository; +import com.thughari.jobtrackerpro.util.UrlParser; + +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +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.util.ArrayList; +import java.util.List; + +@Service +@Slf4j +public class GmailIntegrationService { + + private final UserRepository userRepository; + private final GeminiService geminiService; + private final JobService jobService; + private final RestClient restClient; + private final CacheManager cacheManager; + private final String APPLICATION_NAME = "JobTrackerPro"; + + private static final NetHttpTransport HTTP_TRANSPORT; + private static final GsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + + static { + try { + HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport(); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize Google Transport", e); + } + } + + 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)"; + private static final String SUBJECT_FILTER = "subject:(\"Application\" OR \"Applied\" OR \"Applying\" 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\")"; + 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, CacheManager cacheManager) { + this.userRepository = userRepository; + this.geminiService = geminiService; + this.jobService = jobService; + this.restClient = RestClient.create(); + this.cacheManager = cacheManager; + } + + @Transactional + @Caching(evict = { + @CacheEvict(value = "users", key = "#email"), + @CacheEvict(value = "userEntities", key = "#email") + }) + public void connectAndSetupPush(String authCode, String email) throws Exception { + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + NetHttpTransport transport = HTTP_TRANSPORT; + GsonFactory jsonFactory = JSON_FACTORY; + + GoogleTokenResponse tokenResponse = new GoogleAuthorizationCodeTokenRequest( + transport, jsonFactory, "https://oauth2.googleapis.com/token", + clientId, clientSecret, authCode, "postmessage").execute(); + + String refreshToken = tokenResponse.getRefreshToken(); + String accessToken = tokenResponse.getAccessToken(); + + 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(); + + user.setGmailConnected(true); + if (refreshToken != null) { + user.setGmailRefreshToken(refreshToken); + } + user.setGmailLabelId(labelId); + user.setGmailHistoryId(watchResponse.getHistoryId().toString()); + user.setGmailWatchExpiration(watchResponse.getExpiration()); + + userRepository.saveAndFlush(user); + + log.info("Gmail Automation enabled with 1 DB transaction for: {}", user.getEmail()); + } + + @Async("taskExecutor") + @Transactional + public void initiateManualSync(String email) { + + int updatedRows = userRepository.claimSyncLock(email); + + if (updatedRows == 0) { + return; + } + 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(); + + user.setGmailHistoryId(currentHistoryId); + userRepository.saveAndFlush(user); + + log.info("Manual sync finished for {}. Found {} jobs.", email, found); + } catch (Exception e) { + log.error("Manual sync failed for {}: {}", email, e.getMessage()); + } finally { + userRepository.releaseSyncLock(email); + evictUserCaches(email); + } + } + + @Transactional + public void renewWatch(User user) { + try { + String accessToken = getFreshAccessToken(user.getGmailRefreshToken()); + + Gmail service = createGmailClient(accessToken); + + WatchRequest watchRequest = new WatchRequest() + .setTopicName(pubsubTopic) + .setLabelIds(List.of(user.getGmailLabelId())); + + WatchResponse watchResponse = service.users().watch("me", watchRequest).execute(); + + user.setGmailWatchExpiration(watchResponse.getExpiration()); + user.setGmailHistoryId(watchResponse.getHistoryId().toString()); + userRepository.saveAndFlush(user); + + log.info("Successfully renewed Gmail watch for: {}", user.getEmail()); + } catch (Exception e) { + log.error("Failed to renew watch for user {}: {}", user.getEmail(), e.getMessage()); + } + } + + public int scanInbox(String accessToken, String userEmail) { + List batchItems = new ArrayList<>(); + int totalFound = 0; + + 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(); + + String from = "", subj = ""; + 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 (!isSystemNoise(subj)) { + String body = extractTextFromBody(fullMsg.getPayload()); + batchItems.add(new EmailBatchItem(from, subj, body)); + } + } + } + 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++; + } + +// 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++; +// } + } + + } catch (Exception e) { + log.error("Historical batch scan failed for {}: {}", userEmail, e.getMessage()); + } + return totalFound; + } + + private void hydrateJobUrl(JobDTO job, List> urlMaps) { + Integer idx = job.getInputIndex(); + if (idx != null && idx >= 0 && idx < urlMaps.size()) { + List urls = urlMaps.get(idx); + if (job.getUrlIndex() != null && job.getUrlIndex() >= 0 && job.getUrlIndex() < urls.size()) { + job.setUrl(urls.get(job.getUrlIndex())); + } else if (job.getUrl() == null || job.getUrl().isBlank()) { + job.setUrl(urls.stream().filter(u -> u.contains("job") || u.contains("career")).findFirst().orElse("")); + } + } + sanitizeUrl(job); + } + + private void evictUserCaches(String email) { + Cache userCache = cacheManager.getCache("users"); + Cache entityCache = cacheManager.getCache("userEntities"); + Cache jobList = cacheManager.getCache("jobList"); + Cache jobDashboard = cacheManager.getCache("jobDashboard"); + + if (userCache != null) userCache.evict(email); + if (entityCache != null) entityCache.evict(email); + if (jobList != null) jobList.evict(email); + if (jobDashboard != null) jobDashboard.evict(email); + } + + private void sanitizeUrl(JobDTO job) { + if (job.getUrl() != null) { + String lower = job.getUrl().toLowerCase(); + if (lower.contains("unsubscribe") || lower.contains("privacy") || lower.contains("settings")) { + job.setUrl(""); + } + } + } + + 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.getParts() != null) { + for (MessagePart subPart : part.getParts()) { + String text = extractTextFromBody(subPart); + if (text != null && !text.isBlank()) return text; + } + } + return ""; + } + + private boolean isSystemNoise(String subject) { + 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, + request -> request.getHeaders().setAuthorization("Bearer " + token)) + .setApplicationName(APPLICATION_NAME).build(); + } + + @Transactional + @Caching(evict = { + @CacheEvict(value = "users", key = "#email"), + @CacheEvict(value = "userEntities", key = "#email") + }) + public void disconnectGmail(String email) { + User user = userRepository.findByEmail(email.toLowerCase()) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + + String refreshToken = user.getGmailRefreshToken(); + String labelId = user.getGmailLabelId(); + + user.setGmailConnected(false); + user.setGmailRefreshToken(null); + user.setGmailHistoryId(null); + user.setGmailLabelId(null); + user.setGmailWatchExpiration(null); + user.setGmailSyncInProgress(false); + + userRepository.saveAndFlush(user); + + cleanupGoogleResourcesAsync(refreshToken, labelId); + + log.info("User {} disconnected from Gmail. Local state cleared.", email); + } + + @Async("taskExecutor") + protected void cleanupGoogleResourcesAsync(String refreshToken, String labelId) { + try { + String accessToken = getFreshAccessToken(refreshToken); + if (refreshToken == null) return; + + Gmail service = createGmailClient(accessToken); + + service.users().stop("me").execute(); + + restClient.post() + .uri("https://oauth2.googleapis.com/revoke?token=" + refreshToken) + .retrieve(); + + log.info("Google resources cleaned up and token revoked."); + } catch (Exception e) { + log.warn("Non-critical: Google resource cleanup failed: {}", e.getMessage()); + } + } + + public String getFreshAccessToken(String refreshToken) throws Exception { + GoogleTokenResponse response = new GoogleRefreshTokenRequest( + HTTP_TRANSPORT, + JSON_FACTORY, + refreshToken, clientId, clientSecret).execute(); + return response.getAccessToken(); + } + + 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(); + } + } + Label newLabel = new Label().setName("JobTrackerPro") + .setLabelListVisibility("labelShow") + .setMessageListVisibility("show"); + return service.users().labels().create("me", newLabel).execute().getId(); + } + + 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(); + + List existingFilters = listResponse.getFilter(); + + if (existingFilters != null) { + 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()); + service.users().settings().filters().delete("me", existingFilter.getId()).execute(); + } + } + } + + 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(); + log.info("Gmail Filter created successfully."); + } 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; + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailWebhookService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailWebhookService.java new file mode 100644 index 0000000..3deedfc --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/GmailWebhookService.java @@ -0,0 +1,211 @@ +package com.thughari.jobtrackerpro.service; + +import com.google.api.client.googleapis.auth.oauth2.GoogleRefreshTokenRequest; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.gmail.Gmail; +import com.google.api.services.gmail.model.*; +import com.thughari.jobtrackerpro.dto.EmailBatchItem; +import com.thughari.jobtrackerpro.dto.JobDTO; +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.interfaces.GeminiService; +import com.thughari.jobtrackerpro.repo.UserRepository; +import com.thughari.jobtrackerpro.util.UrlParser; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +@Service +@Slf4j +public class GmailWebhookService { + + private final GeminiService geminiService; + private final JobService jobService; + private final UserRepository userRepository; + private final CacheManager cacheManager; + + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String clientSecret; + + public GmailWebhookService(GeminiService geminiService, JobService jobService, UserRepository userRepository, CacheManager cacheManager) { + this.geminiService = geminiService; + this.jobService = jobService; + this.userRepository = userRepository; + this.cacheManager = cacheManager; + } + + @Async("taskExecutor") + @Transactional + public void processHistorySync(String userEmail) { + final String email = userEmail.toLowerCase(); + + int updatedRows = userRepository.claimSyncLock(email); + if (updatedRows == 0) return; + + evictUserCaches(email); + + try { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found after lock")); + + if (user.getGmailRefreshToken() == null) return; + + String accessToken = getFreshAccessToken(user.getGmailRefreshToken()); + Gmail service = new Gmail.Builder(GoogleNetHttpTransport.newTrustedTransport(), GsonFactory.getDefaultInstance(), + request -> request.getHeaders().setAuthorization("Bearer " + accessToken)) + .setApplicationName("JobTrackerPro").build(); + + if (user.getGmailHistoryId() == null || user.getGmailHistoryId().isBlank()) { + bootstrapUserHistory(service, user); + return; + } + + ListHistoryResponse historyResponse = service.users().history().list("me") + .setStartHistoryId(new BigInteger(user.getGmailHistoryId())) + .setLabelId(user.getGmailLabelId()).execute(); + + if (historyResponse.getHistoryId() != null) { + user.setGmailHistoryId(historyResponse.getHistoryId().toString()); + userRepository.saveAndFlush(user); + } + + List batchItems = collectMessages(service, historyResponse.getHistory()); + + if (!batchItems.isEmpty()) {log.info("Ingesting batch of {} emails via Gemini for {}", batchItems.size(), email); + + List> batchUrlLists = batchItems.stream() + .map(item -> UrlParser.extractAndCleanUrls(item.body())) + .toList(); + + List extractedJobs = geminiService.extractJobsFromBatch(batchItems); + + for (JobDTO job : extractedJobs) { + Integer idx = job.getInputIndex(); + + if (idx != null && idx >= 0 && idx < batchUrlLists.size()) { + List originalUrls = batchUrlLists.get(idx); + + if (job.getUrlIndex() != null && job.getUrlIndex() >= 0 && job.getUrlIndex() < originalUrls.size()) { + job.setUrl(originalUrls.get(job.getUrlIndex())); + } + else if (job.getUrl() == null || job.getUrl().isEmpty()) { + job.setUrl(originalUrls.stream() + .filter(u -> { + String lower = u.toLowerCase(); + return lower.contains("career") || + lower.contains("job") || + lower.contains("apply") || + lower.contains("/jobs/") || + lower.contains("/careers/"); + }) + .findFirst().orElse("")); + } + } + + if (job.getUrl() != null) { + String lower = job.getUrl().toLowerCase(); + if (lower.contains("unsubscribe") || + lower.contains("privacy") || + lower.contains("help") || + lower.contains("settings")) { + job.setUrl(""); + } + } + + job.setUrlIndex(null); + job.setInputIndex(null); + + jobService.createOrUpdateJob(job, email); + } + } + + } catch (Exception e) { + log.error("High-Performance Sync failed for {}: ", email, e); + } finally { + userRepository.releaseSyncLock(email); + evictUserCaches(email); + } + } + + private List collectMessages(Gmail service, List historyRecords) { + List items = new ArrayList<>(); + if (historyRecords == null) return items; + + for (History history : historyRecords) { + if (history.getMessagesAdded() == null) continue; + for (HistoryMessageAdded added : history.getMessagesAdded()) { + try { + Message m = service.users().messages().get("me", added.getMessage().getId()) + .setFormat("full").execute(); + + String from = "", subj = ""; + for (var h : m.getPayload().getHeaders()) { + if ("From".equalsIgnoreCase(h.getName())) from = h.getValue(); + if ("Subject".equalsIgnoreCase(h.getName())) subj = h.getValue(); + } + + if (!isSystemNoise(subj)) { + String body = extractTextFromBody(m.getPayload()); + items.add(new EmailBatchItem(from, subj, body)); + } + } catch (Exception e) { + log.warn("Failed to fetch message {}: {}", added.getMessage().getId(), e.getMessage()); + } + } + } + return items; + } + + private String extractTextFromBody(MessagePart part) { + if (part.getBody() != null && part.getBody().getData() != null) { + String content = new String(Base64.getUrlDecoder().decode(part.getBody().getData())); + 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; + } + } + return ""; + } + + private void bootstrapUserHistory(Gmail service, User user) throws Exception { + log.info("Bootstrapping historyId for: {}", user.getEmail()); + String startId = service.users().getProfile("me").execute().getHistoryId().toString(); + user.setGmailHistoryId(startId); + userRepository.saveAndFlush(user); + } + + private boolean isSystemNoise(String subject) { + if (subject == null) return true; + String s = subject.toLowerCase(); + return s.contains("security alert") || s.contains("sign-in") || s.contains("verification code"); + } + + private void evictUserCaches(String email) { + Cache userCache = cacheManager.getCache("users"); + Cache entityCache = cacheManager.getCache("userEntities"); + if (userCache != null) userCache.evict(email); + if (entityCache != null) entityCache.evict(email); + } + + public String getFreshAccessToken(String refreshToken) throws Exception { + return new GoogleRefreshTokenRequest(GoogleNetHttpTransport.newTrustedTransport(), GsonFactory.getDefaultInstance(), + refreshToken, clientId, clientSecret).execute().getAccessToken(); + } +} \ 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 e07e3d4..3f3800e 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/JobService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/JobService.java @@ -69,11 +69,15 @@ public DashboardResponse getDashboardData(String email) { DashboardResponse response = new DashboardResponse(); long total = jobs.size(); - long active = jobs.stream().filter(j -> !j.getStatus().equals("Rejected") && !j.getStatus().equals("Offer Received")).count(); - long interviews = jobs.stream().filter(j -> j.getStatus().equals("Interview Scheduled") || j.getStage() >= 3).count(); - long offers = jobs.stream().filter(j -> j.getStatus().equals("Offer Received")).count(); + long active = jobs.stream().filter(j -> j.getStatus() != null && + !j.getStatus().equals("Rejected") && !j.getStatus().equals("Offer Received")).count(); + long interviews = jobs.stream() + .filter(j -> "Interview Scheduled".equals(j.getStatus()) || + (j.getStage() != null && j.getStage() >= 3)) + .count(); + long offers = jobs.stream().filter(j -> "Offer Received".equals(j.getStatus())).count(); long activeInterviews = jobs.stream().filter(j -> "Interview Scheduled".equals(j.getStatus())).count(); - + response.setStats(new DashboardStatsDTO(total, active, interviews, activeInterviews, offers)); Map statusMap = jobs.stream() @@ -90,8 +94,9 @@ public DashboardResponse getDashboardData(String email) { )); response.setMonthlyChart(mapToChartData(monthMap)); - long interviewCount = jobs.stream().filter(j -> j.getStage() >= 3).count(); - response.setInterviewChart(List.of( + long interviewCount = jobs.stream() + .filter(j -> j.getStage() != null && j.getStage() >= 3) + .count(); response.setInterviewChart(List.of( new ChartData("Interviewed", interviewCount), new ChartData("Not Interviewed", total > 0 ? total - interviewCount : 0) )); @@ -165,7 +170,7 @@ public void createOrUpdateJob(JobDTO incomingJob, String userEmail) { public void cleanupStaleApplications() { LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); - LocalDateTime threeMonthsAgo = now.minusMonths(3); + LocalDateTime threeMonthsAgo = now.minusMonths(2); List affectedEmails = jobRepository.findUserEmailsWithStaleJobs(threeMonthsAgo); @@ -181,25 +186,25 @@ public void cleanupStaleApplications() { Cache jobDashboard = cacheManager.getCache("jobDashboard"); Cache jobPages = cacheManager.getCache("jobPages"); - for (String email : affectedEmails) { - if (jobList != null) jobList.evict(email); - if (jobDashboard != null) jobDashboard.evict(email); - } + affectedEmails.parallelStream().forEach(email -> { + if (jobList != null) jobList.evict(email); + if (jobDashboard != null) jobDashboard.evict(email); + }); - // Clear all paged results once - if (jobPages != null) jobPages.clear(); + if (jobPages != null) jobPages.clear(); log.info("System Cleanup: Successfully rejected stale jobs for {} users.", affectedEmails.size()); } private Job findBestMatch(List existingJobs, JobDTO incoming) { - if (incoming.getCompany() == null) return null; + if (incoming == null || incoming.getCompany() == null) return null; String incomingCompany = incoming.getCompany().toLowerCase().trim(); - String incomingRole = incoming.getRole() != null ? incoming.getRole() : ""; + String incomingRole = (incoming.getRole() != null) ? incoming.getRole().toLowerCase().trim() : ""; List companyMatches = existingJobs.stream() .filter(job -> { + if (job.getCompany() == null) return false; String dbCompany = job.getCompany().toLowerCase().trim(); return dbCompany.contains(incomingCompany) || incomingCompany.contains(dbCompany); }) @@ -208,75 +213,74 @@ private Job findBestMatch(List existingJobs, JobDTO incoming) { if (companyMatches.isEmpty()) return null; List activeMatches = companyMatches.stream() - .filter(j -> !j.getStatus().equals("Rejected") && !j.getStatus().equals("Offer Received")) + .filter(j -> j.getStatus() != null && + !j.getStatus().equalsIgnoreCase("Rejected") && + !j.getStatus().equalsIgnoreCase("Offer Received")) .collect(Collectors.toList()); if (activeMatches.isEmpty()) return null; - if (activeMatches.size() > 1) { - return activeMatches.stream() - .max((j1, j2) -> { - double sim1 = calculateSimilarity(j1.getRole(), incomingRole); - double sim2 = calculateSimilarity(j2.getRole(), incomingRole); - return Double.compare(sim1, sim2); - }) - .filter(bestMatch -> calculateSimilarity(bestMatch.getRole(), incomingRole) > 0.3) - .orElse(activeMatches.get(0)); - } - - return activeMatches.get(0); + return activeMatches.stream() + .max((j1, j2) -> { + double sim1 = calculateSimilarity(j1.getRole(), incomingRole); + double sim2 = calculateSimilarity(j2.getRole(), incomingRole); + return Double.compare(sim1, sim2); + }) + .filter(bestMatch -> calculateSimilarity(bestMatch.getRole(), incomingRole) > 0.2) + .orElse(activeMatches.get(0)); } - + private void updateExistingJobFromEmail(Job existingJob, JobDTO incoming) { - // Precaution for email auto updation - if (incoming.getStage() != null && incoming.getStage() >= existingJob.getStage()) { - existingJob.setStatus(incoming.getStatus()); - existingJob.setStage(incoming.getStage()); - existingJob.setStageStatus(incoming.getStageStatus()); + String currentStatus = (existingJob.getStatus() != null) ? existingJob.getStatus() : ""; + String incomingStatus = (incoming.getStatus() != null) ? incoming.getStatus() : ""; + + if (currentStatus.equalsIgnoreCase(incomingStatus) && + Objects.equals(existingJob.getStage(), incoming.getStage())) { + return; } - - String timestamp = LocalDateTime.now().format(fmt); - String newNote = "\n[" + timestamp + "] Update via Email: " + incoming.getNotes(); - String currentNotes = existingJob.getNotes() != null ? existingJob.getNotes() : ""; - existingJob.setNotes(currentNotes + newNote); + + String newNotesFromAI = (incoming.getNotes() != null) ? incoming.getNotes().trim() : ""; + + boolean statusChanged = !currentStatus.equalsIgnoreCase(incomingStatus); + boolean stageChanged = incoming.getStage() != null && !incoming.getStage().equals(existingJob.getStage()); - if (incoming.getUrl() != null && incoming.getUrl().toLowerCase().startsWith("http")) { + boolean isNewInfo = !newNotesFromAI.isEmpty() && + (existingJob.getNotes() == null || !existingJob.getNotes().contains(newNotesFromAI)); + + boolean shouldThrottle = existingJob.getUpdatedAt().isAfter(LocalDateTime.now().minusHours(1)); + + if (statusChanged || stageChanged || (isNewInfo && !shouldThrottle)) { - boolean currentUrlMissing = existingJob.getUrl() == null || - existingJob.getUrl().isEmpty() || - !existingJob.getUrl().startsWith("http"); - - if (currentUrlMissing) { - existingJob.setUrl(incoming.getUrl()); + log.info("Updating job for {}: Status change [{} -> {}], New Info: {}", + existingJob.getCompany(), currentStatus, incomingStatus, isNewInfo); + + existingJob.setStatus(incomingStatus); + if (incoming.getStage() != null) existingJob.setStage(incoming.getStage()); + if (incoming.getStageStatus() != null) existingJob.setStageStatus(incoming.getStageStatus()); + + if (isNewInfo) { + String timestamp = LocalDateTime.now().format(fmt); + String formattedNote = "\n[" + timestamp + "] Update via Email: " + newNotesFromAI; + + String updatedNotes = (existingJob.getNotes() != null ? existingJob.getNotes() : "") + formattedNote; + existingJob.setNotes(updatedNotes); } + + existingJob.setUpdatedAt(LocalDateTime.now()); + + jobRepository.saveAndFlush(existingJob); + + } else { + log.debug("Sync detected no significant changes for {}. Skipping redundant update.", existingJob.getCompany()); } - - existingJob.setUpdatedAt(LocalDateTime.now()); - jobRepository.save(existingJob); - -// existingJob.setStatus(incoming.getStatus()); -// existingJob.setStage(incoming.getStage()); -// existingJob.setStageStatus(incoming.getStageStatus()); -// -// String newNote = "\n[" + LocalDateTime.now().format(fmt) + "] Update via Email: " + incoming.getNotes(); -// String currentNotes = existingJob.getNotes() != null ? existingJob.getNotes() : ""; -// existingJob.setNotes(currentNotes + newNote); -// -// if ((existingJob.getUrl() == null || existingJob.getUrl().isEmpty()) && incoming.getUrl() != null) { -// existingJob.setUrl(incoming.getUrl()); -// } -// -// existingJob.setUpdatedAt(LocalDateTime.now()); -// jobRepository.save(existingJob); } - private double calculateSimilarity(String role1, String role2) { if (role1 == null || role2 == null) return 0.0; - + Set set1 = tokenize(role1); Set set2 = tokenize(role2); - + if (set1.isEmpty() || set2.isEmpty()) return 0.0; Set intersection = new HashSet<>(set1); @@ -287,8 +291,10 @@ private double calculateSimilarity(String role1, String role2) { return (double) intersection.size() / union.size(); } - + private Set tokenize(String text) { + if (text == null || text.isBlank()) return Collections.emptySet(); + String[] words = text.toLowerCase().replaceAll("[^a-z0-9\\s]", "").split("\\s+"); Set uniqueWords = new HashSet<>(); for (String w : words) { diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/MockGeminiService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/MockGeminiService.java index 6329a62..1c08fc0 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/MockGeminiService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/MockGeminiService.java @@ -1,11 +1,16 @@ package com.thughari.jobtrackerpro.service.mock; +import com.thughari.jobtrackerpro.dto.EmailBatchItem; import com.thughari.jobtrackerpro.dto.JobDTO; import com.thughari.jobtrackerpro.interfaces.GeminiService; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /* * This is a mock service for Gemini AI extraction for applications @@ -14,24 +19,31 @@ @Service @ConditionalOnProperty(name = "app.gemini.enabled", havingValue = "false", matchIfMissing = true) public class MockGeminiService implements GeminiService { + + private static final Pattern COMPANY_PATTERN = Pattern.compile("(?:to|at)\\s+([A-Z][A-Za-z0-9\\s]+)"); - @Override + @Override public JobDTO extractJobFromEmail(String from, String subject, String body) { + if (isTrashEmail(subject, body)) return null; + JobDTO mockJob = new JobDTO(); - String company = (from != null && from.contains("@")) ? - from.split("@")[1].split("\\.")[0] : "Mock Company"; + String company = extractCompanyFromSubject(subject); + if (company == null && from != null) { + company = extractCompanyFromDomain(from); + } + + mockJob.setCompany(company != null ? capitalize(company) : "Target Company"); + mockJob.setRole(subject != null ? subject : "Software Professional"); + mockJob.setLocation("Remote"); + mockJob.setStatus(determineStatus(subject)); - mockJob.setCompany(company); - mockJob.setRole(subject != null ? subject : "Software Engineer"); - mockJob.setLocation("Remote (Mock)"); - mockJob.setStatus("Applied"); mockJob.setStage(1); mockJob.setStageStatus("active"); - mockJob.setSalaryMin(50000.0); - mockJob.setSalaryMax(80000.0); - mockJob.setUrl("https://example.com/mock-job"); - mockJob.setNotes("Ingested via Mock Gemini Service. No API key was used."); + mockJob.setSalaryMin(0.0); + mockJob.setSalaryMax(0.0); + mockJob.setUrl(""); + mockJob.setNotes("Ingested via Smarter Mock Service."); LocalDateTime now = LocalDateTime.now(); mockJob.setAppliedDate(now); @@ -39,5 +51,58 @@ public JobDTO extractJobFromEmail(String from, String subject, String body) { return mockJob; } + + @Override + public List extractJobsFromBatch(List items) { + if (items == null) return List.of(); + + return items.stream() + .map(item -> + extractJobFromEmail(item.from(), item.subject(), item.body()) + ) + .filter(Objects::nonNull) + .toList(); + } + + private boolean isTrashEmail(String subject, String body) { + if (subject == null) return true; + String s = subject.toLowerCase(); + return s.contains("security alert") || + s.contains("sign-in") || + s.contains("verify your email") || + s.contains("password changed"); + } + + private String extractCompanyFromSubject(String subject) { + if (subject == null) return null; + Matcher matcher = COMPANY_PATTERN.matcher(subject); + if (matcher.find()) { + return matcher.group(1).trim(); + } + return null; + } + + private String capitalize(String str) { + if (str == null || str.isEmpty()) return str; + return str.substring(0, 1).toUpperCase() + str.substring(1); + } + + private String extractCompanyFromDomain(String from) { + try { + String domain = from.split("@")[1].split("\\.")[0]; + List atsProviders = List.of("myworkday", "greenhouse", "lever", "smartrecruiters", "icims"); + if (atsProviders.contains(domain.toLowerCase())) return null; + return domain; + } catch (Exception e) { + return null; + } + } + + private String determineStatus(String subject) { + String s = subject.toLowerCase(); + if (s.contains("interview") || s.contains("invitation")) return "Interview Scheduled"; + if (s.contains("assessment") || s.contains("challenge")) return "Shortlisted"; + return "Applied"; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/util/GoogleNotificationDecoder.java b/backend/src/main/java/com/thughari/jobtrackerpro/util/GoogleNotificationDecoder.java new file mode 100644 index 0000000..4165053 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/util/GoogleNotificationDecoder.java @@ -0,0 +1,24 @@ +package com.thughari.jobtrackerpro.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import java.util.Base64; +import java.util.Map; + +@Component +public class GoogleNotificationDecoder { + private final ObjectMapper mapper = new ObjectMapper(); + + public String extractEmail(Map body) { + try { + Map message = (Map) body.get("message"); + String dataBase64 = (String) message.get("data"); + String decodedJson = new String(Base64.getDecoder().decode(dataBase64)); + JsonNode node = mapper.readTree(decodedJson); + return node.get("emailAddress").asText().toLowerCase(); + } catch (Exception e) { + return null; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/util/GoogleOidcVerifier.java b/backend/src/main/java/com/thughari/jobtrackerpro/util/GoogleOidcVerifier.java new file mode 100644 index 0000000..548cb26 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/util/GoogleOidcVerifier.java @@ -0,0 +1,65 @@ +package com.thughari.jobtrackerpro.util; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +@Component +@Slf4j +public class GoogleOidcVerifier { + + private final GoogleIdTokenVerifier verifier; + private final String expectedServiceAccount; + + public GoogleOidcVerifier( + @Value("${app.security.webhook-audience}") String expectedAudience, + @Value("${app.security.google-pubsub-service-account}") String serviceAccount) { + + this.expectedServiceAccount = serviceAccount; + + this.verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(expectedAudience)) + .setIssuer("https://accounts.google.com") + .build(); + } + + public boolean verify(String authHeader) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + log.warn("Webhook rejected: Missing or malformed Authorization header."); + return false; + } + + String idTokenString = authHeader.substring(7); + + try { + GoogleIdToken idToken = verifier.verify(idTokenString); + + if (idToken != null) { + Payload payload = idToken.getPayload(); + + boolean isAuthorizedSender = payload.getEmail().equals(expectedServiceAccount); + + boolean isEmailVerified = payload.getEmailVerified(); + + if (isAuthorizedSender && isEmailVerified) { + log.debug("OIDC Verified: Request from {}", expectedServiceAccount); + return true; + } else { + log.warn("Security Alert: OIDC Token valid but sender {} is unauthorized.", payload.getEmail()); + } + } else { + log.warn("Webhook rejected: Invalid ID Token signature or expired."); + } + } catch (Exception e) { + log.error("OIDC Verification Engine Error: {}", e.getMessage()); + } + return false; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/util/UrlParser.java b/backend/src/main/java/com/thughari/jobtrackerpro/util/UrlParser.java new file mode 100644 index 0000000..7924fb6 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/util/UrlParser.java @@ -0,0 +1,37 @@ +package com.thughari.jobtrackerpro.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class UrlParser { + + private static final Pattern URL_PATTERN = Pattern.compile("https?://[a-zA-Z0-9./?=&%_\\-]+"); + + public static List extractAndCleanUrls(String text) { + if (text == null) return List.of(); + List urls = new ArrayList<>(); + Matcher matcher = URL_PATTERN.matcher(text); + while (matcher.find()) { + urls.add(cleanTrackingParams(matcher.group())); + } + return urls.stream().distinct().collect(Collectors.toList()); + } + + private static String cleanTrackingParams(String url) { + int qIndex = url.indexOf("?"); + return qIndex > 0 ? url.substring(0, qIndex) : url; + } + + public static String trimNoise(String body) { + if (body == null) return ""; + String[] markers = {"View similar jobs", "Unsubscribe", "©", "Help Center", "References"}; + for (String marker : markers) { + int index = body.indexOf(marker); + if (index > 0) body = body.substring(0, index); + } + return body.length() > 5000 ? body.substring(0, 5000) : body; + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index d591d8c..29c95d1 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -35,7 +35,7 @@ spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/o # config app.allowed.cors=http://localhost:4200 app.allowed.methods=GET,POST,PUT,DELETE,OPTIONS -app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email +app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push # JWT Secret (Must be long and secure) app.jwt.secret=${JWT_SECRET} @@ -55,6 +55,12 @@ spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.scope=email,profile +app.google.pubsub-topic=${GOOGLE_PUBSUB_TOPIC} + +# Webhook Security +app.security.webhook-audience=https://${NGROK_TUNNEL:localhost:8080}/api/webhooks/gmail/push +app.security.google-pubsub-service-account=${GOOGLE_PUBSUB_SERVICE_ACCOUNT} + # GITHUB OAuth2 spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID} spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET} diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 5b05816..563e1d0 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -14,6 +14,12 @@ 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 + app.storage.type=local # JWT (Only for dev) @@ -28,7 +34,7 @@ app.jwt.refresh-cookie-same-site=Lax # config app.allowed.cors=http://localhost:4200 app.allowed.methods=GET,POST,PUT,DELETE,OPTIONS -app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/storage/files/** +app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push,/api/storage/files/** # Hibernate spring.jpa.hibernate.ddl-auto=update @@ -40,10 +46,6 @@ app.ui.url=http://localhost:4200 # Base url app.base-url=http://localhost:8080 -spring.security.oauth2.client.registration.github.redirect-uri={baseUrl}/login/oauth2/code/github -spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/google - - # File Upload Limits spring.servlet.multipart.max-file-size=5MB spring.servlet.multipart.max-request-size=5MB @@ -61,6 +63,18 @@ cloudflare.r2.public-url.resources=http://localhost:8080/api/storage/files spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.scope=email,profile +spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/google + +# configure Google Pub/Sub for Gmail push notifications open https://console.cloud.google.com/apis/library/pubsub.googleapis.com +# create a topic named gmail-notifications in the project job-tracker-pro and use the topic name below +# Set up a subscription for the topic with the push endpoint as https://{NGROK_TUNNEL}/api/webhooks/gmail/push and use the service account +app.google.pubsub-topic=${GOOGLE_PUBSUB_TOPIC:projects/job-tracker-pro/topics/gmail-notifications} + +# Webhook Security +# For local development, you can use ngrok to create a secure tunnel to your localhost. Run ngrok with the command ngrok http 8080 and it will provide you with a public URL (e.g., https://petrolic-jennie-hungrily.ngrok-free.dev) that tunnels to your local server. Use this URL as the webhook audience and in your Google Pub/Sub subscription. +app.security.webhook-audience=https://${NGROK_TUNNEL:localhost:8080}/api/webhooks/gmail/push +# The service account should have the role Pub/Sub Subscriber and Pub/Sub Invoker. You can create a service account in the Google Cloud Console, generate a key for it, and use the email address of the service account below. +app.security.google-pubsub-service-account=${GOOGLE_PUBSUB_SERVICE_ACCOUNT:job-tracker-webhook-invoker@gserviceaccount.com} # GITHUB OAuth2 open github.com/settings/applications/new # Set Application Name as JobTrackerPro Local @@ -71,6 +85,7 @@ spring.security.oauth2.client.registration.google.scope=email,profile spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID} spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET} spring.security.oauth2.client.registration.github.scope=read:user,user:email +spring.security.oauth2.client.registration.github.redirect-uri={baseUrl}/login/oauth2/code/github # Email Sender Details email.sender_email=${EMAIL_SENDER:noreply@jobtrackerpro.local} diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties index e29fe42..f94fd31 100644 --- a/backend/src/main/resources/application-prod.properties +++ b/backend/src/main/resources/application-prod.properties @@ -36,7 +36,7 @@ spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/o # config app.allowed.cors=https://thughari.github.io app.allowed.methods=GET,POST,PUT,DELETE,OPTIONS -app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email +app.public.endpoints=/api/auth/signup,/api/auth/login,/api/auth/refresh,/api/auth/logout,/api/auth/forgot-password,/api/auth/verify-email,/api/auth/resend-verification,/api/auth/reset-password,/oauth2/**,/api/webhooks/inbound-email,/api/webhooks/gmail/push server.forward-headers-strategy=framework @@ -58,6 +58,12 @@ spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.scope=email,profile +app.google.pubsub-topic=${GOOGLE_PUBSUB_TOPIC} + +# Webhook Security +app.security.webhook-audience=${app.base-url}/api/webhooks/gmail/push +app.security.google-pubsub-service-account=${GOOGLE_PUBSUB_SERVICE_ACCOUNT} + # GITHUB OAuth2 spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID} spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET} diff --git a/backend/src/test/java/com/thughari/jobtrackerpro/controller/AuthControllerTest.java b/backend/src/test/java/com/thughari/jobtrackerpro/controller/AuthControllerTest.java index b5082d8..a12f7c8 100644 --- a/backend/src/test/java/com/thughari/jobtrackerpro/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/thughari/jobtrackerpro/controller/AuthControllerTest.java @@ -1,102 +1,112 @@ package com.thughari.jobtrackerpro.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import com.thughari.jobtrackerpro.dto.AuthRequest; import com.thughari.jobtrackerpro.dto.AuthTokens; -import com.thughari.jobtrackerpro.dto.ChangePasswordRequest; import com.thughari.jobtrackerpro.dto.UserProfileResponse; +import com.thughari.jobtrackerpro.exception.GlobalExceptionHandler; import com.thughari.jobtrackerpro.service.AuthService; -import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatusCode; -import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @ExtendWith(MockitoExtension.class) class AuthControllerTest { - @Mock - private AuthService authService; + private MockMvc mockMvc; @Mock - private HttpServletResponse httpServletResponse; + private AuthService authService; @InjectMocks private AuthController authController; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(authController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + @AfterEach void cleanUp() { SecurityContextHolder.clearContext(); } @Test - void registerUser_returnsOkWhenServiceSucceeds() { - ReflectionTestUtils.setField(authController, "refreshExpirationMs", 1000L); - ReflectionTestUtils.setField(authController, "refreshCookieSecure", false); - ReflectionTestUtils.setField(authController, "refreshCookieSameSite", "Lax"); - + void registerUser_returnsOkWhenServiceSucceeds() throws Exception { AuthRequest request = new AuthRequest(); - when(authService.registerUser(request)).thenReturn(new AuthTokens("token", "refresh")); + request.setEmail("test@example.com"); + request.setName("Hari"); + request.setPassword("password123"); - var result = authController.registerUser(request, httpServletResponse); + mockMvc.perform(post("/api/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("verify"))); - assertEquals(HttpStatusCode.valueOf(200), result.getStatusCode()); + verify(authService).registerUser(any(AuthRequest.class)); } @Test - void loginUser_returnsBadRequestOnIllegalArgument() { + void loginUser_returnsUnauthorized_WhenEmailNotVerified() throws Exception { AuthRequest request = new AuthRequest(); - when(authService.loginUser(request)).thenThrow(new IllegalArgumentException("bad creds")); + request.setEmail("unverified@example.com"); - var result = authController.loginUser(request, httpServletResponse); + when(authService.loginUser(any(AuthRequest.class))) + .thenThrow(new IllegalStateException("verify email")); - assertEquals(HttpStatusCode.valueOf(400), result.getStatusCode()); - assertEquals("bad creds", result.getBody()); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("verify email")); } @Test - void getCurrentUser_readsEmailFromSecurityContext() { - SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("USER@EXAMPLE.COM", null)); + void verifyEmail_returnsOkAndSetsCookie() throws Exception { + AuthTokens mockTokens = new AuthTokens("access-token", "refresh-token"); + + when(authService.verifyUser("some-token")).thenReturn(mockTokens); - UserProfileResponse profile = new UserProfileResponse(); - profile.setEmail("user@example.com"); - when(authService.getCurrentUser("user@example.com")).thenReturn(profile); + mockMvc.perform(get("/api/auth/verify-email").param("token", "some-token")) + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(jsonPath("$.token").value("access-token")); - var result = authController.getCurrentUser(); - - assertEquals(HttpStatusCode.valueOf(200), result.getStatusCode()); - assertEquals("user@example.com", result.getBody().getEmail()); + verify(authService).verifyUser("some-token"); } @Test - void changePassword_returnsBadRequestOnValidationError() { - SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("user@example.com", null)); - - ChangePasswordRequest request = new ChangePasswordRequest(); - doThrow(new IllegalArgumentException("Incorrect current password")) - .when(authService).changePassword("user@example.com", request); - - var result = authController.changePassword(request); - - assertEquals(HttpStatusCode.valueOf(400), result.getStatusCode()); - assertEquals("Incorrect current password", result.getBody()); - } + void getCurrentUser_readsEmailFromSecurityContext() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("user@example.com", null) + ); - @Test - void forgotPassword_alwaysReturnsOk() { - var result = authController.forgotPassword("missing@example.com"); + UserProfileResponse profile = new UserProfileResponse(); + profile.setEmail("user@example.com"); + when(authService.getCurrentUser("user@example.com")).thenReturn(profile); - assertEquals(HttpStatusCode.valueOf(200), result.getStatusCode()); - verify(authService).forgotPassword("missing@example.com"); + mockMvc.perform(get("/api/auth/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("user@example.com")); } -} +} \ No newline at end of file diff --git a/backend/src/test/java/com/thughari/jobtrackerpro/scheduler/JobSchedulerTest.java b/backend/src/test/java/com/thughari/jobtrackerpro/scheduler/JobSchedulerTest.java index c70fbcc..eebc11c 100644 --- a/backend/src/test/java/com/thughari/jobtrackerpro/scheduler/JobSchedulerTest.java +++ b/backend/src/test/java/com/thughari/jobtrackerpro/scheduler/JobSchedulerTest.java @@ -1,30 +1,92 @@ package com.thughari.jobtrackerpro.scheduler; +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.repo.UserRepository; +import com.thughari.jobtrackerpro.service.GmailIntegrationService; import com.thughari.jobtrackerpro.service.JobService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; import static org.mockito.Mockito.*; +@ExtendWith(MockitoExtension.class) class JobSchedulerTest { + @Mock private JobService jobService; + @Mock private UserRepository userRepository; + @Mock private GmailIntegrationService gmailIntegrationService; + + @InjectMocks + private JobScheduler scheduler; + @Test - void runStaleJobCleanupInvokesService() { - JobService jobService = mock(JobService.class); - JobScheduler scheduler = new JobScheduler(jobService); + void runStaleJobCleanup_InvokesService() { + scheduler.runStaleJobCleanup(); + verify(jobService, times(1)).cleanupStaleApplications(); + } + @Test + void runStaleJobCleanup_HandlesServiceException() { + // Verification that an exception in the service doesn't propagate and crash the scheduler thread + doThrow(new RuntimeException("DB Timeout")).when(jobService).cleanupStaleApplications(); + scheduler.runStaleJobCleanup(); verify(jobService).cleanupStaleApplications(); } @Test - void runStaleJobCleanupHandlesServiceException() { - JobService jobService = mock(JobService.class); - doThrow(new RuntimeException("boom")).when(jobService).cleanupStaleApplications(); - JobScheduler scheduler = new JobScheduler(jobService); + void renewGmailWatches_ProcessesAllConnectedUsers() { + // Setup: Mocking connected users + User user1 = new User(); + user1.setEmail("user1@test.com"); + User user2 = new User(); + user2.setEmail("user2@test.com"); - scheduler.runStaleJobCleanup(); + when(userRepository.findByGmailConnectedTrue()).thenReturn(List.of(user1, user2)); - verify(jobService).cleanupStaleApplications(); + // Act + scheduler.renewGmailWatches(); + + // Assert: High Performance check + // Verify that the integration service was called for every user returned by the repo + verify(gmailIntegrationService, times(1)).renewWatch(user1); + verify(gmailIntegrationService, times(1)).renewWatch(user2); + } + + @Test + void renewGmailWatches_HandlesPartialFailures() { + // Setup: One user succeeds, one fails + User user1 = new User(); + user1.setEmail("fail@test.com"); + User user2 = new User(); + user2.setEmail("success@test.com"); + + when(userRepository.findByGmailConnectedTrue()).thenReturn(List.of(user1, user2)); + + // Mocking an error for the first user + doThrow(new RuntimeException("Token Revoked")).when(gmailIntegrationService).renewWatch(user1); + + // Act + scheduler.renewGmailWatches(); + + // Assert: Robustness check + // Even though user1 failed, user2 MUST still be processed (Fault Tolerance) + verify(gmailIntegrationService).renewWatch(user1); + verify(gmailIntegrationService).renewWatch(user2); + } + + @Test + void renewGmailWatches_SkipsIfNoUsersConnected() { + when(userRepository.findByGmailConnectedTrue()).thenReturn(List.of()); + + scheduler.renewGmailWatches(); + + verify(gmailIntegrationService, never()).renewWatch(any()); } -} +} \ No newline at end of file diff --git a/backend/src/test/java/com/thughari/jobtrackerpro/service/AuthServiceTest.java b/backend/src/test/java/com/thughari/jobtrackerpro/service/AuthServiceTest.java index fa91f18..9028e74 100644 --- a/backend/src/test/java/com/thughari/jobtrackerpro/service/AuthServiceTest.java +++ b/backend/src/test/java/com/thughari/jobtrackerpro/service/AuthServiceTest.java @@ -5,16 +5,18 @@ import com.thughari.jobtrackerpro.entity.AuthProvider; import com.thughari.jobtrackerpro.entity.PasswordResetToken; import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.entity.VerificationToken; import com.thughari.jobtrackerpro.exception.ResourceNotFoundException; -import com.thughari.jobtrackerpro.interfaces.StorageService; import com.thughari.jobtrackerpro.repo.PasswordResetTokenRepository; import com.thughari.jobtrackerpro.repo.UserRepository; +import com.thughari.jobtrackerpro.repo.VerificationTokenRepository; import com.thughari.jobtrackerpro.security.JwtUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.CacheManager; import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDateTime; @@ -30,15 +32,16 @@ class AuthServiceTest { @Mock private UserRepository userRepository; @Mock private PasswordEncoder passwordEncoder; @Mock private JwtUtils jwtUtils; - @Mock private StorageService storageService; + @Mock private VerificationTokenRepository verificationTokenRepository; // Added @Mock private PasswordResetTokenRepository tokenRepository; @Mock private EmailService emailService; + @Mock private CacheManager cacheManager; // Added @InjectMocks private AuthService authService; @Test - void registerUser_createsUserAndReturnsToken() { + void registerUser_createsDisabledUserAndSendsEmail() { AuthRequest request = new AuthRequest(); request.setName("Test User"); request.setEmail("test@example.com"); @@ -46,29 +49,48 @@ void registerUser_createsUserAndReturnsToken() { when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.empty()); when(passwordEncoder.encode("secret")).thenReturn("encoded"); - when(jwtUtils.generateAccessToken("test@example.com")).thenReturn("jwt"); - when(jwtUtils.generateRefreshToken("test@example.com")).thenReturn("refresh-jwt"); - var response = authService.registerUser(request); + // Act - No variable assignment because return is void + authService.registerUser(request); - assertEquals("jwt", response.accessToken()); - assertEquals("refresh-jwt", response.refreshToken()); - verify(userRepository).save(any(User.class)); + // Assert - Verify interactions for high performance / atomic flow + verify(userRepository).saveAndFlush(any(User.class)); + verify(verificationTokenRepository).save(any(VerificationToken.class)); + verify(emailService).sendVerificationEmail(eq("test@example.com"), any(String.class)); } @Test - void loginUser_throwsWhenPasswordMismatch() { + void loginUser_throwsWhenUserNotEnabled() { User user = new User(); user.setEmail("test@example.com"); - user.setPassword("encoded"); + user.setEnabled(false); // User exists but not verified when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("wrong", "encoded")).thenReturn(false); AuthRequest request = new AuthRequest(); request.setEmail("test@example.com"); - request.setPassword("wrong"); + request.setPassword("password"); - assertThrows(IllegalArgumentException.class, () -> authService.loginUser(request)); + assertThrows(IllegalStateException.class, () -> authService.loginUser(request)); + } + + @Test + void verifyEmail_enablesUserAndClearsToken() { + User user = new User(); + user.setEmail("test@example.com"); + user.setEnabled(false); + + VerificationToken token = new VerificationToken(); + token.setToken("valid-token"); + token.setUser(user); + token.setExpiryDate(LocalDateTime.now().plusHours(1)); + + when(verificationTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(token)); + + authService.verifyUser("valid-token"); + + assertTrue(user.getEnabled()); + verify(userRepository).saveAndFlush(user); + verify(verificationTokenRepository).delete(token); } @Test @@ -84,12 +106,6 @@ void forgotPassword_createsTokenAndSendsEmail() { verify(emailService).sendResetEmail(eq("test@example.com"), any(String.class)); } - @Test - void forgotPassword_throwsWhenMissingUser() { - when(userRepository.findByEmail("missing@example.com")).thenReturn(Optional.empty()); - assertThrows(ResourceNotFoundException.class, () -> authService.forgotPassword("missing@example.com")); - } - @Test void resetPassword_rejectsExpiredToken() { PasswordResetToken token = new PasswordResetToken(); @@ -101,26 +117,4 @@ void resetPassword_rejectsExpiredToken() { assertThrows(IllegalArgumentException.class, () -> authService.resetPassword("abc", "newpassword")); verify(tokenRepository).delete(token); } - - @Test - void changePassword_updatesWhenCurrentMatches() { - User user = new User(); - user.setEmail("test@example.com"); - user.setPassword("oldEncoded"); - user.setProvider(AuthProvider.LOCAL); - - ChangePasswordRequest request = new ChangePasswordRequest(); - request.setCurrentPassword("old"); - request.setNewPassword("newpass"); - - when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("old", "oldEncoded")).thenReturn(true); - when(passwordEncoder.matches("newpass", "oldEncoded")).thenReturn(false); - when(passwordEncoder.encode("newpass")).thenReturn("newEncoded"); - - authService.changePassword("test@example.com", request); - - verify(userRepository).save(user); - assertEquals("newEncoded", user.getPassword()); - } -} +} \ No newline at end of file diff --git a/backend/src/test/java/com/thughari/jobtrackerpro/service/CareerResourceServiceTest.java b/backend/src/test/java/com/thughari/jobtrackerpro/service/CareerResourceServiceTest.java index 674bb77..41fe11e 100644 --- a/backend/src/test/java/com/thughari/jobtrackerpro/service/CareerResourceServiceTest.java +++ b/backend/src/test/java/com/thughari/jobtrackerpro/service/CareerResourceServiceTest.java @@ -9,11 +9,13 @@ import com.thughari.jobtrackerpro.repo.UserRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -37,7 +39,7 @@ class CareerResourceServiceTest { @Test void getResourcePageSanitizesInput() { - when(resourceRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + when(resourceRepository.findAll(ArgumentMatchers.>any(), any(Pageable.class))) .thenAnswer(invocation -> { Pageable pageable = invocation.getArgument(1); return new PageImpl<>(List.of(), pageable, 0); diff --git a/backend/src/test/java/com/thughari/jobtrackerpro/service/JobServiceTest.java b/backend/src/test/java/com/thughari/jobtrackerpro/service/JobServiceTest.java index 50185cf..4ee1745 100644 --- a/backend/src/test/java/com/thughari/jobtrackerpro/service/JobServiceTest.java +++ b/backend/src/test/java/com/thughari/jobtrackerpro/service/JobServiceTest.java @@ -119,11 +119,11 @@ void updateJob_throwsWhenUnauthorized() { @Test void createOrUpdateJob_updatesBestActiveMatch() { - Job matchA = baseJob("Acme", "Backend Engineer", "Applied", 2); + Job matchA = baseJob("Acme", "Backend Engineer", "Applied", 1); matchA.setNotes("Existing note"); matchA.setUrl("https://existing.example.com"); - Job matchB = baseJob("Acme Corp", "Data Analyst", "Applied", 2); + Job matchB = baseJob("Acme Corp", "Data Analyst", "Applied", 1); when(jobRepository.findByUserEmailOrderByUpdatedAtDesc(EMAIL)).thenReturn(List.of(matchA, matchB)); @@ -139,12 +139,15 @@ void createOrUpdateJob_updatesBestActiveMatch() { jobService.createOrUpdateJob(incoming, EMAIL); ArgumentCaptor captor = ArgumentCaptor.forClass(Job.class); - verify(jobRepository).save(captor.capture()); + + verify(jobRepository).saveAndFlush(captor.capture()); + Job saved = captor.getValue(); assertEquals("Interview Scheduled", saved.getStatus()); assertEquals(3, saved.getStage()); assertTrue(saved.getNotes().contains("Recruiter email update")); + assertEquals("https://existing.example.com", saved.getUrl()); } diff --git a/backend/src/test/java/com/thughari/jobtrackerpro/service/mock/MockGeminiServiceTest.java b/backend/src/test/java/com/thughari/jobtrackerpro/service/mock/MockGeminiServiceTest.java index bbbbda6..11848d3 100644 --- a/backend/src/test/java/com/thughari/jobtrackerpro/service/mock/MockGeminiServiceTest.java +++ b/backend/src/test/java/com/thughari/jobtrackerpro/service/mock/MockGeminiServiceTest.java @@ -6,15 +6,15 @@ class MockGeminiServiceTest { - @Test - void buildsMockJobFromEmailAndSubject() { - MockGeminiService service = new MockGeminiService(); + @Test + void buildsMockJobFromEmailAndSubject() { + MockGeminiService service = new MockGeminiService(); - var result = service.extractJobFromEmail("hr@acme.com", "Backend Engineer", "Body"); + var result = service.extractJobFromEmail("hr@acme.com", "Backend Engineer", "Body"); - assertEquals("acme", result.getCompany()); - assertEquals("Backend Engineer", result.getRole()); - assertEquals("Applied", result.getStatus()); - assertNotNull(result.getAppliedDate()); - } + assertEquals("Acme", result.getCompany()); + assertEquals("Backend Engineer", result.getRole()); + assertEquals("Applied", result.getStatus()); + assertNotNull(result.getAppliedDate()); + } } diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index bd7d7dc..2280131 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -22,6 +22,10 @@ app.jwt.refresh-cookie-same-site=Lax app.ui.url=http://localhost:4200 app.base-url=http://localhost:8080 +app.security.webhook-audience=${app.base-url}/api/webhooks/gmail/push +app.security.google-pubsub-service-account=test-account@job-tracker-pro.iam.gserviceaccount.com + +app.google.pubsub-topic=projects/job-tracker-pro/topics/gmail-notifications spring.security.oauth2.client.registration.google.client-id=test-google-id spring.security.oauth2.client.registration.google.client-secret=test-google-secret diff --git a/frontend/LICENSE b/frontend/LICENSE index 0de43a9..6b81b80 100644 --- a/frontend/LICENSE +++ b/frontend/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Hari Thatikonda +Copyright (c) 2026 Hari Thatikonda Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/frontend/angular.json b/frontend/angular.json index deb8484..d2c77d5 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -70,6 +70,10 @@ "buildTarget": "jobtrackerpro:build:production" }, "development": { + "headers": { + "Cross-Origin-Opener-Policy": "unsafe-none", + "Cross-Origin-Embedder-Policy": "unsafe-none" + }, "buildTarget": "jobtrackerpro:build:development" } }, diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 3b9c73b..3d2e1fc 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,5 +1,5 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { routes } from './app.routes'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; @@ -8,7 +8,7 @@ import { authInterceptor } from './core/interceptors/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes), + provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })), provideHttpClient(withInterceptors([authInterceptor])), ], }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index b7d1bca..63bc07f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -14,6 +14,7 @@ import { ResetPasswordComponent } from './components/auth/reset-password/reset-p import { PrivacyComponent } from './components/privacy/privacy.component'; import { ResourcesComponent } from './components/resources/resources.component'; import { TocComponent } from './components/toc/toc.component'; +import { VerifyComponent } from './components/auth/verify/verify.component'; export const routes: Routes = [ { @@ -60,6 +61,11 @@ export const routes: Routes = [ component: LoginSuccessComponent, canActivate: [guestGuard] }, + { + path: 'verify-email', + component: VerifyComponent, + title: 'Verify Account | JobTrackerPro' + }, { path: 'app', 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 1167b8f..d3a64fd 100644 --- a/frontend/src/app/components/application-list/application-list.component.html +++ b/frontend/src/app/components/application-list/application-list.component.html @@ -1,12 +1,36 @@
-
+ @if (successMessage()) { +
+
+ +

{{ successMessage() }}

+
+
+ } + +
-

All Applications +

+ All Applications

-

Track and manage {{ totalElements() }} - applications

+

+ Managing {{ totalElements() }} active tracking entries +

+ + @if (authService.userProfile()?.gmailConnected) { + + }
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 d60cec7..2bd209b 100644 --- a/frontend/src/app/components/application-list/application-list.component.ts +++ b/frontend/src/app/components/application-list/application-list.component.ts @@ -12,9 +12,11 @@ import { Job, JobService } from '../../services/job.service'; import { debounceTime, distinctUntilChanged, + firstValueFrom, Subject, Subscription, } from 'rxjs'; +import { AuthService } from '../../services/auth.service'; type SortField = 'company' | 'role' | 'date' | 'status' | 'location'; type SortDirection = 'asc' | 'desc'; @@ -28,6 +30,7 @@ type SortDirection = 'asc' | 'desc'; }) export class ApplicationListComponent implements OnInit, OnDestroy { private jobService = inject(JobService); + public authService = inject(AuthService); searchQuery = signal(''); statusFilter = signal('All Statuses'); @@ -35,6 +38,10 @@ export class ApplicationListComponent implements OnInit, OnDestroy { sortDirection = signal('desc'); currentPage = signal(0); pageSize = signal(8); + isSyncing = signal(false); + + successMessage = signal(''); + errorMessage = signal(''); activeMenuId = signal(null); @@ -60,10 +67,36 @@ export class ApplicationListComponent implements OnInit, OnDestroy { this.currentPage.set(0); this.searchQuery.set(val); }); + + this.jobService.startAutoRefresh(); } ngOnDestroy() { this.searchSubscription?.unsubscribe(); + this.jobService.stopAutoRefresh(); + } + + async onGmailSync() { + if (this.isSyncing() || !this.authService.userProfile()?.gmailConnected) return; + + this.isSyncing.set(true); + try { + await firstValueFrom(this.authService.syncGmail()); + this.showMessage('success', 'Gmail sync started in background.'); + setTimeout(() => this.isSyncing.set(false), 30000); + } catch (err) { + this.showMessage('error', 'Failed to start sync.'); + } finally { + this.isSyncing.set(false); + } + } + + showMessage(type: 'success' | 'error', text: string) { + if (type === 'success') this.successMessage.set(text); + else this.errorMessage.set(text); + setTimeout(() => { this.successMessage.set(''); + this.errorMessage.set(''); + }, 5000); } onSearchInput(event: Event) { diff --git a/frontend/src/app/components/auth/signup/signup.component.html b/frontend/src/app/components/auth/signup/signup.component.html index 0cfad20..54710b3 100644 --- a/frontend/src/app/components/auth/signup/signup.component.html +++ b/frontend/src/app/components/auth/signup/signup.component.html @@ -4,11 +4,10 @@
- - - + +
@@ -17,105 +16,103 @@
-
-
-

Create account

-

Enter your details to get started

-
+
+ + +
- @if (errorMessage()) { -
- - {{ errorMessage() }} - -
- } + @if (!signupSuccess()) { + +
+
+

Create account

+

Enter your details to get started

+
-
- - -
+ @if (errorMessage()) { +
+ + {{ errorMessage() }} + +
+ } -
-
-
-
-
- Or continue with email -
-
+ +
+ + +
-
-
- - -
+
+
+
Or continue with email
+
-
- - -
+ +
+ + +
-
- -
- - - -
+
+ + +
-
-
-
-
-
-
+
+ +
+ + +
+ +
+
+
+
+
+
+
+
- - {{ getStrengthLabel() }} - + + + + +
+ Already have an account? Login now
+ } @else { + +
+
+

Sent to

+

{{ signUpUser.email }}

+
- - +
+ -
- Already have an account? Login now -
+ +
+
+ }
diff --git a/frontend/src/app/components/auth/signup/signup.component.ts b/frontend/src/app/components/auth/signup/signup.component.ts index f32060d..7046b68 100644 --- a/frontend/src/app/components/auth/signup/signup.component.ts +++ b/frontend/src/app/components/auth/signup/signup.component.ts @@ -5,6 +5,7 @@ import { RouterLink } from '@angular/router'; import { CommonModule } from '@angular/common'; import { environment } from '../../../../environments/environment'; import { LogoComponent } from '../../ui/logo/logo.component'; +import { firstValueFrom } from 'rxjs'; export interface SignUpUser { email: string; @@ -23,32 +24,30 @@ export class SignupComponent { private readonly API = environment.apiBaseUrl; authService = inject(AuthService); - signUpUser: SignUpUser = { - email: '', - password: '', - name: '', - }; + signUpUser: SignUpUser = { email: '', password: '', name: '' }; - errorMessage = signal(''); - isLoading = signal(false); + // --- UI SIGNALS --- + isLoading = signal(false); + signupSuccess = signal(false); + isResending = signal(false); + resendCooldown = signal(0); + + errorMessage = signal(''); + successMessage = signal(''); + private messageTimeout: any; showPassword = signal(false); passwordStrength = signal(0); + // --- PASSWORD LOGIC --- onPasswordInput() { let score = 0; const p = this.signUpUser.password; - - if (!p) { - this.passwordStrength.set(0); - return; - } - + if (!p) { this.passwordStrength.set(0); return; } if (p.length >= 8) score++; if (/[A-Z]/.test(p)) score++; if (/[0-9]/.test(p)) score++; if (/[^A-Za-z0-9]/.test(p)) score++; - this.passwordStrength.set(score); } @@ -70,12 +69,7 @@ export class SignupComponent { async onSubmit() { if (!this.signUpUser.name || !this.signUpUser.email || !this.signUpUser.password) { - this.errorMessage.set('Please fill in all fields'); - return; - } - - if (this.signUpUser.password.length < 6) { - this.errorMessage.set('Password must be at least 6 characters'); + this.showMessage('error', 'Please fill in all fields'); return; } @@ -83,19 +77,49 @@ export class SignupComponent { this.errorMessage.set(''); try { - await this.authService.signup(this.signUpUser); + const response: any = await firstValueFrom(this.authService.signup(this.signUpUser)); + this.successMessage.set(response.message || 'Check your email to verify your account.'); + this.signupSuccess.set(true); } catch (err: any) { + const msg = err.error?.message || err.error || 'Signup failed.'; + this.showMessage('error', msg); + } finally { this.isLoading.set(false); - if (err.error && err.error.message) { - this.errorMessage.set(err.error.message); - } else { - this.errorMessage.set('Signup failed. Please try again.'); - } } } + async resendEmail() { + if (this.resendCooldown() > 0 || this.isResending()) return; + + this.isResending.set(true); + try { + await firstValueFrom(this.authService.resendVerificationEmail(this.signUpUser.email)); + this.showMessage('success', 'New verification link sent!'); + + this.resendCooldown.set(60); + const interval = setInterval(() => { + this.resendCooldown.update(v => v - 1); + if (this.resendCooldown() <= 0) clearInterval(interval); + }, 1000); + } catch (err) { + this.showMessage('error', 'Failed to resend. Please try again later.'); + } finally { + this.isResending.set(false); + } + } + + showMessage(type: 'success' | 'error', message: string) { + this.clearMessages(); + if (type === 'success') this.successMessage.set(message); + else this.errorMessage.set(message); + + this.messageTimeout = setTimeout(() => this.clearMessages(), 5000); + } + clearMessages() { this.errorMessage.set(''); + if (!this.signupSuccess()) this.successMessage.set(''); + if (this.messageTimeout) clearTimeout(this.messageTimeout); } socialSignUp(provider: string) { diff --git a/frontend/src/app/components/auth/verify/verify.component.css b/frontend/src/app/components/auth/verify/verify.component.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/auth/verify/verify.component.html b/frontend/src/app/components/auth/verify/verify.component.html new file mode 100644 index 0000000..e06a031 --- /dev/null +++ b/frontend/src/app/components/auth/verify/verify.component.html @@ -0,0 +1,55 @@ +
+
+ +
+ + @if (status() === 'loading') { +
+
+

Verifying your account...

+
+ } + + @if (status() === 'success') { +
+
+ +
+ +
+
+ +
+

Identity Verified

+

Your account is now active. You can start automating your job hunt immediately.

+
+ + + Proceed to Login + +
+ } + + @if (status() === 'error') { +
+
+ +
+ +
+

Link Expired

+

{{ errorMessage() }}

+
+ + +
+ } +
+
\ No newline at end of file diff --git a/frontend/src/app/components/auth/verify/verify.component.spec.ts b/frontend/src/app/components/auth/verify/verify.component.spec.ts new file mode 100644 index 0000000..f60f8e0 --- /dev/null +++ b/frontend/src/app/components/auth/verify/verify.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VerifyComponent } from './verify.component'; + +describe('VerifyComponent', () => { + let component: VerifyComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VerifyComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(VerifyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/auth/verify/verify.component.ts b/frontend/src/app/components/auth/verify/verify.component.ts new file mode 100644 index 0000000..0db64a8 --- /dev/null +++ b/frontend/src/app/components/auth/verify/verify.component.ts @@ -0,0 +1,57 @@ +import { Component, OnInit, signal, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterLink, Router } from '@angular/router'; // Added Router +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { AuthService } from '../../../services/auth.service'; // Added AuthService + +export interface AuthResponse { + token: string; +} + +@Component({ + selector: 'app-verify', + standalone: true, + imports: [CommonModule, RouterLink], + templateUrl: './verify.component.html', + styleUrl: './verify.component.css' +}) +export class VerifyComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private http = inject(HttpClient); + private authService = inject(AuthService); + + status = signal<'loading' | 'success' | 'error'>('loading'); + errorMessage = signal(''); + + async ngOnInit() { + const token = this.route.snapshot.queryParamMap.get('token'); + + if (!token) { + this.status.set('error'); + this.errorMessage.set('Invalid or missing verification link.'); + return; + } + + try { + const response = await firstValueFrom( + this.http.get(`${environment.apiBaseUrl}/api/auth/verify-email?token=${token}`) + ); + + this.authService.setAccessToken(response.token); + + this.status.set('success'); + + setTimeout(() => { + this.router.navigate(['/app/dashboard']); + this.authService.fetchUserProfile(); + }, 2000); + + } catch (err: any) { + this.status.set('error'); + this.errorMessage.set(err.error?.message || err.error || 'Verification failed. The link may be expired.'); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/dashboard/dashboard.component.html b/frontend/src/app/components/dashboard/dashboard.component.html index 3a99a13..97b0f5c 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.html +++ b/frontend/src/app/components/dashboard/dashboard.component.html @@ -18,26 +18,28 @@

Success

-
-
-

Overview

-

Track your job search progress at a glance

-
- -
- - - -
+
+
+

+ All Applications +

+

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

+ @if (authService.userProfile()?.gmailConnected) { + + } +
+ @if (stats().totalApplications === 0) {
@@ -164,6 +166,7 @@

+ (onMessage)="handleModalMessage($event)" + (onConnect)="connectGmail()"> } \ 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 70372db..75b60e0 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.ts +++ b/frontend/src/app/components/dashboard/dashboard.component.ts @@ -1,10 +1,12 @@ -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Component, computed, EventEmitter, inject, OnDestroy, OnInit, Output, signal } from '@angular/core'; import { ThemeService } from '../../services/theme.service'; 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'; @Component({ selector: 'app-dashboard', @@ -18,17 +20,25 @@ import { GmailSetupModalComponent } from '../gmail-setup-modal/gmail-setup-modal templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.css', }) -export class DashboardComponent implements OnInit { +export class DashboardComponent implements OnInit, OnDestroy { + + public authService = inject(AuthService); private jobService = inject(JobService); private themeService = inject(ThemeService); isRefreshing = signal(false); showHelpModal = signal(false); + isSyncing = signal(false); + + isGmailConnected = computed(() => !!this.authService.userProfile()?.gmailConnected); successMessage = signal(''); errorMessage = signal(''); private messageTimeout: any; + @Output() onConnect = new EventEmitter(); + @Output() onClose = new EventEmitter(); + stats = this.jobService.dashboardStats; statusData = this.jobService.statusDistribution; monthlyData = this.jobService.monthlyApplications; @@ -36,6 +46,11 @@ export class DashboardComponent implements OnInit { ngOnInit() { this.jobService.loadDashboard(); + this.jobService.startAutoRefresh(); + } + + ngOnDestroy() { + this.jobService.stopAutoRefresh(); } async onRefresh() { @@ -47,6 +62,23 @@ export class DashboardComponent implements OnInit { this.isRefreshing.set(false); } + async onGmailSync() { + if (this.isSyncing() || !this.authService.userProfile()?.gmailConnected) return; + + this.isSyncing.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); + + } catch (err) { + this.showMessage('error', 'Sync failed. Please check your Gmail connection.'); + } finally { + this.isSyncing.set(false); + } + } + handleModalMessage(event: { type: 'success' | 'error'; text: string }) { this.showMessage(event.type, event.text); } @@ -126,4 +158,9 @@ export class DashboardComponent implements OnInit { }); interviewColors = ['#10b981', '#d1d5db']; + + connectGmail() { + this.onConnect.emit(); + this.onClose.emit(); + } } diff --git a/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.html b/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.html index db4331d..654b014 100644 --- a/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.html +++ b/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.html @@ -1,181 +1,329 @@ @if (isVisible) { -
-
- -
- - -
-
- -

Gmail Sync Setup

-
- +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
- -
- @for (step of [1,2,3,4]; track step) { -
-
- } +
+

+ Automation Setup +

+

+ Connect Gmail or configure forwarding +

-
- - - @if (isMobile && activeStep() < 4) { -
- -

- Forwarding setup is best on Desktop. Gmail's mobile app doesn't support filter creation. -

-
- } - - -
- - - - @if (activeStep() === 1) { -
-

- Open settings and click "Add a forwarding address". -

- - - -
-

Address to paste

- {{ inboundEmail }} -
- - -
- } -
+
- -
- - - - @if (activeStep() === 2) { -
-
- -

- Gmail will send a verification link. We receive it automatically and forward it to your inbox. -Just open the email and click the link to confirm. -

-
- -
- } -
+ + +
+ + +
+ +
+ +
+ +
- -
- - - - @if (activeStep() === 3) { -
-

Only job emails are forwarded. Your private mail stays private.

- -
-
- 1 -

Click the button below to open Gmail search.

-
-
- 2 -

In the dropdown, click "Create filter".

-
-
- 3 -

Check "Forward it to" and select our address.

-
- - -
- - -
- } + + Recommended + + +
+

+ Automatic Gmail Sync +

+ +

+ Our AI automatically scans your inbox for job updates. + No filters. No setup. Just connect Gmail. +

+
+ + @if (!isGmailConnected()) { + + + + } @else { + +
+ +
+ + Gmail Connected
- - @if (activeStep() === 4) { -
-
- -
- -
-
-

You're all set!

-

- Any new job updates will now appear on your dashboard within seconds. Happy hunting! -

- +
+ + } + +
+ + + +
+ + +
+ +
+
+ Manual Fallback +
+ +

+ Only required if you prefer not to grant Gmail access. +

+
+ + +
+ + @for (step of [1,2,3,4]; track step) { + +
+ + +
+ + {{step}} +
- } + + + @if(step < 4){
+
+ + }
+ + } + +
+ + + @if (activeStep() === 1) { + +
+ +

+ Add Forwarding Address +

+ +
+ + + {{ inboundEmail }} + + + + +
+ + + + + +
+ + } + + @if (activeStep() === 2) { + +
+ +
+ +
+ +
+

+ Verify Forwarding Address +

+ +

+ Gmail just sent a verification email. + Open it and click the confirmation link to approve forwarding. +

+
+ + + +
+ + } + + @if (activeStep() === 3) { + +
+ +
+

+ Create Gmail Filter +

+ +

+ This ensures only job-related emails are forwarded to the AI system. +

+
+ +
+ +
    + +
  1. + + + 1 + + +

    + Open Gmail search using the button below +

    + +
  2. + + +
  3. + + + 2 + + +

    + Click Create Filter inside the search dropdown +

    + +
  4. + + +
  5. + + + 3 + + +

    + Enable Forward it to and choose your inbound address +

    + +
  6. + +
+ + + +
+ + + +
+ + } + + + @if (activeStep() === 4) { + +
+ +
+ + + +
+ + + +
+ +
+ +

+ You're Automated +

+ +

+ Job emails will now automatically appear on your dashboard. +

+ + + +
+ + } +
+ +
+ +
+
} \ No newline at end of file diff --git a/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.ts b/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.ts index ae4201d..7688112 100644 --- a/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.ts +++ b/frontend/src/app/components/gmail-setup-modal/gmail-setup-modal.component.ts @@ -1,6 +1,7 @@ -import { Component, EventEmitter, Input, Output, signal } from '@angular/core'; +import { Component, computed, EventEmitter, inject, Input, Output, signal } from '@angular/core'; import { environment } from '../../../environments/environment'; import { CommonModule } from '@angular/common'; +import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-gmail-setup-modal', @@ -10,15 +11,22 @@ import { CommonModule } from '@angular/common'; styleUrl: './gmail-setup-modal.component.css' }) export class GmailSetupModalComponent { + + public authService = inject(AuthService); @Input() isVisible = false; @Output() onClose = new EventEmitter(); @Output() onMessage = new EventEmitter<{type: 'success' | 'error', text: string}>(); + isGmailConnected = computed(() => + this.authService.userProfile()?.gmailConnected +); + + @Output() onConnect = new EventEmitter(); + activeStep = signal(1); isMobile = window.innerWidth < 768; inboundEmail = environment.inboundEmail; - // Optimized Query List readonly atsFilterQuery = `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)`; readonly subjectFilterQuery = `subject:("Application" OR "Applied" 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")`; readonly finalAtsQuery = `${this.atsFilterQuery} ${this.subjectFilterQuery}`; @@ -43,6 +51,16 @@ export class GmailSetupModalComponent { window.open(`https://mail.google.com/mail/u/0/#search/${encodedQuery}`, '_blank'); } + triggerOAuth() { + this.onConnect.emit(); + this.close(); + } + + connectGmail() { + this.onConnect.emit(); + this.onClose.emit(); + } + close() { this.onClose.emit(); setTimeout(() => this.activeStep.set(1), 300); diff --git a/frontend/src/app/components/landing/landing.component.html b/frontend/src/app/components/landing/landing.component.html index caf4bb4..6d640da 100644 --- a/frontend/src/app/components/landing/landing.component.html +++ b/frontend/src/app/components/landing/landing.component.html @@ -137,23 +137,63 @@

Zero Latency

-