Added user notifications, improved transfer interface, and some refactoring.

This commit is contained in:
Andrew Lalis 2022-02-25 13:40:46 +01:00
parent 2640115e50
commit 095bdc0c74
31 changed files with 345 additions and 46 deletions

View File

@ -0,0 +1,36 @@
package nl.andrewl.coyotecredit.config;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.dao.UserNotificationRepository;
import nl.andrewl.coyotecredit.model.User;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* A filter that fetches and sets the user's notification count, which is
* displayed in the header for easy checking.
*/
@Component
@RequiredArgsConstructor
public class UserNotificationSetFilter extends OncePerRequestFilter {
private final UserNotificationRepository notificationRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
User user = (User) auth.getPrincipal();
user.setNewNotificationCount(notificationRepository.countAllNewNotifications(user));
user.setHasNotifications(user.getNewNotificationCount() > 0);
}
filterChain.doFilter(request, response);
}
}

View File

@ -9,6 +9,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter;
@Configuration
@EnableWebSecurity
@ -20,6 +21,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final CCUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final UserNotificationSetFilter userNotificationSetFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
@ -43,6 +45,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
.logoutSuccessUrl("/login")
.deleteCookies("JSESSIONID");
http.addFilterAfter(this.userNotificationSetFilter, SecurityContextHolderAwareRequestFilter.class);
}
@Override

View File

@ -17,7 +17,7 @@ public class HomePage {
@GetMapping
public String get(Model model, @AuthenticationPrincipal User user) {
model.addAttribute("accounts", accountService.getAccountsOverview(user));
//model.addAttribute("accounts", accountService.getAccountsOverview(user));
return "home";
}
}

View File

@ -1,6 +0,0 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record ExchangeAccountData(
ExchangeData exchange,
SimpleAccountData account
) {}

View File

