Skip to content

Commit 338b45c

Browse files
committed
feat: Implement refresh token functionality and update JWT handling
- Added RefreshTokenRequest DTO for handling refresh token requests. - Updated AuthResponse to include refresh token. - Implemented refreshToken method in UserService to generate new access and refresh tokens. - Enhanced JwtUtil to support refresh token generation and validation. - Updated application.properties to include refresh token expiration configuration. - Modified AuthController to handle refresh token endpoint. - Added tests for refresh token validation and handling in JwtUtilTest. - Updated WorkShiftAssignmentService to ensure no overlapping assignments. - Added unit tests for WorkShiftAssignmentService to validate assignment creation logic.
1 parent a024400 commit 338b45c

File tree

13 files changed

+573
-9
lines changed

13 files changed

+573
-9
lines changed

backend/src/main/java/com/smalltrend/controller/AuthController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.smalltrend.dto.auth.AuthRequest;
44
import com.smalltrend.dto.auth.AuthResponse;
55
import com.smalltrend.dto.auth.ForgotPasswordOtpRequest;
6+
import com.smalltrend.dto.auth.RefreshTokenRequest;
67
import com.smalltrend.dto.auth.ResetPasswordOtpRequest;
78
import com.smalltrend.dto.common.MessageResponse;
89
import com.smalltrend.entity.User;
@@ -103,6 +104,18 @@ public ResponseEntity<?> logout() {
103104
}
104105
}
105106

