diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/ExchangeController.java b/src/main/java/nl/andrewl/coyotecredit/ctl/ExchangeController.java index f53d370..49838ea 100644 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/ExchangeController.java +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/ExchangeController.java @@ -2,6 +2,8 @@ package nl.andrewl.coyotecredit.ctl; import lombok.RequiredArgsConstructor; import nl.andrewl.coyotecredit.ctl.dto.AddAccountPayload; +import nl.andrewl.coyotecredit.ctl.dto.EditExchangePayload; +import nl.andrewl.coyotecredit.ctl.dto.InviteUserPayload; import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.service.ExchangeService; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -9,6 +11,8 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; +import javax.validation.Valid; + @Controller @RequestMapping(path = "/exchanges") @RequiredArgsConstructor @@ -33,16 +37,28 @@ public class ExchangeController { return "exchange/accounts"; } - @GetMapping(path = "/{exchangeId}/addAccount") - public String getAddAccountPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) { + @GetMapping(path = "/{exchangeId}/inviteUser") + public String getInviteUserPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) { exchangeService.ensureAdminAccount(exchangeId, user); - return "exchange/addAccount"; + return "exchange/invite_user"; } - @PostMapping(path = "/{exchangeId}/addAccount") - public String postAddAcount(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute AddAccountPayload payload) { - long accountId = exchangeService.addAccount(exchangeId, user, payload); - return "redirect:/accounts/" + accountId; + @PostMapping(path = "/{exchangeId}/inviteUser") + public String postInviteUser(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute InviteUserPayload payload) { + exchangeService.inviteUser(exchangeId, user, payload); + return "redirect:/exchanges/" + exchangeId; + } + + @PostMapping(path = "/{exchangeId}/acceptInvite/{inviteId}") + public String postAcceptInvite(@PathVariable long exchangeId, @PathVariable long inviteId, @AuthenticationPrincipal User user) { + exchangeService.acceptInvite(exchangeId, inviteId, user); + return "redirect:/exchanges/" + exchangeId; + } + + @PostMapping(path = "/{exchangeId}/rejectInvite/{inviteId}") + public String postRejectInvite(@PathVariable long exchangeId, @PathVariable long inviteId, @AuthenticationPrincipal User user) { + exchangeService.rejectInvite(exchangeId, inviteId, user); + return "redirect:/users/" + user.getId(); } @GetMapping(path = "/{exchangeId}/removeAccount/{accountId}") @@ -56,4 +72,16 @@ public class ExchangeController { exchangeService.removeAccount(exchangeId, accountId, user); return "redirect:/exchanges/" + exchangeId + "/accounts"; } + + @GetMapping(path = "/{exchangeId}/edit") + public String getEditPage(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) { + model.addAttribute("exchange", exchangeService.getData(exchangeId, user)); + return "exchange/edit"; + } + + @PostMapping(path = "/{exchangeId}/edit") + public String postEdit(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @Valid @ModelAttribute EditExchangePayload payload) { + exchangeService.edit(exchangeId, payload, user); + return "redirect:/exchanges/" + exchangeId; + } } diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/PublicPageController.java b/src/main/java/nl/andrewl/coyotecredit/ctl/PublicPageController.java index 4602e4c..1789d49 100644 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/PublicPageController.java +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/PublicPageController.java @@ -2,8 +2,11 @@ package nl.andrewl.coyotecredit.ctl; import lombok.RequiredArgsConstructor; import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload; +import nl.andrewl.coyotecredit.dao.ExchangeInvitationRepository; import nl.andrewl.coyotecredit.service.UserService; import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; @@ -13,14 +16,26 @@ import org.springframework.web.bind.annotation.RequestParam; @RequiredArgsConstructor public class PublicPageController { private final UserService userService; + private final ExchangeInvitationRepository invitationRepository; @GetMapping(path = "/login") public String getLoginPage() { return "public/login"; } - @GetMapping(path = "/register") - public String getRegisterPage() { + @GetMapping(path = "/register") @Transactional(readOnly = true) + public String getRegisterPage( + Model model, + @RequestParam(name = "inviteCode", required = false) String inviteCode + ) { + if (inviteCode != null && !inviteCode.isBlank()) { + invitationRepository.findByCode(inviteCode).ifPresent(invite -> { + model.addAttribute("inviteCode", inviteCode); + model.addAttribute("inviteUserEmail", invite.getUserEmail()); + model.addAttribute("inviteSenderName", invite.getSender().getName()); + model.addAttribute("inviteExchangeName", invite.getExchange().getName()); + }); + } return "public/register"; } diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddAccountPayload.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddAccountPayload.java deleted file mode 100644 index ee451e4..0000000 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddAccountPayload.java +++ /dev/null @@ -1,8 +0,0 @@ -package nl.andrewl.coyotecredit.ctl.dto; - -public record AddAccountPayload( - String name, - String email, - String username, - String password -) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/EditExchangePayload.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/EditExchangePayload.java new file mode 100644 index 0000000..a4310fd --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/EditExchangePayload.java @@ -0,0 +1,20 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.PositiveOrZero; + +public record EditExchangePayload( + @NotNull @NotBlank + String name, + String description, + boolean publiclyAccessible, + @PositiveOrZero + long primaryTradeableId, + + // Colors + String primaryBackgroundColor, + String secondaryBackgroundColor, + String primaryForegroundColor, + String secondaryForegroundColor +) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullExchangeData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullExchangeData.java index 55cad76..eff3a09 100644 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullExchangeData.java +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullExchangeData.java @@ -8,7 +8,13 @@ import java.util.List; public record FullExchangeData ( long id, String name, + String description, + boolean publiclyAccessible, TradeableData primaryTradeable, + String primaryBackgroundColor, + String secondaryBackgroundColor, + String primaryForegroundColor, + String secondaryForegroundColor, List supportedTradeables, String totalMarketValue, int accountCount, diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/InvitationData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/InvitationData.java new file mode 100644 index 0000000..b074f9e --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/InvitationData.java @@ -0,0 +1,7 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +public record InvitationData( + long id, + long exchangeId, + String exchangeName +) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/InviteUserPayload.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/InviteUserPayload.java new file mode 100644 index 0000000..e69817f --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/InviteUserPayload.java @@ -0,0 +1,10 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public record InviteUserPayload( + @NotBlank @NotNull + String email +) { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/RegisterPayload.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/RegisterPayload.java index 5e5f83b..9599b0a 100644 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/RegisterPayload.java +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/RegisterPayload.java @@ -3,5 +3,8 @@ package nl.andrewl.coyotecredit.ctl.dto; public record RegisterPayload ( String username, String email, - String password + String password, + // Invite data, this may be null. + String inviteCode, + String accountName ) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/SimpleAccountData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/SimpleAccountData.java index 932b17a..3a54c8a 100644 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/SimpleAccountData.java +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/SimpleAccountData.java @@ -2,6 +2,7 @@ package nl.andrewl.coyotecredit.ctl.dto; public record SimpleAccountData ( long id, + long userId, String number, String name, boolean admin, diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/UserData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/UserData.java index 9c22094..b6f0dd5 100644 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/UserData.java +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/UserData.java @@ -1,7 +1,10 @@ package nl.andrewl.coyotecredit.ctl.dto; +import java.util.List; + public record UserData ( long id, String username, - String email + String email, + List exchangeInvitations ) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/ExchangeInvitationRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/ExchangeInvitationRepository.java new file mode 100644 index 0000000..2d40ade --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/dao/ExchangeInvitationRepository.java @@ -0,0 +1,15 @@ +package nl.andrewl.coyotecredit.dao; + +import nl.andrewl.coyotecredit.model.ExchangeInvitation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ExchangeInvitationRepository extends JpaRepository { + Optional findByCode(String code); + + List findAllByUserEmail(String email); +} diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/UserRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/UserRepository.java index 75e9229..0ebe683 100644 --- a/src/main/java/nl/andrewl/coyotecredit/dao/UserRepository.java +++ b/src/main/java/nl/andrewl/coyotecredit/dao/UserRepository.java @@ -10,4 +10,6 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByUsername(String username); boolean existsByUsername(String username); + + Optional findByEmail(String email); } diff --git a/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java b/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java index e17f3a5..eb75a70 100644 --- a/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java +++ b/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java @@ -3,6 +3,7 @@ package nl.andrewl.coyotecredit.model; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import javax.persistence.*; import java.util.HashSet; @@ -23,15 +24,34 @@ public class Exchange { /** * The name for this exchange. */ - @Column(nullable = false) + @Column(nullable = false) @Setter private String name; /** * The primary tradeable that's used by this exchange. */ - @ManyToOne(optional = false, fetch = FetchType.LAZY) + @ManyToOne(optional = false, fetch = FetchType.LAZY) @Setter private Tradeable primaryTradeable; + /** + * A user-provided description for the exchange. + */ + @Column(length = 1024) @Setter + private String description; + + @Column(nullable = false) @Setter + private boolean publiclyAccessible; + + // Colors: + @Column @Setter + private String primaryBackgroundColor; + @Column @Setter + private String secondaryBackgroundColor; + @Column @Setter + private String primaryForegroundColor; + @Column @Setter + private String secondaryForegroundColor; + /** * The set of tradeables that this exchange allows users to interact with. */ diff --git a/src/main/java/nl/andrewl/coyotecredit/model/ExchangeInvitation.java b/src/main/java/nl/andrewl/coyotecredit/model/ExchangeInvitation.java new file mode 100644 index 0000000..9aacb8f --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/model/ExchangeInvitation.java @@ -0,0 +1,52 @@ +package nl.andrewl.coyotecredit.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** + * Represents an invitation which an admin can send to users (or any email + * address) to invite them to participate in an exchange. If no user with the + * given email address exists, then a registration code will be sent to the + * email address, which when present at the /register page, will give context + * to allow the user to be automatically added to the exchange. + */ +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ExchangeInvitation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Exchange exchange; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Account sender; + + @Column(nullable = false) + private String userEmail; + + @Column(nullable = false, unique = true) + private String code; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + public ExchangeInvitation(Exchange exchange, Account sender, String userEmail, String code, LocalDateTime expiresAt) { + this.exchange = exchange; + this.sender = sender; + this.userEmail = userEmail; + this.code = code; + this.expiresAt = expiresAt; + } + + public boolean isExpired() { + return getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC)); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/service/ExchangeService.java b/src/main/java/nl/andrewl/coyotecredit/service/ExchangeService.java index fee5f27..af3e007 100644 --- a/src/main/java/nl/andrewl/coyotecredit/service/ExchangeService.java +++ b/src/main/java/nl/andrewl/coyotecredit/service/ExchangeService.java @@ -5,21 +5,24 @@ import nl.andrewl.coyotecredit.ctl.dto.*; import nl.andrewl.coyotecredit.dao.*; import nl.andrewl.coyotecredit.model.*; 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.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.math.RoundingMode; import java.text.DecimalFormat; import java.time.LocalDateTime; import java.time.ZoneOffset; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; @Service @RequiredArgsConstructor @@ -29,8 +32,13 @@ public class ExchangeService { private final TransactionRepository transactionRepository; private final TradeableRepository tradeableRepository; private final UserRepository userRepository; + private final ExchangeInvitationRepository invitationRepository; + private final JavaMailSender mailSender; private final PasswordEncoder passwordEncoder; + @Value("${coyote-credit.base-url}") + private String baseUrl; + @Transactional(readOnly = true) public FullExchangeData getData(long exchangeId, User user) { Exchange e = exchangeRepository.findById(exchangeId) @@ -46,7 +54,13 @@ public class ExchangeService { return new FullExchangeData( e.getId(), e.getName(), + e.getDescription(), + e.isPubliclyAccessible(), new TradeableData(e.getPrimaryTradeable()), + e.getPrimaryBackgroundColor(), + e.getSecondaryBackgroundColor(), + e.getPrimaryForegroundColor(), + e.getSecondaryForegroundColor(), e.getAllTradeables().stream() .map(TradeableData::new) .sorted(Comparator.comparing(TradeableData::symbol)) @@ -72,6 +86,7 @@ public class ExchangeService { .sorted(Comparator.comparing(Account::getName)) .map(a -> new SimpleAccountData( a.getId(), + a.getUser().getId(), a.getNumber(), a.getName(), a.isAdmin(), @@ -203,9 +218,125 @@ public class ExchangeService { return accountRepository.findAllByUser(user).stream() .map(a -> new ExchangeAccountData( new ExchangeData(a.getExchange().getId(), a.getExchange().getName(), a.getExchange().getPrimaryTradeable().getSymbol()), - new SimpleAccountData(a.getId(), a.getNumber(), a.getName(), a.isAdmin(), TradeableData.DECIMAL_FORMAT.format(a.getTotalBalance())) + new SimpleAccountData( + a.getId(), + user.getId(), + a.getNumber(), + a.getName(), + a.isAdmin(), + TradeableData.DECIMAL_FORMAT.format(a.getTotalBalance()) + ) )) .sorted(Comparator.comparing(d -> d.exchange().name())) .toList(); } + + @Transactional + public void edit(long exchangeId, EditExchangePayload payload, User user) { + Exchange e = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account account = accountRepository.findByUserAndExchange(user, e) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!account.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + e.setName(payload.name()); + e.setDescription(payload.description()); + Tradeable primaryTradeable = tradeableRepository.findById(payload.primaryTradeableId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown primary tradeable currency.")); + if (!e.getAllTradeables().contains(primaryTradeable)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This exchange doesn't support " + primaryTradeable.getSymbol() + "."); + } + if (primaryTradeable.getType().equals(TradeableType.STOCK)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid primary tradeable currency. Stocks are not permitted."); + } + e.setPrimaryTradeable(primaryTradeable); + e.setPubliclyAccessible(payload.publiclyAccessible()); + + e.setPrimaryBackgroundColor(payload.primaryBackgroundColor()); + e.setSecondaryBackgroundColor(payload.secondaryBackgroundColor()); + e.setPrimaryForegroundColor(payload.primaryForegroundColor()); + e.setSecondaryForegroundColor(payload.secondaryForegroundColor()); + exchangeRepository.save(e); + } + + @Transactional + public void inviteUser(long exchangeId, User user, InviteUserPayload payload) { + Exchange exchange = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account account = accountRepository.findByUserAndExchange(user, exchange) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!account.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusDays(7); + ExchangeInvitation invitation = invitationRepository.save( + new ExchangeInvitation(exchange, account, payload.email(), StringUtils.random(64), expiresAt)); + Optional invitedUser = userRepository.findByEmail(payload.email()); + if (invitedUser.isEmpty()) { + try { + sendInvitationEmail(invitation); + } catch (MessagingException e) { + e.printStackTrace(); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not send invitation email."); + } + } + } + + @Transactional + public void acceptInvite(long exchangeId, long inviteId, User user) { + Exchange exchange = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + ExchangeInvitation invite = invitationRepository.findById(inviteId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!invite.getUserEmail().equalsIgnoreCase(user.getEmail())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for someone else."); + } + if (!invite.getExchange().getId().equals(exchangeId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for a different exchange."); + } + // If the user already has an account, silently delete the invite. + if (accountRepository.existsByUserAndExchange(user, exchange)) { + invitationRepository.delete(invite); + } + if (invite.isExpired()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is expired."); + } + // Create the account. + Account account = accountRepository.save(new Account(AccountNumberUtils.generate(), user, user.getUsername(), exchange)); + invitationRepository.delete(invite); + for (var t : exchange.getAllTradeables()) { + account.getBalances().add(new Balance(account, t, BigDecimal.ZERO)); + } + accountRepository.save(account); + } + + @Transactional + public void rejectInvite(long exchangeId, long inviteId, User user) { + ExchangeInvitation invite = invitationRepository.findById(inviteId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!invite.getUserEmail().equalsIgnoreCase(user.getEmail())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for someone else."); + } + if (!invite.getExchange().getId().equals(exchangeId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for a different exchange."); + } + invitationRepository.delete(invite); + } + + private void sendInvitationEmail(ExchangeInvitation invitation) throws MessagingException { + MimeMessage msg = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(msg); + helper.setFrom("Coyote Credit "); + helper.setTo(invitation.getUserEmail()); + helper.setSubject("Exchange Invitation"); + String url = baseUrl + "/register?inviteCode=" + invitation.getCode(); + helper.setText(String.format( + """ +

You have been invited by %s to join %s on Coyote Credit. + Click the link below to register an account.

+ %s + """, + invitation.getSender().getName(), + invitation.getExchange().getName(), + url, url + ), true); + mailSender.send(msg); + } } diff --git a/src/main/java/nl/andrewl/coyotecredit/service/UserService.java b/src/main/java/nl/andrewl/coyotecredit/service/UserService.java index cde61eb..c71cc2c 100644 --- a/src/main/java/nl/andrewl/coyotecredit/service/UserService.java +++ b/src/main/java/nl/andrewl/coyotecredit/service/UserService.java @@ -1,12 +1,18 @@ 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; @@ -20,14 +26,20 @@ 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; @@ -42,7 +54,13 @@ public class UserService { } else { user = userRepository.findById(userId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); } - return new UserData(user.getId(), user.getUsername(), user.getEmail()); + 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 @@ -52,6 +70,26 @@ public class UserService { } 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 = userRepository.save(user); + } + String token = StringUtils.random(64); LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusHours(24); UserActivationToken activationToken = new UserActivationToken(token, user, expiresAt); diff --git a/src/main/resources/templates/account/edit_balances.html b/src/main/resources/templates/account/edit_balances.html index 3b459fc..d470d06 100644 --- a/src/main/resources/templates/account/edit_balances.html +++ b/src/main/resources/templates/account/edit_balances.html @@ -2,7 +2,7 @@

Edit Balances

diff --git a/src/main/resources/templates/account/transfer.html b/src/main/resources/templates/account/transfer.html index 7f4e876..f23dd9b 100644 --- a/src/main/resources/templates/account/transfer.html +++ b/src/main/resources/templates/account/transfer.html @@ -2,7 +2,7 @@

Transfer

diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html index bafdfb7..c62a0bd 100644 --- a/src/main/resources/templates/error.html +++ b/src/main/resources/templates/error.html @@ -2,7 +2,7 @@
diff --git a/src/main/resources/templates/exchange/accounts.html b/src/main/resources/templates/exchange/accounts.html index 005dbd6..1c40184 100644 --- a/src/main/resources/templates/exchange/accounts.html +++ b/src/main/resources/templates/exchange/accounts.html @@ -19,14 +19,18 @@ - + - Remove + Remove - Add Account + Invite User
diff --git a/src/main/resources/templates/exchange/addAccount.html b/src/main/resources/templates/exchange/addAccount.html deleted file mode 100644 index eeb65f2..0000000 --- a/src/main/resources/templates/exchange/addAccount.html +++ /dev/null @@ -1,33 +0,0 @@ - - -
-

Add Account

-

- Use this page to add an account to the exchange. -

- -
-
- - -
-
- - -
-
- - -
-
- - -
- - -
-
diff --git a/src/main/resources/templates/exchange/edit.html b/src/main/resources/templates/exchange/edit.html new file mode 100644 index 0000000..369320b --- /dev/null +++ b/src/main/resources/templates/exchange/edit.html @@ -0,0 +1,85 @@ + + +
+

Edit Exchange Settings

+ +
+
+
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
diff --git a/src/main/resources/templates/exchange/exchange.html b/src/main/resources/templates/exchange/exchange.html index e936356..1505c0a 100644 --- a/src/main/resources/templates/exchange/exchange.html +++ b/src/main/resources/templates/exchange/exchange.html @@ -6,6 +6,7 @@ >

+

@@ -22,31 +23,39 @@
Number of Accounts
+ My Account
-

Tradeable Assets

- - - - - - - - - - - - - - - - - -
SymbolTypePrice (in USD)Name
+
+
+
Tradeable Assets
+ + + + + + + + + + + + + + + + + +
SymbolTypePrice (in USD)Name
+
+
-
- My Account - All Accounts +
+
+
Administrator Tools
+ View All Accounts + Edit Exchange Settings +
\ No newline at end of file diff --git a/src/main/resources/templates/exchange/invite_user.html b/src/main/resources/templates/exchange/invite_user.html new file mode 100644 index 0000000..ecc5114 --- /dev/null +++ b/src/main/resources/templates/exchange/invite_user.html @@ -0,0 +1,23 @@ + + +
+

Invite User

+

+ Invite someone to join your exchange. If they've already got an account, + their account will receive an invitation, or if not, they'll receive an + email so they can create an account. +

+ +
+
+ + +
+ + +
+
diff --git a/src/main/resources/templates/exchange/removeAccount.html b/src/main/resources/templates/exchange/removeAccount.html index 2a200e7..bc2bbd8 100644 --- a/src/main/resources/templates/exchange/removeAccount.html +++ b/src/main/resources/templates/exchange/removeAccount.html @@ -2,7 +2,7 @@

Remove Account

diff --git a/src/main/resources/templates/public/register.html b/src/main/resources/templates/public/register.html index 039c738..edcff0e 100644 --- a/src/main/resources/templates/public/register.html +++ b/src/main/resources/templates/public/register.html @@ -16,7 +16,15 @@
- +
@@ -24,7 +32,21 @@
- +
+ + + You have been invited by to join . + + + +
+ + + +

+ After registering, you will receive an email with a verification link. Please + click that link to verify your account before attempting to log in. +

diff --git a/src/main/resources/templates/tradeable.html b/src/main/resources/templates/tradeable.html index 52abbe9..1178ba4 100644 --- a/src/main/resources/templates/tradeable.html +++ b/src/main/resources/templates/tradeable.html @@ -2,7 +2,7 @@

diff --git a/src/main/resources/templates/user.html b/src/main/resources/templates/user.html index bb011f0..b48794a 100644 --- a/src/main/resources/templates/user.html +++ b/src/main/resources/templates/user.html @@ -19,4 +19,22 @@ + +
+
+
Exchange Invitations
+
+
    +
  • + + +
    + +
    +
    + +
    +
  • +
+