Skip to content

Commit d0167d5

Browse files
committed
Feat: implement soft delete for employees with audit tracking
- Add soft delete functionality to Employee entity via deletedAt field - Implement JPA auditing with createdBy and lastModifiedBy tracking - Configure JpaConfig with AuditorAware for user audit trail - Update BaseModel with audit fields and entity listeners - Modify employee delete endpoint to require status (TERMINATED/RESIGNED) - Handle supervisor reassignment when deleting employees with subordinates - Update EmployeeRepository queries to exclude soft-deleted records - Add validation to prevent deletion of already deleted employees - update unit tests for soft delete
1 parent 1a56db8 commit d0167d5

6 files changed

Lines changed: 126 additions & 12 deletions

File tree

src/main/java/com/iodsky/sweldox/common/BaseModel.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
package com.iodsky.sweldox.common;
22

3-
import jakarta.persistence.Column;
4-
import jakarta.persistence.MappedSuperclass;
5-
import jakarta.persistence.Version;
3+
import com.iodsky.sweldox.security.user.User;
4+
import jakarta.persistence.*;
65
import lombok.Getter;
76
import lombok.Setter;
87
import org.hibernate.annotations.CreationTimestamp;
98
import org.hibernate.annotations.UpdateTimestamp;
9+
import org.springframework.data.annotation.CreatedBy;
10+
import org.springframework.data.annotation.LastModifiedBy;
11+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
1012

1113
import java.time.Instant;
1214