107+
@PostMapping("/refresh")
108+
public ResponseEntity<?> refreshToken(@Valid @RequestBody RefreshTokenRequest request) {
109+
try {
110+
AuthResponse response = userService.refreshToken(request.getRefreshToken());
111+
return ResponseEntity.ok(response);
112+
} catch (Exception e) {
113+
log.warn("Refresh token failed: {}", e.getMessage());
114+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
115+
.body(new MessageResponse("Refresh token không hợp lệ hoặc đã hết hạn"));
116+
}
117+
}
118+
106119
@GetMapping("/me")
107120
public ResponseEntity<?> getCurrentUser() {
108121
try {

backend/src/main/java/com/smalltrend/dto/auth/AuthResponse.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
public class AuthResponse {
1313

1414
private String token;
15+
private String refreshToken;
1516
@lombok.Builder.Default
1617
private String type = "Bearer";
1718
private Integer userId;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.smalltrend.dto.auth;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Data;
7+
import lombok.NoArgsConstructor;
8+
9+
@Data
10+
@Builder
11+
@NoArgsConstructor
12+
@AllArgsConstructor
13+
public class RefreshTokenRequest {
14+
15+
@NotBlank(message = "Refresh token is required")
16+
private String refreshToken;
17+
}

backend/src/main/java/com/smalltrend/repository/WorkShiftAssignmentRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ public interface WorkShiftAssignmentRepository extends JpaRepository<WorkShiftAs
1919

2020
List<WorkShiftAssignment> findByUserIdAndShiftDateBetweenAndDeletedFalse(Integer userId, LocalDate startDate, LocalDate endDate);
2121

22+
List<WorkShiftAssignment> findByUserIdAndShiftDateAndDeletedFalse(Integer userId, LocalDate shiftDate);
23+
2224
boolean existsByUserIdAndWorkShiftIdAndShiftDateAndDeletedFalse(Integer userId, Integer workShiftId, LocalDate shiftDate);
2325
}

backend/src/main/java/com/smalltrend/service/ShiftWorkforceService.java

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public List<AttendanceResponse> listAttendance(LocalDate date, LocalDate startDa
7777
LocalDate shiftDate = assignment.getShiftDate();
7878

7979
Attendance attendance = attendanceMap.get(key(user.getId(), shiftDate));
80-
String attendanceStatus = resolveAttendanceStatus(attendance, assignment, today, now);
80+
String attendanceStatus = resolveAttendanceStatusForMonitoring(attendance, assignment, today, now);
8181

8282
if (status != null && !status.trim().isEmpty() && !"ALL".equalsIgnoreCase(status)) {
8383
if (!attendanceStatus.equalsIgnoreCase(status.trim())) {
@@ -129,14 +129,21 @@ public AttendanceResponse upsertAttendance(AttendanceUpsertRequest request) {
129129
attendance.setDate(request.getDate());
130130
attendance.setTimeIn(request.getTimeIn());
131131
attendance.setTimeOut(request.getTimeOut());
132-
attendance.setStatus(normalizeStatus(request.getStatus()));
133132

134133
WorkShiftAssignment assignment = assignmentRepository
135134
.findByUserIdAndShiftDateBetweenAndDeletedFalse(user.getId(), request.getDate(), request.getDate())
136135
.stream()
137136
.findFirst()
138137
.orElse(null);
139138

139+
validateAttendanceTimeline(request.getTimeIn(), request.getTimeOut(), assignment);
140+
141+
attendance.setStatus(resolveAttendanceStatusForUpsert(request.getStatus(), request.getTimeIn(), request.getTimeOut(), assignment));
142+
143+
if (request.getTimeOut() != null && !shouldMarkAssignmentCompleted(attendance)) {
144+
attendance.setStatus("PRESENT");
145+
}
146+
140147
if (assignment != null && assignment.getWorkShift() != null) {
141148
WorkShift shift = assignment.getWorkShift();
142149
attendance.setAssignmentIdSnapshot(assignment.getId());
@@ -253,7 +260,7 @@ public PayrollSummaryResponse buildPayrollSummary(String month,
253260
acc.totalShifts += 1;
254261

255262
Attendance attendance = attendanceMap.get(key(user.getId(), assignment.getShiftDate()));
256-
String attendanceStatus = resolveAttendanceStatus(attendance, assignment, today, now);
263+
String attendanceStatus = resolveAttendanceStatusForPayroll(attendance, assignment, today, now);
257264

258265
if ("ABSENT".equals(attendanceStatus)) {
259266
acc.absentShifts += 1;
@@ -688,7 +695,7 @@ private boolean shouldMarkAssignmentCompleted(Attendance attendance) {
688695
return "PRESENT".equals(normalized) || "LATE".equals(normalized);
689696
}
690697

691-
private String resolveAttendanceStatus(Attendance attendance,
698+
private String resolveAttendanceStatusForPayroll(Attendance attendance,
692699
WorkShiftAssignment assignment,
693700
LocalDate today,
694701
LocalTime now) {
@@ -712,6 +719,114 @@ private String resolveAttendanceStatus(Attendance attendance,
712719
return "PENDING";
713720
}
714721

722+
private String resolveAttendanceStatusForMonitoring(Attendance attendance,
723+
WorkShiftAssignment assignment,
724+
LocalDate today,
725+
LocalTime now) {
726+
if (attendance != null) {
727+
String normalizedStatus = normalizeStatus(attendance.getStatus());
728+
if (!"PENDING".equals(normalizedStatus)) {
729+
return normalizedStatus;
730+
}
731+
732+
if (attendance.getTimeIn() != null && attendance.getTimeOut() == null
733+
&& hasShiftEnded(assignment, today, now)) {
734+
return "MISSING_CLOCK_OUT";
735+
}
736+
737+
if (hasShiftEndedWithoutCheckIn(attendance.getTimeIn(), assignment, today, now)) {
738+
return "ABSENT";
739+
}
740+
741+
return "PENDING";
742+
}
743+
744+
if (hasShiftEndedWithoutCheckIn(null, assignment, today, now)) {
745+
return "ABSENT";
746+
}
747+
748+
return "PENDING";
749+
}
750+
751+
private String resolveAttendanceStatusForUpsert(String requestedStatus,
752+
LocalTime timeIn,
753+
LocalTime timeOut,
754+
WorkShiftAssignment assignment) {
755+
if (requestedStatus != null && !requestedStatus.isBlank()) {
756+
return normalizeStatus(requestedStatus);
757+
}
758+
759+
if (timeIn == null) {
760+
return "PENDING";
761+
}
762+
763+
if (timeOut == null) {
764+
return "PENDING";
765+
}
766+
767+
WorkShift shift = assignment != null ? assignment.getWorkShift() : null;
768+
if (shift == null || shift.getStartTime() == null) {
769+
return "PRESENT";
770+
}
771+
772+
LocalTime graceCutoff = shift.getStartTime().plusMinutes(Optional.ofNullable(shift.getGracePeroidMinutes()).orElse(0));
773+
if (timeIn.isAfter(graceCutoff)) {
774+
return "LATE";
775+
}
776+
777+
return "PRESENT";
778+
}
779+
780+
private void validateAttendanceTimeline(LocalTime timeIn,
781+
LocalTime timeOut,
782+
WorkShiftAssignment assignment) {
783+
if (timeOut != null && timeIn == null) {
784+
throw new RuntimeException("Không thể rời ca khi chưa chấm công vào ca");
785+
}
786+
787+
if (timeIn != null && timeOut != null && timeOut.equals(timeIn)) {
788+
throw new RuntimeException("Giờ vào ca và rời ca không được trùng nhau");
789+
}
790+
791+
if (timeIn == null || timeOut == null || assignment == null || assignment.getWorkShift() == null) {
792+
return;
793+
}
794+
795+
WorkShift shift = assignment.getWorkShift();
796+
LocalTime shiftStart = shift.getStartTime();
797+
LocalTime shiftEnd = shift.getEndTime();
798+
799+
if (shiftStart == null || shiftEnd == null) {
800+
return;
801+
}
802+
803+
boolean overnight = !shiftEnd.isAfter(shiftStart);
804+
if (!overnight && timeOut.isBefore(timeIn)) {
805+
throw new RuntimeException("Giờ rời ca không hợp lệ: phải sau giờ vào ca");
806+
}
807+
}
808+
809+
private boolean hasShiftEnded(WorkShiftAssignment assignment,
810+
LocalDate today,
811+
LocalTime now) {
812+
if (assignment == null || assignment.getShiftDate() == null || assignment.getWorkShift() == null) {
813+
return false;
814+
}
815+
816+
WorkShift shift = assignment.getWorkShift();
817+
if (shift.getEndTime() == null) {
818+
return false;
819+
}
820+
821+
LocalDateTime shiftEndDateTime = LocalDateTime.of(assignment.getShiftDate(), shift.getEndTime());
822+
if (shift.getStartTime() != null && !shift.getEndTime().isAfter(shift.getStartTime())) {
823+
shiftEndDateTime = shiftEndDateTime.plusDays(1);
824+
}
825+
826+
LocalDateTime nowDateTime = LocalDateTime.of(today, now);
827+
return !nowDateTime.isBefore(shiftEndDateTime);
828+
}
829+
715830
private boolean hasShiftEndedWithoutCheckIn(LocalTime checkIn,
716831
WorkShiftAssignment assignment,
717832
LocalDate today,

backend/src/main/java/com/smalltrend/service/UserService.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,11 @@ public AuthResponse register(RegisterRequest request) {
153153

154154
// Generate JWT token
155155
String token = jwtUtil.generateToken(savedUser.getUsername());
156+
String refreshToken = jwtUtil.generateRefreshToken(savedUser.getUsername());
156157

157158
return AuthResponse.builder()
158159
.token(token)
160+
.refreshToken(refreshToken)
159161
.type("Bearer")
160162
.userId(savedUser.getId())
161163
.username(savedUser.getUsername())
@@ -172,9 +174,36 @@ public AuthResponse login(String username) {
172174
.map(UserCredential::getUser)
173175
.orElseThrow(() -> new UsernameNotFoundException("User not found")));
174176
String token = jwtUtil.generateToken(username);
177+
String refreshToken = jwtUtil.generateRefreshToken(username);
175178

176179
return AuthResponse.builder()
177180
.token(token)
181+
.refreshToken(refreshToken)
182+
.type("Bearer")
183+
.userId(user.getId())
184+
.username(user.getUsername())
185+
.fullName(user.getFullName())
186+
.email(user.getEmail())
187+
.role(user.getRole() != null ? user.getRole().getName().toUpperCase() : "ROLE_USER")
188+
.avatarUrl(user.getAvatarUrl())
189+
.build();
190+
}
191+
192+
public AuthResponse refreshToken(String refreshToken) {
193+
String username = jwtUtil.extractUsername(refreshToken);
194+
UserDetails userDetails = loadUserByUsername(username);
195+
196+
if (!jwtUtil.validateRefreshToken(refreshToken, userDetails)) {
197+
throw new RuntimeException("Refresh token không hợp lệ hoặc đã hết hạn");
198+
}
199+
200+
User user = getCurrentUser(username);
201+
String newAccessToken = jwtUtil.generateToken(username);
202+
String newRefreshToken = jwtUtil.generateRefreshToken(username);
203+
204+
return AuthResponse.builder()
205+
.token(newAccessToken)
206+
.refreshToken(newRefreshToken)
178207
.type("Bearer")
179208
.userId(user.getId())
180209
.username(user.getUsername())

0 commit comments

Comments
 (0)