Added user notifications, improved transfer interface, and some refactoring.
This commit is contained in:
		
							parent
							
								
									2640115e50
								
							
						
					
					
						commit
						095bdc0c74
					
				| 
						 | 
				
			
			@ -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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +0,0 @@
 | 
			
		|||
package nl.andrewl.coyotecredit.ctl.dto;
 | 
			
		||||
 | 
			
		||||
public record ExchangeAccountData(
 | 
			
		||||
		ExchangeData exchange,
 | 
			
		||||
		SimpleAccountData account
 | 
			
		||||
) {}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,7 @@
 | 
			
		|||
package nl.andrewl.coyotecredit.ctl.dto;
 | 
			
		||||
 | 
			
		||||
import nl.andrewl.coyotecredit.ctl.exchange.dto.ExchangeData;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
) {}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
package nl.andrewl.coyotecredit.ctl.dto;
 | 
			
		||||
package nl.andrewl.coyotecredit.ctl.exchange.dto;
 | 
			
		||||
 | 
			
		||||
public record AddSupportedTradeablePayload(long tradeableId) {
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
) {}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
package nl.andrewl.coyotecredit.ctl.dto;
 | 
			
		||||
package nl.andrewl.coyotecredit.ctl.exchange.dto;
 | 
			
		||||
 | 
			
		||||
public record ExchangeData(
 | 
			
		||||
		long id,
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
package nl.andrewl.coyotecredit.ctl.dto;
 | 
			
		||||
package nl.andrewl.coyotecredit.ctl.exchange.dto;
 | 
			
		||||
 | 
			
		||||
public record InvitationData(
 | 
			
		||||
		long id,
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
) {}
 | 
			
		||||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue