implementado el soft-delete

This commit is contained in:
Jaime Jiménez
2025-09-29 15:35:41 +02:00
parent 865b1573b9
commit 656bb5bad2
11 changed files with 522 additions and 241 deletions

View File

@ -7,6 +7,14 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.annotations.SQLRestriction;
import jakarta.persistence.OneToMany;
import jakarta.persistence.FetchType;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Entity
@Table(name = "roles")
public class Role {
@ -15,10 +23,15 @@ public class Role {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "name")
private String name;
@JsonIgnore
@SQLRestriction("deleted = false")
@OneToMany(mappedBy = "role", fetch = FetchType.LAZY)
private Set<UserRole> usersLink = new HashSet<>();
public Role() {
}
@ -42,6 +55,14 @@ public class Role {
this.name = name;
}
public Set<UserRole> getUsersLink() {
return usersLink;
}
public void setUsersLink(Set<UserRole> usersLink) {
this.usersLink = usersLink;
}
@Override
public String toString() {
return "Role{" + "id=" + id + ", name='" + name + '\'' + '}';

View File

@ -8,10 +8,23 @@ import java.util.Set;
import org.hibernate.annotations.Formula;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.hibernate.annotations.SQLRestriction;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@Entity
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(name = "uk_users_username", columnNames = "username")
@UniqueConstraint(name = "uk_users_username", columnNames = "username")
})
@SQLRestriction("deleted = false")
public class User {
@Id
@ -35,32 +48,42 @@ public class User {
@Column(name = "enabled")
private boolean enabled;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new java.util.HashSet<>();
@Column(name = "deleted", nullable = false)
private boolean deleted = false;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Long deletedBy;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
@SQLRestriction("deleted = false")
@JsonIgnore
private Set<UserRole> rolesLink = new HashSet<>();
// SUPERADMIN=3, ADMIN=2, USER=1 (ajusta a tus nombres reales)
@Formula("""
(
select coalesce(max(
case r.name
when 'SUPERADMIN' then 3
when 'ADMIN' then 2
else 1
end
), 0)
from users_roles ur
join roles r on r.id = ur.role_id
where ur.user_id = id
)
""")
(
select coalesce(max(
case r.name
when 'SUPERADMIN' then 3
when 'ADMIN' then 2
else 1
end
), 0)
from users_roles ur
join roles r on r.id = ur.role_id
where ur.user_id = id
)
""")
private Integer roleRank;
@Formula("""
(select group_concat(lower(r.name) order by r.name separator ', ')
from users_roles ur join roles r on r.id = ur.role_id
where ur.user_id = id)
""")
(select group_concat(lower(r.name) order by r.name separator ', ')
from users_roles ur join roles r on r.id = ur.role_id
where ur.user_id = id)
""")
private String rolesConcat;
/* Constructors */
@ -75,12 +98,12 @@ public class User {
}
public User(String fullName, String userName, String password, boolean enabled,
Set<Role> roles) {
Set<UserRole> roles) {
this.fullName = fullName;
this.userName = userName;
this.password = password;
this.enabled = enabled;
this.roles = roles;
this.rolesLink = roles;
}
/* Getters and Setters */
@ -125,12 +148,63 @@ public class User {
this.enabled = enabled;
}
@Transient
public Set<Role> getRoles() {
return roles;
return rolesLink.stream()
.filter(ur -> !ur.isDeleted())
.map(UserRole::getRole)
.collect(Collectors.toSet());
}
public void setRoles(Set<Role> roles) {
this.roles = roles;
@JsonProperty("roles")
public List<String> getRoleNames() {
return this.getRoles().stream()
.map(Role::getName)
.filter(java.util.Objects::nonNull)
.map(String::trim)
.toList();
}
public void setRoles(Set<Role> desired) {
if (desired == null)
desired = Collections.emptySet();
// 1) ids deseados
Set<Long> desiredIds = desired.stream()
.map(Role::getId)
.collect(Collectors.toSet());
// 2) Soft-delete de vínculos activos que ya no se desean
this.rolesLink.stream()
.filter(ur -> !ur.isDeleted() && !desiredIds.contains(ur.getRole().getId()))
.forEach(UserRole::softDelete);
// 3) Para cada rol deseado: si hay vínculo borrado => reactivar; si no existe
// => crear
for (Role role : desired) {
// ya activo
boolean activeExists = this.rolesLink.stream()
.anyMatch(ur -> !ur.isDeleted() && ur.getRole().getId().equals(role.getId()));
if (activeExists)
continue;
// existe borrado => reactivar
Optional<UserRole> deletedLink = this.rolesLink.stream()
.filter(ur -> ur.isDeleted() && ur.getRole().getId().equals(role.getId()))
.findFirst();
if (deletedLink.isPresent()) {
UserRole ur = deletedLink.get();
ur.setDeleted(false);
ur.setDeletedAt(null);
} else {
// crear nuevo vínculo
UserRole ur = new UserRole(this, role);
this.rolesLink.add(ur);
// si tienes la colección inversa en Role:
role.getUsersLink().add(ur);
}
}
}
public Integer getRoleRank() {
@ -141,15 +215,46 @@ public class User {
return rolesConcat;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public Long getDeletedBy() {
return deletedBy;
}
public void setDeletedBy(Long deletedBy) {
this.deletedBy = deletedBy;
}
public Set<UserRole> getRolesLink() {
return rolesLink;
}
public void setRolesLink(Set<UserRole> rolesLink) {
this.rolesLink = rolesLink;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", fullName='" + fullName + '\'' +
", userName='" + userName + '\'' +
", password='" + password + '\'' +
", enabled=" + enabled +
", roles=" + roles +
", roles=" + getRoles() +
'}';
}

View File

@ -13,6 +13,7 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
@ -33,6 +34,7 @@ import com.imprimelibros.erp.datatables.DataTable;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import org.springframework.web.bind.annotation.RequestParam;
@ -66,18 +68,17 @@ public class UserController {
public String list(Model model, Authentication authentication, Locale locale) {
List<String> keys = List.of(
"usuarios.delete.title",
"usuarios.delete.text",
"usuarios.eliminar",
"usuarios.delete.button",
"app.yes",
"app.cancelar",
"usuarios.delete.ok.title",
"usuarios.delete.ok.text"
);
"usuarios.delete.title",
"usuarios.delete.text",
"usuarios.eliminar",
"usuarios.delete.button",
"app.yes",
"app.cancelar",
"usuarios.delete.ok.title",
"usuarios.delete.ok.text");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/users/users-list";
}
@ -86,7 +87,8 @@ public class UserController {
// método con @ResponseBody.
@GetMapping(value = "/datatable", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatable(HttpServletRequest request, Locale locale) {
public DataTablesResponse<Map<String, Object>> datatable(HttpServletRequest request, Authentication authentication,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request); //
@ -94,8 +96,9 @@ public class UserController {
// Si 'role' es relación, sácalo de aquí:
List<String> searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de
// verdad
List<String> orderable = List.of("id", "fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas
// columnas
List<String> orderable = List.of("id", "fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por
// estas
// columnas
Specification<User> base = (root, query, cb) -> cb.conjunction();
long total = repo.count();
@ -121,12 +124,27 @@ public class UserController {
messageSource.getMessage("usuarios.rol." + rol, null, locale) + "</span>")
.collect(Collectors.joining(" ")))
.add("actions", (user) -> {
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
+ "\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
+ "\" class=\"link-danger btn-delete-user fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>\n" +
" </div>";
boolean isSuperAdmin = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_SUPERADMIN"));
if (!isSuperAdmin) {
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
+ "\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
+
" </div>";
} else {
// Admin editando otro admin o usuario normal: puede editarse y eliminarse
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
+ "\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
+
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
+ "\" class=\"link-danger btn-delete-user fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>\n"
+
" </div>";
}
})
.where(base)
// Filtros custom:
@ -295,28 +313,38 @@ public class UserController {
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id, Authentication authentication, Locale locale) {
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
return repo.findById(id).map(u -> {
if (authentication != null && u.getUserName().equalsIgnoreCase(authentication.getName())) {
if (auth != null && u.getUserName().equalsIgnoreCase(auth.getName())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", messageSource.getMessage("usuarios.error.delete-self", null, locale)));
}
try {
repo.delete(u);
return ResponseEntity.status(HttpStatus.OK).body(
Map.of("message", messageSource.getMessage("usuarios.exito.eliminado", null, locale))
);
} catch (DataIntegrityViolationException dive) {
// Restricción FK / dependencias
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("message", messageSource.getMessage("usuarios.error.delete-relational-data", null, locale)));
Long currentUserId = null;
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else if (auth != null) {
currentUserId = repo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); // fallback
}
u.setDeleted(true);
u.setDeletedAt(LocalDateTime.now());
u.setDeletedBy(currentUserId);
// Soft-delete de los vínculos (si usas cascade REMOVE + @SQLDelete en UserRole,
// podrías omitir este foreach y dejar que JPA lo haga)
u.getRolesLink().forEach(UserRole::softDelete);
repo.save(u); // ← NO delete(); guardamos el soft delete con deleted_by relleno
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("usuarios.exito.eliminado", null, locale)));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", messageSource.getMessage("usuarios.error.delete-internal-error", null, locale)));
.body(Map.of("message",
messageSource.getMessage("usuarios.error.delete-internal-error", null, locale)));
}
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", messageSource.getMessage("usuarios.error.delete-not-found", null, locale))));
.body(Map.of("message", messageSource.getMessage("usuarios.error.not-found", null, locale))));
}
}

View File

@ -2,16 +2,47 @@ package com.imprimelibros.erp.users;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
@Repository
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
User findByUserNameAndEnabledTrue(String userName);
// Aplicamos EntityGraph a la versión con Specification+Pageable
@Override
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
@NonNull
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
Optional<User> findByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
// Nuevo: para login/negocio "activo"
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
// Para poder restaurar, necesitas leer ignorando @Where (native):
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
List<User> findAllDeleted();
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
}

View File

@ -4,10 +4,7 @@ import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Adaptador de la entidad User a Spring Security.
@ -15,21 +12,22 @@ import java.util.stream.Collectors;
public class UserDetailsImpl implements UserDetails {
private final User user;
private final java.util.Collection<? extends GrantedAuthority> authorities;
public UserDetailsImpl(User user) {
this.user = user;
this.authorities = user.getRoles().stream()
.map(Role::getName)
.filter(java.util.Objects::nonNull)
.map(String::trim)
.map(String::toUpperCase)
.map(name -> new SimpleGrantedAuthority("ROLE_" + name))
.toList();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// Si tu User tiene un Set<Role>:
Set<String> roles = user.getRoles().stream()
.map(r -> r.getName()) // ejemplo: "ADMIN", "USER"
.collect(Collectors.toSet());
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
public java.util.Collection<? extends GrantedAuthority> getAuthorities() {
return authorities; // no volvemos a tocar user.getRoles() fuera de sesión
}
@Override
@ -43,11 +41,10 @@ public class UserDetailsImpl implements UserDetails {
}
public String getFullname() {
return user.getFullName();
return user.getFullName();
}
// 👇 si en la vista usas principal.role, añade este también
public String getRole() {
return user.getRoles().stream()
.map(r -> r.getName()) // "ADMIN", "USER", ...
@ -79,10 +76,15 @@ public class UserDetailsImpl implements UserDetails {
@Override
public boolean isEnabled() {
return user.isEnabled();
return user.isEnabled() && !user.isDeleted();
}
public User getUser() {
return user;
}
public Long getId() {
return user.getId();
}
}

View File

@ -0,0 +1,121 @@
package com.imprimelibros.erp.users;
import java.time.LocalDateTime;
import jakarta.persistence.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity
@Table(name = "users_roles", uniqueConstraints = {
@UniqueConstraint(name = "ux_users_roles_active", columnNames = { "user_id", "role_id", "deleted" })
})
@SQLDelete(sql = "UPDATE users_roles SET deleted = TRUE, deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted = false")
public class UserRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// FK a users
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
// FK a roles
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@Column(nullable = false)
private boolean deleted = false;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
protected UserRole() {
}
public UserRole(User user, Role role) {
this.user = user;
this.role = role;
}
/* ---- helpers ---- */
public void softDelete() {
this.deleted = true;
this.deletedAt = LocalDateTime.now();
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof UserRole other))
return false;
Long u1 = this.getUser() != null ? this.getUser().getId() : null;
Long r1 = this.getRole() != null ? this.getRole().getId() : null;
Long u2 = other.getUser() != null ? other.getUser().getId() : null;
Long r2 = other.getRole() != null ? other.getRole().getId() : null;
// igualdad por clave lógica (user_id, role_id) cuando existen
if (u1 != null && r1 != null && u2 != null && r2 != null) {
return u1.equals(u2) && r1.equals(r2);
}
// fallback: identidad por id si está asignado
if (this.getId() != null && other.getId() != null) {
return this.getId().equals(other.getId());
}
return false;
}
@Override
public int hashCode() {
Long u = this.getUser() != null ? this.getUser().getId() : null;
Long r = this.getRole() != null ? this.getRole().getId() : null;
if (u != null && r != null) {
return java.util.Objects.hash(u, r);
}
return java.util.Objects.hash(getId());
}
/* ---- getters/setters ---- */
public Long getId() {
return id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Role getRole() {
return role;
}
public void setRole(Role role) {
this.role = role;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
}

View File

@ -3,5 +3,5 @@ package com.imprimelibros.erp.users;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface UserService extends UserDetailsService {
public User findByUserName(String userName);
}

View File

@ -1,44 +1,22 @@
package com.imprimelibros.erp.users;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.stream.Collectors;
@Service
public class UserServiceImpl implements UserService {
private UserDao userDao;
private RoleDao roleDao;
public UserServiceImpl(UserDao userDao, RoleDao roleDao) {
public UserServiceImpl(UserDao userDao) {
this.userDao = userDao;
this.roleDao = roleDao;
}
@Override
public User findByUserName(String userName) {
// check the database if the user already exists
return userDao.findByUserNameAndEnabledTrue(userName);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userDao.findByUserNameAndEnabledTrue(username);
if (user == null) {
throw new UsernameNotFoundException("No existe usuario: " + username);
}
@Override
public UserDetails loadUserByUsername(String username) {
User user = userDao.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(username)
.orElseThrow(() -> new UsernameNotFoundException("No existe usuario activo: " + username));
return new UserDetailsImpl(user);
}
/*public List<User> getUsersList() {
return userDao.findAll();
}*/
private Collection<? extends GrantedAuthority> mapRolesToAuthorities(Collection<Role> roles) {
return roles.stream().map(role -> new SimpleGrantedAuthority(role.getName())).collect(Collectors.toList());
}
}