@ -1,5 +1,7 @@
package nl.andrewl.coyotecredit.ctl.dto;
import nl.andrewl.coyotecredit.ctl.exchange.dto.ExchangeData;
import java.util.List;
/**

View File

@ -1,10 +0,0 @@
package nl.andrewl.coyotecredit.ctl.dto;
import java.util.List;
public record UserData (
long id,
String username,
String email,
List<InvitationData> exchangeInvitations
) {}

View File

@ -1,9 +1,9 @@
package nl.andrewl.coyotecredit.ctl;
package nl.andrewl.coyotecredit.ctl.exchange;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.AddSupportedTradeablePayload;
import nl.andrewl.coyotecredit.ctl.dto.EditExchangePayload;
import nl.andrewl.coyotecredit.ctl.dto.InviteUserPayload;
import nl.andrewl.coyotecredit.ctl.exchange.dto.AddSupportedTradeablePayload;
import nl.andrewl.coyotecredit.ctl.exchange.dto.EditExchangePayload;
import nl.andrewl.coyotecredit.ctl.exchange.dto.InviteUserPayload;
import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.ExchangeService;
import org.springframework.data.domain.Pageable;

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto;
package nl.andrewl.coyotecredit.ctl.exchange.dto;
public record AddSupportedTradeablePayload(long tradeableId) {
}

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto;
package nl.andrewl.coyotecredit.ctl.exchange.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

View File

@ -1,4 +1,6 @@
package nl.andrewl.coyotecredit.ctl.dto;
package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import java.util.List;

View File

@ -0,0 +1,8 @@
package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.dto.SimpleAccountData;
public record ExchangeAccountData(
ExchangeData exchange,
SimpleAccountData account
) {}

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto;
package nl.andrewl.coyotecredit.ctl.exchange.dto;
public record ExchangeData(
long id,

View File

@ -1,4 +1,6 @@
package nl.andrewl.coyotecredit.ctl.dto;
package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import java.util.List;

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto;
package nl.andrewl.coyotecredit.ctl.exchange.dto;
public record InvitationData(
long id,

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto;
package nl.andrewl.coyotecredit.ctl.exchange.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

View File

@ -0,0 +1,29 @@
package nl.andrewl.coyotecredit.ctl.user;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.UserService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(path = "/userNotifications")
@RequiredArgsConstructor
public class NotificationController {
private final UserService userService;
@PostMapping(path = "/{notificationId}/dismiss")
public String dismiss(@PathVariable long notificationId, @AuthenticationPrincipal User user) {
userService.dismissNotification(user, notificationId);
return "redirect:/users/" + user.getId();
}
@PostMapping(path = "/dismissAll")
public String dismissAll(@AuthenticationPrincipal User user) {
userService.dismissAllNotifications(user);
return "redirect:/users/" + user.getId();
}
}

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl;
package nl.andrewl.coyotecredit.ctl.user;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.model.User;
@ -8,6 +8,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller

View File

@ -0,0 +1,13 @@
package nl.andrewl.coyotecredit.ctl.user.dto;
import nl.andrewl.coyotecredit.ctl.exchange.dto.InvitationData;
import java.util.List;
public record UserData (
long id,
String username,
String email,
List<InvitationData> exchangeInvitations,
List<UserNotificationData> newNotifications
) {}

View File

@ -0,0 +1,16 @@
package nl.andrewl.coyotecredit.ctl.user.dto;
import nl.andrewl.coyotecredit.model.UserNotification;
import java.time.format.DateTimeFormatter;
public record UserNotificationData(
long id,
String sentAt,
String content,
boolean dismissed
) {
public UserNotificationData(UserNotification n) {
this(n.getId(), n.getSentAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " UTC", n.getContent(), n.isDismissed());
}
}

View File

@ -0,0 +1,21 @@
package nl.andrewl.coyotecredit.dao;
import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.model.UserNotification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UserNotificationRepository extends JpaRepository<UserNotification, Long> {
@Query("SELECT un FROM UserNotification un " +
"WHERE un.user = :user AND un.dismissed = FALSE " +
"ORDER BY un.sentAt DESC")
List<UserNotification> findAllNewNotifications(User user);
@Query("SELECT COUNT(un) FROM UserNotification un " +
"WHERE un.user = :user AND un.dismissed = FALSE")
long countAllNewNotifications(User user);
}

View File

@ -4,6 +4,7 @@ import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.Formula;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@ -35,10 +36,12 @@ public class User implements UserDetails {
@Column(nullable = false)
private String email;
@Column(nullable = false)
@Setter
@Column(nullable = false) @Setter
private boolean activated = false;
@Column(nullable = false) @Setter
private boolean admin = false;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@ -48,6 +51,11 @@ public class User implements UserDetails {
@OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Account> accounts;
@Setter
private transient long newNotificationCount;
@Setter
private transient boolean hasNotifications;
public User(String username, String passwordHash, String email) {
this.username = username;
this.passwordHash = passwordHash;

View File

@ -0,0 +1,46 @@
package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/**
* Represents a notification that is shown to a user, and can be dismissed once
* the user has read it.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class UserNotification {
public static final int MAX_LENGTH = 2048;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private User user;
@Column(nullable = false, updatable = false)
private LocalDateTime sentAt;
@Column(nullable = false, updatable = false, length = MAX_LENGTH)
private String content;
@Column(nullable = false) @Setter
private boolean dismissed = false;
@Column @Setter
private LocalDateTime dismissedAt;
public UserNotification(User user, String content) {
this.user = user;
this.content = content;
this.sentAt = LocalDateTime.now(ZoneOffset.UTC);
}
}

View File

@ -2,6 +2,7 @@ package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.*;
import nl.andrewl.coyotecredit.ctl.exchange.dto.ExchangeData;
import nl.andrewl.coyotecredit.dao.*;
import nl.andrewl.coyotecredit.model.*;
import nl.andrewl.coyotecredit.util.AccountNumberUtils;
@ -30,6 +31,7 @@ public class AccountService {
private final TradeableRepository tradeableRepository;
private final TransferRepository transferRepository;
private final AccountValueSnapshotRepository valueSnapshotRepository;
private final UserNotificationRepository notificationRepository;
@Transactional(readOnly = true)
public List<BalanceData> getTransferData(long accountId, User user) {
@ -86,13 +88,31 @@ public class AccountService {
recipientBalance.setAmount(recipientBalance.getAmount().add(amount));
accountRepository.save(sender);
accountRepository.save(recipient);
transferRepository.save(new Transfer(
Transfer t = transferRepository.save(new Transfer(
sender.getNumber(),
recipient.getNumber(),
tradeable,
amount,
payload.message()
));
String senderMessage = String.format("You have sent %s %s to %s (%s) from your account in %s.",
amount.toPlainString(),
tradeable.getSymbol(),
recipient.getNumber(),
recipient.getName(),
sender.getExchange().getName());
String recipientMessage = String.format("You have received %s %s from %s (%s) in your account in %s.",
amount.toPlainString(),
tradeable.getSymbol(),
sender.getNumber(),
sender.getName(),
recipient.getExchange().getName());
if (t.getMessage() != null) {
recipientMessage += " Message: " + t.getMessage();
senderMessage += " Message: " + t.getMessage();
}
notificationRepository.save(new UserNotification(sender.getUser(), senderMessage));
notificationRepository.save(new UserNotification(recipient.getUser(), recipientMessage));
}
public static record AccountData (
@ -175,6 +195,13 @@ public class AccountService {
}
}
accountRepository.save(account);
notificationRepository.save(new UserNotification(
account.getUser(),
String.format("Your account %s in %s had its balances edited by %s.",
account.getNumber(),
account.getExchange().getName(),
userAccount.getName())
));
}
@Scheduled(cron = "@midnight")

View File

@ -2,6 +2,7 @@ package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.*;
import nl.andrewl.coyotecredit.ctl.exchange.dto.*;
import nl.andrewl.coyotecredit.dao.*;
import nl.andrewl.coyotecredit.model.*;
import nl.andrewl.coyotecredit.util.AccountNumberUtils;
@ -18,7 +19,6 @@ import org.springframework.web.server.ResponseStatusException;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.Subquery;
import java.math.BigDecimal;
@ -39,6 +39,7 @@ public class ExchangeService {
private final AccountValueSnapshotRepository accountValueSnapshotRepository;
private final UserRepository userRepository;
private final ExchangeInvitationRepository invitationRepository;
private final UserNotificationRepository notificationRepository;
private final JavaMailSender mailSender;
@Value("${coyote-credit.base-url}")
@ -129,6 +130,10 @@ public class ExchangeService {
}
accountValueSnapshotRepository.deleteAllByAccount(account);
accountRepository.delete(account);
notificationRepository.save(new UserNotification(
account.getUser(),
"Your account in " + exchange.getName() + " has been removed."
));
}
@Transactional(readOnly = true)
@ -299,6 +304,10 @@ public class ExchangeService {
account.getBalances().add(new Balance(account, t, BigDecimal.ZERO));
}
accountRepository.save(account);
notificationRepository.save(new UserNotification(
user,
"Congratulations! You've just joined " + exchange.getName() + "."
));
}
@Transactional
@ -391,6 +400,12 @@ public class ExchangeService {
if (bal != null) {
acc.getBalances().remove(bal);
accountRepository.save(acc);
notificationRepository.save(new UserNotification(
acc.getUser(),
String.format("Your balance of %s has been removed from your account in %s because the exchange no longer supports it.",
tradeable.getSymbol(),
exchange.getName())
));
}
}
exchangeRepository.save(exchange);

View File

@ -1,17 +1,12 @@
package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.InvitationData;
import nl.andrewl.coyotecredit.ctl.exchange.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.ctl.user.dto.UserData;
import nl.andrewl.coyotecredit.ctl.user.dto.UserNotificationData;
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;
@ -40,6 +35,7 @@ public class UserService {
private final AccountRepository accountRepository;
private final UserActivationTokenRepository activationTokenRepository;
private final ExchangeInvitationRepository exchangeInvitationRepository;
private final UserNotificationRepository notificationRepository;
private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder;
@ -60,7 +56,10 @@ public class UserService {
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);
var notifications = notificationRepository.findAllNewNotifications(user).stream()
.map(UserNotificationData::new)
.toList();
return new UserData(user.getId(), user.getUsername(), user.getEmail(), exchangeInvitations, notifications);
}
@Transactional
@ -140,4 +139,26 @@ public class UserService {
public void removeExpiredActivationTokens() {
activationTokenRepository.deleteAllByExpiresAtBefore(LocalDateTime.now(ZoneOffset.UTC));
}
@Transactional
public void dismissNotification(User user, long notificationId) {
UserNotification n = notificationRepository.findById(notificationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!n.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
n.setDismissed(true);
n.setDismissedAt(LocalDateTime.now(ZoneOffset.UTC));
notificationRepository.save(n);
}
@Transactional
public void dismissAllNotifications(User user) {
var notifications = notificationRepository.findAllNewNotifications(user);
for (var n : notifications) {
n.setDismissed(true);
n.setDismissedAt(LocalDateTime.now(ZoneOffset.UTC));
}
notificationRepository.saveAll(notifications);
}
}

View File

@ -4,6 +4,7 @@ spring.datasource.password=tester
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
spring.jpa.show-sql=false
spring.mail.host=127.0.0.1
spring.mail.port=1025

View File

@ -0,0 +1,19 @@
const tradeableSelect = document.getElementById("tradeableSelect");
const valueInput = document.getElementById("amountInput");
tradeableSelect.addEventListener("change", onSelectChanged);
function onSelectChanged() {
valueInput.value = null;
const option = tradeableSelect.options[tradeableSelect.selectedIndex];
const type = option.dataset.type;
const balance = Number(option.dataset.amount);
valueInput.setAttribute("max", "" + balance);
if (type === "STOCK") {
valueInput.setAttribute("step", 1);
valueInput.setAttribute("min", 1);
} else {
valueInput.setAttribute("step", 0.0000000001);
valueInput.setAttribute("min", 0.0000000001);
}
}

View File

@ -10,7 +10,15 @@
<form th:action="@{/accounts/{aId}/editBalances(aId=${accountId})}" th:method="post">
<div class="mb-3" th:each="bal, iter : ${account.balances()}">
<label th:for="${'tradeable-' + bal.id()}" class="form-label" th:text="${bal.symbol()}"></label>
<input type="number" min="0" th:value="${bal.amount()}" th:name="${'tradeable-' + bal.id()}" class="form-control" required/>
<input
class="form-control"
th:name="${'tradeable-' + bal.id()}"
th:value="${bal.amount()}"
type="number"
min="0"
th:step="${bal.type().equals('STOCK') ? '1' : '0.0000000001'}"
required
/>
</div>
<button type="submit" class="btn btn-success">Submit</button>
</form>

View File

@ -20,10 +20,13 @@
<div class="mb-3">
<label for="tradeableSelect" class="form-label">Asset</label>
<select class="form-select" id="tradeableSelect" name="tradeableId" required>
<option value="" selected disabled hidden>Choose something to send</option>
<option
th:each="b : ${balances}"
th:text="${b.symbol() + ' - Balance ' + b.amount()}"
th:value="${b.id()}"
th:data-amount="${b.amount()}"
th:data-type="${b.type()}"
></option>
</select>
</div>
@ -48,4 +51,6 @@
Warning! All transfers are final, and cannot be reversed.
</p>
</form>
<script src="/static/js/transfer.js"></script>
</div>

View File

@ -27,7 +27,14 @@
<a class="nav-link" th:href="@{/exchanges}">Exchanges</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/users/{id}(id=${#authentication.getPrincipal().getId()})}">My Profile</a>
<a class="nav-link position-relative" th:href="@{/users/{id}(id=${#authentication.getPrincipal().getId()})}">
My Profile
<span
th:if="${#authentication.getPrincipal().isHasNotifications()}"
th:text="${#authentication.getPrincipal().getNewNotificationCount()}"
class="badge rounded-pill bg-danger"
></span>
</a>
</li>
</ul>
<form class="d-flex" th:action="@{/logout}" th:method="post">

View File

@ -20,6 +20,31 @@
</tbody>
</table>
<div class="card text-white bg-dark mb-3">
<div class="card-body">
<div class="card-title d-flex justify-content-between">
<h5>Notifications</h5>
<form th:action="@{/userNotifications/dismissAll}" method="post" class="float-end">
<button type="submit" class="btn btn-sm btn-secondary">Dismiss All</button>
</form>
</div>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item list-group-item-dark" th:each="n : ${user.newNotifications()}">
<div class="d-flex justify-content-between">
<small th:text="${n.sentAt()}"></small>
<form th:action="@{/userNotifications/{nId}/dismiss(nId=${n.id()})}" method="post" class="float-end">
<button type="submit" class="btn btn-sm btn-secondary">Dismiss</button>
</form>
</div>
<p th:text="${n.content()}"></p>
</li>
<li class="list-group-item list-group-item-dark" th:if="${user.newNotifications().isEmpty()}">
You don't have any new notifications.
</li>
</ul>
</div>
<div class="card text-white bg-dark mb-3" th:if="${!user.exchangeInvitations().isEmpty()}">
<div class="card-body">
<h5 class="card-title">Exchange Invitations</h5>