package nl.andrewl.coyotecredit.service; import lombok.RequiredArgsConstructor; import nl.andrewl.coyotecredit.ctl.dto.InvitationData; import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload; import nl.andrewl.coyotecredit.ctl.dto.UserData; import nl.andrewl.coyotecredit.dao.AccountRepository; import nl.andrewl.coyotecredit.dao.ExchangeInvitationRepository; import nl.andrewl.coyotecredit.dao.UserActivationTokenRepository; import nl.andrewl.coyotecredit.dao.UserRepository; import nl.andrewl.coyotecredit.model.Account; import nl.andrewl.coyotecredit.model.Balance; import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.model.UserActivationToken; import nl.andrewl.coyotecredit.util.AccountNumberUtils; import nl.andrewl.coyotecredit.util.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final AccountRepository accountRepository; private final UserActivationTokenRepository activationTokenRepository; private final ExchangeInvitationRepository exchangeInvitationRepository; private final JavaMailSender mailSender; private final PasswordEncoder passwordEncoder; @Value("${coyote-credit.base-url}") private String baseUrl; @Transactional(readOnly = true) public UserData getUser(long userId, User requestingUser) { User user; if (requestingUser.getId().equals(userId)) { user = requestingUser; } else { user = userRepository.findById(userId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); } List exchangeInvitations = new ArrayList<>(); for (var invitation : exchangeInvitationRepository.findAllByUserEmail(user.getEmail())) { if (invitation.isExpired()) continue; exchangeInvitations.add(new InvitationData(invitation.getId(), invitation.getExchange().getId(), invitation.getExchange().getName())); } exchangeInvitations.sort(Comparator.comparing(InvitationData::id)); return new UserData(user.getId(), user.getUsername(), user.getEmail(), exchangeInvitations); } @Transactional public void registerUser(RegisterPayload payload) { if (userRepository.existsByUsername(payload.username())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is already taken."); } User user = new User(payload.username(), passwordEncoder.encode(payload.password()), payload.email()); user = userRepository.save(user); if (payload.inviteCode() != null) { var invite = exchangeInvitationRepository.findByCode(payload.inviteCode()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid invitation code.")); if (!invite.getUserEmail().equalsIgnoreCase(user.getEmail())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invitation code is for somebody else."); } if (invite.isExpired()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invitation code is expired."); } exchangeInvitationRepository.delete(invite); Account account = new Account(AccountNumberUtils.generate(), user, payload.accountName(), invite.getExchange()); account = accountRepository.save(account); for (var tradeable : invite.getExchange().getAllTradeables()) { account.getBalances().add(new Balance(account, tradeable, BigDecimal.ZERO)); } account = accountRepository.save(account); user.getAccounts().add(account); user.setActivated(true); user = userRepository.save(user); } else { String token = StringUtils.random(64); LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusHours(24); UserActivationToken activationToken = new UserActivationToken(token, user, expiresAt); activationTokenRepository.save(activationToken); try { sendActivationEmail(activationToken); } catch (MessagingException e) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not send activation email."); } } } @Transactional public void activateUser(String tokenString) { UserActivationToken token = activationTokenRepository.findById(tokenString) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid activation code.")); if (token.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Activation code is expired."); } token.getUser().setActivated(true); activationTokenRepository.delete(token); userRepository.save(token.getUser()); } private void sendActivationEmail(UserActivationToken token) throws MessagingException { MimeMessage msg = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(msg); helper.setFrom("Coyote Credit "); helper.setTo(token.getUser().getEmail()); helper.setSubject("Activate Your Account"); String activationUrl = baseUrl + "/activate?token=" + token.getToken(); helper.setText(String.format( """

In order to complete your account registration for Coyote Credit, please follow this link:

%s.

Note that this link will expire in 24 hours.

If you did not register for an account, or you are unaware of someone registering on your behalf, you may safely ignore this email.

""", activationUrl, activationUrl ), true); mailSender.send(msg); } @Scheduled(cron = "@midnight") @Transactional public void removeExpiredActivationTokens() { activationTokenRepository.deleteAllByExpiresAtBefore(LocalDateTime.now(ZoneOffset.UTC)); } }