package com.imprimelibros.erp.users; import com.imprimelibros.erp.datatables.DataTablesResponse; import com.imprimelibros.erp.users.validation.UserForm; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.MessageSource; import org.springframework.dao.DataIntegrityViolationException; 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.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import com.imprimelibros.erp.datatables.DataTablesRequest; import com.imprimelibros.erp.datatables.DataTablesParser; import com.imprimelibros.erp.config.Sanitizer; import com.imprimelibros.erp.datatables.DataTable; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.List; import java.util.Locale; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PathVariable; @Controller @PreAuthorize("hasRole('ADMIN') or hasRole('SUPERADMIN')") @RequestMapping("/users") public class UserController { private UserDao repo; private RoleDao roleRepo; private MessageSource messageSource; private Sanitizer sanitizer; private PasswordEncoder passwordEncoder; public UserController(UserDao repo, UserService userService, MessageSource messageSource, Sanitizer sanitizer, PasswordEncoder passwordEncoder, RoleDao roleRepo) { this.repo = repo; this.messageSource = messageSource; this.sanitizer = sanitizer; this.roleRepo = roleRepo; this.passwordEncoder = passwordEncoder; } @GetMapping public String list(Model model, Authentication authentication, Locale locale) { return "imprimelibros/users/users-list"; } // IMPORTANTE: asegúrate de que el controller es @RestController O anota el // método con @ResponseBody. @GetMapping(value = "/datatable", produces = "application/json") @ResponseBody public DataTablesResponse> datatable(HttpServletRequest request, Locale locale) { DataTablesRequest dt = DataTablesParser.from(request); // // OJO: en la whitelist mete solo columnas "reales" y escalares (no relaciones). // Si 'role' es relación, sácalo de aquí: List searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de // verdad List orderable = List.of("fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas // columnas Specification base = (root, query, cb) -> cb.conjunction(); long total = repo.count(); return DataTable .of(repo, User.class, dt, searchable) // 'searchable' en DataTable.java // edita columnas "reales": .orderable(orderable) .edit("enabled", (User u) -> { if (u.isEnabled()) { return "" + messageSource.getMessage("usuarios.tabla.activo", null, locale) + ""; } else { return "" + messageSource.getMessage("usuarios.tabla.inactivo", null, locale) + ""; } }) // acciones virtuales: .add("roles", (User u) -> u.getRoles().stream() .map(Role::getName) .map(String::toLowerCase) .map(rol -> "" + messageSource.getMessage("usuarios.rol." + rol, null, locale) + "") .collect(Collectors.joining(" "))) .add("actions", (user) -> { return "
\n" + " \n" + " \n" + "
"; }) .where(base) // Filtros custom: .filter((builder, req) -> { // f_enabled: 'true' | 'false' | '' String fEnabled = Optional.ofNullable(req.raw.get("f_enabled")).orElse("").trim(); if (!fEnabled.isEmpty()) { boolean enabledVal = Boolean.parseBoolean(fEnabled); builder.add((root, q, cb) -> cb.equal(root.get("enabled"), enabledVal)); } // f_role: 'USER' | 'ADMIN' | 'SUPERADMIN' | '' String fRole = Optional.ofNullable(req.raw.get("f_role")).orElse("").trim(); if (!fRole.isEmpty()) { builder.add((root, q, cb) -> { // join a roles; marca la query como distinct para evitar duplicados var r = root.join("roles", jakarta.persistence.criteria.JoinType.LEFT); q.distinct(true); return cb.equal(r.get("name"), fRole); }); } }) .toJson(total); } @GetMapping("form") public String getForm(@RequestParam(required = false) Long id, @ModelAttribute("user") UserForm form, BindingResult binding, Model model, HttpServletResponse response, Locale locale) { if (id != null) { var opt = repo.findById(id); if (opt.isEmpty()) { binding.reject("usuarios.error.noEncontrado", messageSource.getMessage("usuarios.error.noEncontrado", null, locale)); response.setStatus(404); model.addAttribute("action", "/users/" + id); return "imprimelibros/users/user-form :: userForm"; } User u = opt.get(); // map ENTIDAD -> DTO (¡no metas la entidad en "user"!) form.setId(u.getId()); form.setFullName(u.getFullName()); form.setUserName(u.getUserName()); form.setEnabled(u.isEnabled()); form.setRoleName(u.getRoles().stream().findFirst().map(Role::getName).orElse("USER")); form.setPassword(null); form.setConfirmPassword(null); model.addAttribute("action", "/users/" + id); } else { // Crear: valores por defecto form.setEnabled(true); model.addAttribute("action", "/users"); } return "imprimelibros/users/user-form :: userForm"; } @PostMapping public String create( @Validated(UserForm.Create.class) @ModelAttribute("user") UserForm form, BindingResult binding, Model model, HttpServletResponse response, Locale locale) { String normalized = sanitizer.plain(form.getUserName().trim()); if (repo.existsByUserNameIgnoreCase(normalized)) { binding.rejectValue("userName", "validation.unique", messageSource.getMessage("usuarios.error.duplicado", null, locale)); } var optRole = roleRepo.findRoleByName(form.getRoleName()); if (optRole.isEmpty()) { binding.rejectValue("roleName", "usuarios.errores.rol.invalido", messageSource.getMessage("usuarios.error.rol", null, locale)); } if (binding.hasErrors()) { response.setStatus(422); // <- clave model.addAttribute("action", "/users"); return "imprimelibros/users/user-form :: userForm"; } User u = new User(); u.setFullName(sanitizer.plain(form.getFullName())); u.setUserName(normalized.toLowerCase()); u.setPassword(passwordEncoder.encode(form.getPassword())); java.util.Set roles = new java.util.HashSet<>(); roles.add(optRole.get()); u.setRoles(roles); u.setEnabled(Boolean.TRUE.equals(form.getEnabled())); try { repo.save(u); } catch (org.springframework.dao.DataIntegrityViolationException ex) { // carrera contra otra inserción: vuelve como error de campo binding.rejectValue("userName", "validation.unique", messageSource.getMessage("usuarios.error.duplicado", null, locale)); response.setStatus(422); model.addAttribute("action", "/users"); return "imprimelibros/users/user-form :: userForm"; } response.setStatus(204); return null; } @PutMapping("/{id}") public String edit( @PathVariable Long id, @Validated(UserForm.Update.class) @ModelAttribute("user") UserForm form, BindingResult binding, Model model, HttpServletResponse response, Locale locale) { var uOpt = repo.findById(id); if (uOpt.isEmpty()) { binding.reject("usuarios.error.noEncontrado", messageSource.getMessage("usuarios.error.noEncontrado", null, locale)); } String normalized = sanitizer.plain(form.getUserName()).trim(); if (repo.existsByUserNameIgnoreCaseAndIdNot(normalized, id)) { binding.rejectValue("userName", "validation.unique", messageSource.getMessage("usuarios.error.duplicado", null, locale)); } var optRole = roleRepo.findRoleByName(form.getRoleName()); if (optRole.isEmpty()) { binding.rejectValue("roleName", "usuarios.errores.rol.invalido", messageSource.getMessage("usuarios.error.rol", null, locale)); } if (binding.hasErrors()) { response.setStatus(422); model.addAttribute("action", "/users/" + id); return "imprimelibros/users/user-form :: userForm"; } var u = uOpt.get(); u.setFullName(sanitizer.plain(form.getFullName()).trim()); u.setUserName(normalized.toLowerCase()); if (form.getPassword() != null && !form.getPassword().isBlank()) { u.setPassword(passwordEncoder.encode(form.getPassword())); } u.setRoles(new java.util.HashSet<>(java.util.List.of(optRole.get()))); u.setEnabled(Boolean.TRUE.equals(form.getEnabled())); try { repo.save(u); } catch (org.springframework.dao.DataIntegrityViolationException ex) { binding.rejectValue("userName", "validation.unique", messageSource.getMessage("usuarios.error.duplicado", null, locale)); response.setStatus(422); model.addAttribute("action", "/users/" + id); return "imprimelibros/users/user-form :: userForm"; } response.setStatus(204); return null; } @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable Long id, Authentication authentication, Locale locale) { return repo.findById(id).map(u -> { if (authentication != null && u.getUserName().equalsIgnoreCase(authentication.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))); } catch (Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .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)))); } }