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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 8 additions & 1 deletion backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@
<java.version>21</java.version>
<aws.sdk.version>2.25.27</aws.sdk.version>
<jjwt.version>0.11.5</jjwt.version>
<google.apis.version>v1-rev20220404-2.0.0</google.apis.version>
</properties>

<dependencies>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
Expand Down Expand Up @@ -72,6 +73,12 @@
<artifactId>s3</artifactId>
<version>${aws.sdk.version}</version>
</dependency>

<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-gmail</artifactId>
<version>${google.apis.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
10 changes: 10 additions & 0 deletions backend/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +20,7 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@RestController
@RequestMapping("/api/auth")
public class AuthController {
Expand All @@ -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) {
Expand All @@ -97,43 +102,29 @@ 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();
}

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());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Boolean> 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<String> connectGmail(@RequestBody Map<String, String> 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<Void> disconnectGmail() {
String email = SecurityContextHolder.getContext().getAuthentication().getName().toLowerCase();
gmailAutomationService.disconnectGmail(email);
return ResponseEntity.noContent().build();
}

@PostMapping("/gmail/sync")
public ResponseEntity<String> 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();
}
}
Loading