AccountController.java
package com.albertsilva.dev.asjcatalog.web.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.albertsilva.dev.asjcatalog.dto.user.request.AuthenticatedUserUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.user.request.PasswordResetRequest;
import com.albertsilva.dev.asjcatalog.dto.user.request.PasswordUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.user.request.UserEmailRequest;
import com.albertsilva.dev.asjcatalog.dto.user.request.UserRegisterRequest;
import com.albertsilva.dev.asjcatalog.dto.user.response.UserResponse;
import com.albertsilva.dev.asjcatalog.service.AccountService;
import com.albertsilva.dev.asjcatalog.web.exception.response.ProblemDetails;
import com.albertsilva.dev.asjcatalog.web.exception.response.ValidationError;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.mail.MessagingException;
import jakarta.validation.Valid;
/**
* Endpoints da <b>conta do próprio usuário</b> ({@code /api/v1/accounts}), todos
* delegados a {@code AccountService}. O controller não usa mappers nem
* repositories: recebe o DTO, chama o service e monta o {@code ResponseEntity}.
*
* <p>
* <b>Acesso:</b> na cadeia de segurança, {@code GET} e {@code POST} em
* {@code /api/v1/accounts/**} são <b>públicos por URL</b>; {@code PUT} e
* {@code PATCH} exigem autenticação. Por isso, nas rotas públicas por URL que
* precisam de login ({@code POST /deactivate}, {@code GET /me}) a proteção vem
* <b>somente</b> do {@code @PreAuthorize} do método.
* </p>
*
* <p>
* <b>Documentação OpenAPI:</b> as anotações {@code @Operation}/{@code @ApiResponses}
* descrevem os endpoints, exceto {@code PATCH /me/password}, que não tem
* nenhuma; os códigos declarados nem sempre coincidem com os que o código
* produz (ver documentação de cada método).
* </p>
*/
@Tag(name = "Conta", description = "Operações públicas e autenticadas relacionadas ao ciclo de vida da conta do usuário.")
@RestController
@RequestMapping("/api/v1/accounts")
public class AccountController {
private static final Logger logger = LoggerFactory.getLogger(AccountController.class);
private final AccountService accountService;
/**
* @param accountService service dos fluxos de conta
*/
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
/**
* <b>{@code POST /api/v1/accounts/register}</b> — registro público de conta.
*
* <ul>
* <li><b>Acesso:</b> público (regra de URL); sem {@code @PreAuthorize}.</li>
* <li><b>Entrada:</b> {@link UserRegisterRequest} com {@code @Valid} (Bean
* Validation antes do método: e-mail válido por DNS e único, senha forte sem
* dados pessoais, nomes).</li>
* <li><b>Fluxo:</b> {@code AccountService.register}: usuário inativo com
* {@code ROLE_OPERATOR}, token de ativação e e-mail de confirmação.</li>
* <li><b>Sucesso:</b> {@code 201 Created} com {@link UserResponse}; <b>não</b>
* envia cabeçalho {@code Location}.</li>
* <li><b>Erros:</b> {@code 422} (validação); {@code 409} (violação de unicidade
* no banco, por exemplo em corrida); {@code 500} (por exemplo, role padrão
* ausente). O {@code 400} declarado no OpenAPI não é produzido por este
* endpoint.</li>
* </ul>
*
* @param userRegisterRequest dados do registro
* @return resposta {@code 201} com o usuário criado (inativo)
* @throws MessagingException declarada, mas não lançada pela implementação atual
*/
@Operation(summary = "Registra um novo usuário", description = "Cria uma nova conta de usuário, envia o e-mail de confirmação e mantém a conta desativada até a confirmação do token recebido por e-mail.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Usuário criado com sucesso", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "400", description = "Requisição inválida", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "409", description = "Conflito de dados", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "422", description = "Erro de validação", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ValidationError.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@Valid @RequestBody UserRegisterRequest userRegisterRequest)
throws MessagingException {
logger.debug("Recebendo requisição de registro. email={}", userRegisterRequest.email());
UserResponse response = accountService.register(userRegisterRequest);
logger.info("Usuário criado com sucesso. id: {}", response.id());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
/**
* <b>{@code GET /api/v1/accounts/activate?token=...}</b> — confirma o e-mail e
* ativa a conta (operação que <b>altera estado usando {@code GET}</b>).
*
* <ul>
* <li><b>Acesso:</b> público (regra de URL).</li>
* <li><b>Entrada:</b> parâmetro de consulta {@code token} obrigatório; sem
* {@code @Valid}. Se o parâmetro faltar, o Spring lança uma exceção que,
* sem handler específico, vira {@code 500} (ver
* {@code ControllerExceptionHandler}).</li>
* <li><b>Fluxo:</b> {@code AccountService.confirmEmail}.</li>
* <li><b>Sucesso:</b> {@code 204 No Content}.</li>
* <li><b>Erros:</b> {@code 404} (token inexistente); {@code 400} (token de
* outro tipo, desabilitado ou expirado).</li>
* </ul>
*
* @param token valor do token de ativação recebido por e-mail
* @return resposta {@code 204}
*/
@Operation(summary = "Ativa uma conta de usuário", description = "Ativa a conta do usuário a partir do token recebido por e-mail. Se o token for inválido ou expirado, a operação retorna erro.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Conta ativada com sucesso"),
@ApiResponse(responseCode = "400", description = "Requisição inválida", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@GetMapping("/activate")
public ResponseEntity<Void> activateAccount(@RequestParam String token) {
accountService.confirmEmail(token);
return ResponseEntity.noContent().build();
}
/**
* <b>{@code POST /api/v1/accounts/resend-activation}</b> — reenvia o e-mail de
* ativação.
*
* <ul>
* <li><b>Acesso:</b> público (regra de URL).</li>
* <li><b>Entrada:</b> {@link UserEmailRequest} com {@code @Valid} (e-mail
* obrigatório e válido por DNS).</li>
* <li><b>Fluxo:</b> {@code AccountService.resendActivationEmail}.</li>
* <li><b>Sucesso:</b> {@code 204} <b>sempre</b>, inclusive quando o e-mail não
* existe ou a conta já está ativa (o service não faz nada nesses casos).</li>
* <li><b>Erros:</b> {@code 422} (validação).</li>
* </ul>
*
* @param request e-mail da conta
* @return resposta {@code 204}
* @throws MessagingException declarada, mas não lançada pela implementação atual
*/
@Operation(summary = "Reenvia o e-mail de ativação", description = "Gera um novo token de ativação e envia um novo e-mail de confirmação para usuários que ainda não ativaram a conta.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "E-mail de ativação reenviado com sucesso"),
@ApiResponse(responseCode = "400", description = "Requisição inválida", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "422", description = "Erro de validação", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ValidationError.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PostMapping("/resend-activation")
public ResponseEntity<Void> resendActivationEmail(@Valid @RequestBody UserEmailRequest request)
throws MessagingException {
logger.info("Solicitação de reenvio de ativação recebida. email={}", request.email());
accountService.resendActivationEmail(request.email());
return ResponseEntity.noContent().build();
}
/**
* <b>{@code POST /api/v1/accounts/password-recovery}</b> — solicita a
* recuperação de senha.
*
* <ul>
* <li><b>Acesso:</b> público (regra de URL).</li>
* <li><b>Entrada:</b> {@link UserEmailRequest} com {@code @Valid}.</li>
* <li><b>Fluxo:</b> {@code AccountService.requestPasswordRecovery}: se o usuário
* existir, cria token de recuperação e envia e-mail.</li>
* <li><b>Sucesso:</b> {@code 204} <b>sempre</b> (não revela se o e-mail
* existe).</li>
* <li><b>Erros:</b> {@code 422} (validação).</li>
* </ul>
*
* @param request e-mail da conta
* @return resposta {@code 204}
*/
@Operation(summary = "Solicita recuperação de senha", description = "Gera um token de recuperação de senha e envia as instruções para o e-mail informado.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Solicitação de recuperação recebida com sucesso"),
@ApiResponse(responseCode = "400", description = "Requisição inválida", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "422", description = "Erro de validação", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ValidationError.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PostMapping("/password-recovery")
public ResponseEntity<Void> requestPasswordRecovery(@Valid @RequestBody UserEmailRequest request) {
logger.info("Solicitação de recuperação de senha recebida. email={}", request.email());
accountService.requestPasswordRecovery(request.email());
return ResponseEntity.noContent().build();
}
/**
* <b>{@code POST /api/v1/accounts/reset-password}</b> — redefine a senha com o
* token de recuperação.
*
* <ul>
* <li><b>Acesso:</b> público (regra de URL).</li>
* <li><b>Entrada:</b> {@link PasswordResetRequest} com {@code @Valid} (token
* obrigatório; senha com {@code @StrongPassword}, sem {@code @Size}). O
* controller passa {@code token()} e {@code password()} ao service; o DTO não
* avança além do controller. O log deste método não registra os campos.</li>
* <li><b>Fluxo:</b> {@code AccountService.resetPassword}.</li>
* <li><b>Sucesso:</b> {@code 204}.</li>
* <li><b>Erros:</b> {@code 422} (validação); {@code 404} (token inexistente);
* {@code 400} (token inválido, desabilitado ou expirado).</li>
* </ul>
*
* @param request token e nova senha
* @return resposta {@code 204}
*/
@Operation(summary = "Redefine a senha", description = "Redefine a senha de um usuário a partir do token de recuperação enviado por e-mail.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Senha redefinida com sucesso"),
@ApiResponse(responseCode = "400", description = "Requisição inválida", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "422", description = "Erro de validação", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ValidationError.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PostMapping("/reset-password")
public ResponseEntity<Void> resetPassword(@Valid @RequestBody PasswordResetRequest request) {
logger.info("Solicitação de redefinição de senha recebida");
accountService.resetPassword(request.token(), request.password());
return ResponseEntity.noContent().build();
}
/**
* <b>{@code POST /api/v1/accounts/deactivate}</b> — <b>não implementado</b>.
*
* <ul>
* <li><b>Acesso:</b> a regra de URL é pública ({@code POST} em
* {@code /api/v1/accounts/**}); a proteção vem só de
* {@code @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")}. Sem token, o método
* é negado (resposta esperada {@code 403}, e não {@code 401}, a confirmar).</li>
* <li><b>Fluxo:</b> {@code AccountService.deactivateAccount}, que apenas lança
* {@link UnsupportedOperationException}.</li>
* <li><b>Resultado real para um usuário autenticado:</b> {@code 500}
* (tratado pelo handler genérico), com a exceção registrada em log. Nenhum dado
* é alterado. O {@code 204} descrito no OpenAPI nunca é produzido.</li>
* </ul>
*
* @return nunca retorna normalmente: a chamada ao service lança exceção
*/
@Operation(summary = "Desativa a conta do usuário", description = "Desativa a conta do usuário autenticado. A conta desativada não poderá mais ser utilizada para autenticação nem para acesso aos recursos protegidos.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Conta desativada com sucesso"),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PostMapping("/deactivate")
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
public ResponseEntity<Void> deactivateAccount() {
logger.info("Solicitação de desativação de conta recebida");
accountService.deactivateAccount();
return ResponseEntity.noContent().build();
}
/**
* <b>{@code PUT /api/v1/accounts/me}</b> — atualiza nome, sobrenome e e-mail do
* usuário do token.
*
* <ul>
* <li><b>Acesso:</b> autenticado por regra de URL ({@code PUT} não é público) e
* {@code @PreAuthorize("isAuthenticated()")} (redundante).</li>
* <li><b>Entrada:</b> {@link AuthenticatedUserUpdateRequest} com {@code @Valid}
* (e-mail com {@code @UniqueEmailForAuthenticatedUser}, que usa o usuário do
* JWT). O request não tem id: o usuário vem do claim {@code userId}.</li>
* <li><b>Fluxo:</b> {@code AccountService.updateAuthenticatedUser} (único ponto
* em que o e-mail é gravado normalizado).</li>
* <li><b>Sucesso:</b> {@code 200} com {@link UserResponse}.</li>
* <li><b>Erros:</b> {@code 422} (validação); {@code 401} se a identidade do JWT
* não puder ser resolvida.</li>
* </ul>
*
* @param request novos dados do perfil
* @return resposta {@code 200} com o usuário atualizado
*/
@Operation(summary = "Atualiza os dados do usuário autenticado", description = "Atualiza nome, sobrenome e e-mail da conta autenticada. Requer autenticação com Bearer Token.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Perfil atualizado com sucesso", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "422", description = "Erro de validação", content = @Content(schema = @Schema(implementation = ValidationError.class))),
@ApiResponse(responseCode = "500", description = "Erro interno", content = @Content(schema = @Schema(implementation = ProblemDetails.class)))
})
@PutMapping("/me")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<UserResponse> updateAuthenticatedUser(
@Valid @RequestBody AuthenticatedUserUpdateRequest request) {
logger.info("Atualizando perfil do usuário autenticado");
UserResponse response = accountService.updateAuthenticatedUser(request);
logger.info("Perfil atualizado com sucesso");
return ResponseEntity.ok(response);
}
/**
* <b>{@code PATCH /api/v1/accounts/me/password}</b> — troca a senha do usuário
* do token. É o único endpoint deste controller <b>sem anotações OpenAPI</b> e
* sem {@code @PreAuthorize}.
*
* <ul>
* <li><b>Acesso:</b> autenticado apenas pela regra de URL ({@code PATCH} não é
* público).</li>
* <li><b>Entrada:</b> {@link PasswordUpdateRequest} com {@code @Valid} (nova
* senha e confirmação de 10 a 72 caracteres; nova senha forte).</li>
* <li><b>Fluxo:</b> {@code AccountService.updatePassword} (confirmação igual,
* senha atual correta, nova diferente da atual).</li>
* <li><b>Sucesso:</b> {@code 204}.</li>
* <li><b>Erros:</b> {@code 422} (validação ou {@code PasswordUpdateException});
* {@code 401} se a identidade do JWT não puder ser resolvida.</li>
* </ul>
*
* @param request senha atual, nova senha e confirmação
* @return resposta {@code 204}
*/
@PatchMapping("/me/password")
public ResponseEntity<Void> updatePassword(@Valid @RequestBody PasswordUpdateRequest request) {
accountService.updatePassword(request);
return ResponseEntity.noContent().build();
}
/**
* <b>{@code GET /api/v1/accounts/me}</b> — devolve o usuário do token.
*
* <ul>
* <li><b>Acesso:</b> a regra de URL é <b>pública</b> ({@code GET} em
* {@code /api/v1/accounts/**}); a proteção vem só de
* {@code @PreAuthorize("isAuthenticated()")}. Sem token, o método é negado
* (resposta esperada {@code 403}, e não {@code 401}, a confirmar).</li>
* <li><b>Fluxo:</b> {@code AccountService.getAuthenticatedUser}.</li>
* <li><b>Sucesso:</b> {@code 200} com {@link UserResponse} (sem senha nem
* {@code active}).</li>
* <li><b>Erros:</b> {@code 401} se a identidade do JWT não puder ser
* resolvida.</li>
* </ul>
*
* @return resposta {@code 200} com os dados do usuário autenticado
*/
@Operation(summary = "Obtém o usuário autenticado", description = "Retorna os dados do usuário atualmente autenticado no sistema. Requer autenticação com Bearer Token.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Usuário retornado com sucesso", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<UserResponse> getAuthenticatedUser() {
UserResponse response = accountService.getAuthenticatedUser();
return ResponseEntity.ok(response);
}
}