1315
@MappedSuperclass
16+
@EntityListeners(AuditingEntityListener.class)
1417
@Getter
1518
@Setter
1619
public abstract class BaseModel {
@@ -22,6 +25,23 @@ public abstract class BaseModel {
2225
@UpdateTimestamp
2326
private Instant updatedAt;
2427

28+
private Instant deletedAt;
29+
30+
@CreatedBy
31+
@ManyToOne(fetch = FetchType.LAZY)
32+
@JoinColumn(name = "created_by")
33+
private User createdBy;
34+
35+
@LastModifiedBy
36+
@ManyToOne(fetch = FetchType.LAZY)
37+
@JoinColumn(name = "last_modified_by")
38+
private User lastModifiedBy;
39+
2540
@Version
2641
private Long version;
42+
43+
public boolean isDeleted() {
44+
return deletedAt != null;
45+
}
46+
2747
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.iodsky.sweldox.common;
2+
3+
import com.iodsky.sweldox.security.user.User;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.data.domain.AuditorAware;
7+
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.security.core.context.SecurityContextHolder;
10+
11+
import java.util.Optional;
12+
13+
@Configuration
14+
@EnableJpaAuditing
15+
public class JpaConfig {
16+
17+
@Bean
18+
public AuditorAware<User> auditorProvider() {
19+
return () -> {
20+
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
21+
if (auth == null || !auth.isAuthenticated()
22+
|| "anonymousUser".equals(auth.getPrincipal())) {
23+
return Optional.empty();
24+
}
25+
26+
return Optional.of((User) auth.getPrincipal());
27+
};
28+
}
29+
30+
}

src/main/java/com/iodsky/sweldox/employee/EmployeeController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ public ResponseEntity<ApiResponse<EmployeeDto>> updateEmployee(@PathVariable lon
8282

8383
@PreAuthorize("hasRole('HR')")
8484
@DeleteMapping("/{id}")
85-
public ResponseEntity<ApiResponse<DeleteResponse>> deleteEmployee(@PathVariable long id) {
86-
employeeService.deleteEmployeeById(id);
85+
public ResponseEntity<ApiResponse<DeleteResponse>> deleteEmployee(@PathVariable long id, @RequestParam String status) {
86+
employeeService.deleteEmployeeById(id, status);
8787
DeleteResponse res = new DeleteResponse("Employee", id);
8888
return ResponseFactory.ok("Employee deleted successfully", res);
8989
}

src/main/java/com/iodsky/sweldox/employee/EmployeeRepository.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,25 @@
44
import org.springframework.data.domain.Pageable;
55
import org.springframework.data.jpa.repository.JpaRepository;
66
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.lang.NonNull;
78
import org.springframework.stereotype.Repository;
89

910
import java.util.List;
1011

1112
@Repository
1213
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
1314

15+
@Query("SELECT e FROM Employee e WHERE e.deletedAt IS NULL")
16+
@NonNull
17+
Page<Employee> findAll(@NonNull Pageable pageable);
18+
19+
@Query("SELECT e FROM Employee e WHERE e.deletedAt IS NULL AND e.employmentDetails.status = :status")
1420
Page<Employee> findByEmploymentDetails_Status(Status status, Pageable pageable);
1521

22+
@Query("SELECT e FROM Employee e WHERE e.deletedAt IS NULL AND e.employmentDetails.department.id = :departmentId")
1623
Page<Employee> findByEmploymentDetails_Department_Id(String departmentId, Pageable pageable);
1724

25+
@Query("SELECT e FROM Employee e WHERE e.deletedAt IS NULL AND e.employmentDetails.supervisor.id = :supervisorId")
1826
Page<Employee> findByEmploymentDetails_Supervisor_Id(Long supervisorId, Pageable pageable);
1927

2028
@Query("""
@@ -27,4 +35,7 @@ WHERE e.employmentDetails.status NOT IN (
2735
""")
2836
List<Long> findAllActiveEmployeeIds();
2937

38+
@Query("SELECT e FROM Employee e WHERE e.deletedAt IS NULL AND e.employmentDetails.supervisor.id = :supervisorId")
39+
List<Employee> findAllByEmploymentDetails_Supervisor_Id(Long supervisorId);
40+
3041
}

src/main/java/com/iodsky/sweldox/employee/EmployeeService.java

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.springframework.web.server.ResponseStatusException;
2626

2727
import java.io.IOException;
28+
import java.time.Instant;
2829
import java.util.*;
2930
import java.util.stream.Collectors;
3031

@@ -135,10 +136,36 @@ public Employee updateEmployeeById(Long id, EmployeeRequest request) {
135136
}
136137
}
137138

138-
public void deleteEmployeeById(Long id) {
139-
employeeRepository.findById(id).ifPresentOrElse(employeeRepository::delete, () -> {
140-
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Employee " + id + " not found");
141-
});
139+
@Transactional
140+
public void deleteEmployeeById(Long id, String status) {
141+
Employee employee = getEmployeeById(id);
142+
if (employee.isDeleted()) {
143+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Employee already deleted");
144+
}
145+
146+
Status status_;
147+
try {
148+
status_ = Status.valueOf(status.toUpperCase());
149+
if (status_ != Status.TERMINATED && status_ != Status.RESIGNED) {
150+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status must be either TERMINATED or RESIGNED");
151+
}
152+
} catch (IllegalArgumentException e) {
153+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status must be either TERMINATED or RESIGNED");
154+
}
155+
156+
List<Employee> subordinates = employeeRepository.findAllByEmploymentDetails_Supervisor_Id(employee.getId());
157+
if (!subordinates.isEmpty()) {
158+
subordinates.forEach(subordinate -> subordinate.getEmploymentDetails().setSupervisor(null));
159+
employeeRepository.saveAll(subordinates);
160+
}
161+
162+
employee.setDeletedAt(Instant.now());
163+
employee.getEmploymentDetails().setStatus(status_);
164+
employee.getEmploymentDetails().setDeletedAt(Instant.now());
165+
employee.getCompensation().setDeletedAt(Instant.now());
166+
employee.getGovernmentId().setDeletedAt(Instant.now());
167+
168+
employeeRepository.save(employee);
142169
}
143170

144171
public List<Long> getAllActiveEmployeeIds() {

src/test/java/com/iodsky/sweldox/employee/EmployeeServiceTest.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ void setUp() {
6262
compensation.setBenefits(new ArrayList<>());
6363
employee.setCompensation(compensation);
6464

65+
GovernmentId governmentId = new GovernmentId();
66+
employee.setGovernmentId(governmentId);
67+
6568
request = new EmployeeRequest();
6669
EmploymentDetailsRequest reqDetails = new EmploymentDetailsRequest();
6770
reqDetails.setDepartmentId("DEP001");
@@ -280,20 +283,43 @@ class DeleteEmployeeTests {
280283
@Test
281284
void shouldDeleteEmployeeSuccessfully() {
282285
when(employeeRepository.findById(1L)).thenReturn(Optional.of(employee));
286+
when(employeeRepository.save(any(Employee.class))).thenReturn(employee);
283287

284-
employeeService.deleteEmployeeById(1L);
288+
employeeService.deleteEmployeeById(1L, "TERMINATED");
285289

286-
verify(employeeRepository).delete(employee);
290+
verify(employeeRepository).save(employee);
291+
assertNotNull(employee.getDeletedAt());
287292
}
288293

289294
@Test
290295
void shouldThrowNotFoundWhenDeletingNonexistentEmployee() {
291296
when(employeeRepository.findById(1L)).thenReturn(Optional.empty());
292297

293298
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
294-
() -> employeeService.deleteEmployeeById(1L));
299+
() -> employeeService.deleteEmployeeById(1L, "RESIGNED"));
295300

296301
assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode());
297302
}
303+
304+
@Test
305+
void shouldThrowBadRequestWhenDeletingAlreadyDeletedEmployee() {
306+
employee.setDeletedAt(java.time.Instant.now());
307+
when(employeeRepository.findById(1L)).thenReturn(Optional.of(employee));
308+
309+
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
310+
() -> employeeService.deleteEmployeeById(1L, "TERMINATED"));
311+
312+
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
313+
}
314+
315+
@Test
316+
void shouldThrowBadRequestWhenStatusIsInvalid() {
317+
when(employeeRepository.findById(1L)).thenReturn(Optional.of(employee));
318+
319+
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
320+
() -> employeeService.deleteEmployeeById(1L, "ACTIVE"));
321+
322+
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
323+
}
298324
}
299325
}

0 commit comments

Comments
 (0)