diff --git a/api/build.gradle b/api/build.gradle deleted file mode 100644 index 6e0638e..0000000 --- a/api/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -apply plugin: 'java' -apply plugin: 'application' -apply plugin: "io.spring.dependency-management" -apply plugin: "org.springframework.boot" - -repositories { - mavenLocal() - mavenCentral() - maven { - url = "http://repo.maven.apache.org/maven2" - } -} - -dependencies { - implementation "org.springframework.boot:spring-boot-starter-data-jpa" - implementation "org.springframework.boot:spring-boot-starter-security" - implementation "org.springframework.session:spring-session-jdbc" - implementation "org.springframework.boot:spring-boot-starter-web" - runtimeOnly "mysql:mysql-connector-java:8.0.15" - testImplementation "org.springframework.boot:spring-boot-starter-test" - testImplementation "org.springframework.security:spring-security-test:5.1.5.RELEASE" -} - -jar { - description = "twigs-server" -} - -mainClassName = "com.wbrawner.budgetserver.TwigsServerApplication" - -sourceCompatibility = 14 -targetCompatibility = 14 diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..80b3fae --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,43 @@ +import java.net.URI + +plugins { + java + kotlin("jvm") + id("org.springframework.boot") +} + +apply(plugin = "io.spring.dependency-management") + +repositories { + mavenLocal() + mavenCentral() + maven { + url = URI("http://repo.maven.apache.org/maven2") + } +} + +val kotlinVersion: String by rootProject.extra + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.session:spring-session-jdbc") + implementation("org.springframework.boot:spring-boot-starter-web") + runtimeOnly("mysql:mysql-connector-java:8.0.15") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test:5.1.5.RELEASE") +} + +description = "twigs-server" + +val twigsMain = "com.wbrawner.budgetserver.TwigsServerApplication" + +tasks.bootJar { + mainClassName = twigsMain +} + +tasks.bootRun { + main = twigsMain +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/ErrorResponse.java b/api/src/main/java/com/wbrawner/budgetserver/ErrorResponse.java deleted file mode 100644 index 3c544d8..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/ErrorResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.wbrawner.budgetserver; - -public class ErrorResponse { - private final String message; - - public ErrorResponse(String message) { - this.message = message; - } - - public String getMessage() { - return message; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/TwigsServerApplication.java b/api/src/main/java/com/wbrawner/budgetserver/TwigsServerApplication.java deleted file mode 100644 index 241ebf3..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/TwigsServerApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.wbrawner.budgetserver; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableScheduling; - -@SpringBootApplication -@EnableScheduling -public class TwigsServerApplication { - public static void main(String[] args) { - SpringApplication.run(TwigsServerApplication.class, args); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/Utils.java b/api/src/main/java/com/wbrawner/budgetserver/Utils.java deleted file mode 100644 index 907263c..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/Utils.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.wbrawner.budgetserver; - -import com.wbrawner.budgetserver.user.User; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.security.SecureRandom; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.Random; - -public final class Utils { - private static final int[] CALENDAR_FIELDS = new int[]{ - Calendar.MILLISECOND, - Calendar.SECOND, - Calendar.MINUTE, - Calendar.HOUR_OF_DAY, - Calendar.DATE - }; - - public static Date getFirstOfMonth() { - GregorianCalendar calendar = new GregorianCalendar(); - for (int field : CALENDAR_FIELDS) { - calendar.set(field, calendar.getActualMinimum(field)); - } - return calendar.getTime(); - } - - public static Date getEndOfMonth() { - GregorianCalendar calendar = new GregorianCalendar(); - for (int field : CALENDAR_FIELDS) { - calendar.set(field, calendar.getActualMaximum(field)); - } - return calendar.getTime(); - } - - public static Date twoWeeksFromNow() { - GregorianCalendar calendar = new GregorianCalendar(); - calendar.add(Calendar.DATE, 14); - return calendar.getTime(); - } - - public static User getCurrentUser() { - Object user = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (user instanceof User) { - return (User) user; - } - - return null; - } - - private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - private static final Random random = new SecureRandom(); - - public static String randomString(int length) { - StringBuilder id = new StringBuilder(); - for (int i = 0; i < length; i++) { - id.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length()))); - } - return id.toString(); - } - - public static String randomId() { - return randomString(32); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/budget/Budget.java b/api/src/main/java/com/wbrawner/budgetserver/budget/Budget.java deleted file mode 100644 index 1b87607..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/budget/Budget.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.wbrawner.budgetserver.budget; - -import com.wbrawner.budgetserver.category.Category; -import com.wbrawner.budgetserver.transaction.Transaction; - -import javax.persistence.*; -import java.util.HashSet; -import java.util.Set; -import java.util.TreeSet; - -import static com.wbrawner.budgetserver.Utils.randomId; - -@Entity -public class Budget { - @Id - private String id = randomId(); - private String name; - private String description; - private String currencyCode; - @OneToMany(mappedBy = "budget") - private final Set transactions = new TreeSet<>(); - @OneToMany(mappedBy = "budget") - private final Set categories = new TreeSet<>(); - @OneToMany(mappedBy = "budget") - private final Set users = new HashSet<>(); - - public Budget() {} - - public Budget(String name, String description) { - this(name, description, "USD"); - } - - public Budget(String name, String description, String currencyCode) { - this.name = name; - this.description = description; - this.currencyCode = currencyCode; - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getCurrencyCode() { - return currencyCode; - } - - public void setCurrencyCode(String currencyCode) { - this.currencyCode = currencyCode; - } - - public Set getTransactions() { - return transactions; - } - - public Set getCategories() { - return categories; - } - - public Set getUsers() { - return users; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetBalanceResponse.java b/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetBalanceResponse.java deleted file mode 100644 index f98666b..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetBalanceResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.wbrawner.budgetserver.budget; - -public class BudgetBalanceResponse { - public final String id; - public final long balance; - - public BudgetBalanceResponse(String id, long balance) { - this.id = id; - this.balance = balance; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetController.java b/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetController.java deleted file mode 100644 index e4a1e04..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetController.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.wbrawner.budgetserver.budget; - -import com.wbrawner.budgetserver.permission.Permission; -import com.wbrawner.budgetserver.permission.UserPermission; -import com.wbrawner.budgetserver.permission.UserPermissionRepository; -import com.wbrawner.budgetserver.transaction.TransactionRepository; -import com.wbrawner.budgetserver.user.User; -import com.wbrawner.budgetserver.user.UserRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.data.domain.PageRequest; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import javax.transaction.Transactional; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static com.wbrawner.budgetserver.Utils.getCurrentUser; - -@RestController -@RequestMapping(value = "/budgets") -@Transactional -public class BudgetController { - private final BudgetRepository budgetRepository; - private final TransactionRepository transactionRepository; - private final UserRepository userRepository; - private final UserPermissionRepository userPermissionsRepository; - private final Logger logger = LoggerFactory.getLogger(BudgetController.class); - - public BudgetController( - BudgetRepository budgetRepository, - TransactionRepository transactionRepository, - UserRepository userRepository, - UserPermissionRepository userPermissionsRepository - ) { - this.budgetRepository = budgetRepository; - this.transactionRepository = transactionRepository; - this.userRepository = userRepository; - this.userPermissionsRepository = userPermissionsRepository; - } - - @GetMapping(value = "", produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity> getBudgets(Integer page, Integer count) { - User user = getCurrentUser(); - if (user == null) { - return ResponseEntity.status(401).build(); - } - - List budgets = userPermissionsRepository.findAllByUser( - getCurrentUser(), - PageRequest.of( - page != null ? page : 0, - count != null ? count : 1000 - ) - ) - .stream() - .map(userPermission -> { - Budget budget = userPermission.getBudget(); - if (budget == null) { - return null; - } -// Hibernate.initialize(budget); - return new BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null)); - }) - .collect(Collectors.toList()); - - return ResponseEntity.ok(budgets); - } - - @GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity getBudget(@PathVariable String id) { - return getBudgetWithPermission(id, Permission.READ, (budget) -> - ResponseEntity.ok(new BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null))) - ); - } - - @GetMapping(value = "/{id}/balance", produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity getBudgetBalance( - @PathVariable String id, - @RequestParam(value = "from", required = false) String from, - @RequestParam(value = "to", required = false) String to - ) { - return getBudgetWithPermission(id, Permission.READ, (budget) -> { - Instant fromInstant; - try { - fromInstant = Instant.parse(from); - } catch (Exception e) { - if (!(e instanceof NullPointerException)) - logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e); - fromInstant = Instant.ofEpochSecond(0); - } - Instant toInstant; - try { - toInstant = Instant.parse(to); - } catch (Exception e) { - if (!(e instanceof NullPointerException)) - logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e); - toInstant = Instant.now(); - } - var balance = transactionRepository.sumBalanceByBudgetId(budget.getId(), fromInstant, toInstant); - return ResponseEntity.ok(new BudgetBalanceResponse(budget.getId(), balance)); - }); - } - - @PostMapping(value = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity newBudget(@RequestBody BudgetRequest request) { - final var budget = budgetRepository.save(new Budget(request.name, request.description)); - var users = request.getUsers() - .stream() - .map(userPermissionRequest -> { - var user = userRepository.findById(userPermissionRequest.getUser()).orElse(null); - if (user == null) { - return null; - } - - return userPermissionsRepository.save( - new UserPermission(budget, user, userPermissionRequest.getPermission()) - ); - }) - .collect(Collectors.toSet()); - - var currentUserIncluded = users.stream().anyMatch(userPermission -> - userPermission.getUser().getId().equals(getCurrentUser().getId()) - ); - if (!currentUserIncluded) { - users.add( - userPermissionsRepository.save( - new UserPermission(budget, getCurrentUser(), Permission.OWNER) - ) - ); - } - return ResponseEntity.ok(new BudgetResponse(budget, new ArrayList<>(users))); - } - - @PutMapping(value = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity updateBudget(@PathVariable String id, @RequestBody BudgetRequest request) { - return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> { - if (request.name != null) { - budget.setName(request.name); - } - - if (request.description != null) { - budget.setDescription(request.description); - } - - var users = new ArrayList(); - if (!request.getUsers().isEmpty()) { - request.getUsers().forEach(userPermissionRequest -> - userRepository.findById(userPermissionRequest.getUser()).ifPresent(requestedUser -> - users.add(userPermissionsRepository.save( - new UserPermission( - budget, - requestedUser, - userPermissionRequest.getPermission() - ) - )) - )); - } else { - users.addAll(userPermissionsRepository.findAllByBudget(budget, null)); - } - - return ResponseEntity.ok(new BudgetResponse(budgetRepository.save(budget), users)); - }); - } - - @DeleteMapping(value = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) - public ResponseEntity deleteBudget(@PathVariable String id) { - return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> { - budgetRepository.delete(budget); - return ResponseEntity.ok().build(); - }); - } - - private ResponseEntity getBudgetWithPermission( - String budgetId, - Permission permission, - Function> callback - ) { - var user = getCurrentUser(); - if (user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - - var userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null); - if (userPermission == null) { - return ResponseEntity.notFound().build(); - } - - if (userPermission.getPermission().isNotAtLeast(permission)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - - var budget = userPermission.getBudget(); - if (budget == null) { - return ResponseEntity.notFound().build(); - } - - return callback.apply(budget); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRepository.java b/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRepository.java deleted file mode 100644 index a1c5460..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.wbrawner.budgetserver.budget; - -import org.springframework.data.repository.PagingAndSortingRepository; - -public interface BudgetRepository extends PagingAndSortingRepository { -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRequest.java b/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRequest.java deleted file mode 100644 index e7e3b67..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.wbrawner.budgetserver.budget; - -import com.wbrawner.budgetserver.permission.UserPermissionRequest; - -import javax.validation.constraints.NotNull; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -public class BudgetRequest { - public final String name; - public final String description; - private final Set users = new HashSet<>(); - - public BudgetRequest() { - // Required empty constructor - this("", "", Collections.emptySet()); - } - - public BudgetRequest(String name, String description, Set users) { - this.name = name; - this.description = description; - this.users.addAll(users); - } - - @NotNull - public Set getUsers() { - return Set.copyOf(users); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetResponse.java b/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetResponse.java deleted file mode 100644 index f2eaad1..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.wbrawner.budgetserver.budget; - -import com.wbrawner.budgetserver.permission.UserPermission; -import com.wbrawner.budgetserver.permission.UserPermissionResponse; -import com.wbrawner.budgetserver.user.User; - -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -public class BudgetResponse { - public final String id; - public final String name; - public final String description; - private final List users; - - public BudgetResponse(String id, String name, String description, List users) { - this.id = id; - this.name = name; - this.description = description; - this.users = users; - } - - public BudgetResponse(Budget budget, List users) { - this( - Objects.requireNonNull(budget.getId()), - budget.getName(), - budget.getDescription(), - users.stream() - .map(UserPermissionResponse::new) - .collect(Collectors.toList()) - ); - } - - public List getUsers() { - return List.copyOf(users); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/Category.java b/api/src/main/java/com/wbrawner/budgetserver/category/Category.java deleted file mode 100644 index ce1b964..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/category/Category.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.wbrawner.budgetserver.category; - -import com.wbrawner.budgetserver.budget.Budget; - -import javax.persistence.*; -import java.util.UUID; - -import static com.wbrawner.budgetserver.Utils.randomId; - -@Entity -public class Category implements Comparable { - @Id - private final String id = randomId(); - private String title; - private String description; - private long amount; - @JoinColumn(nullable = false) - @ManyToOne - private Budget budget; - private boolean expense; - @Column(nullable = false, columnDefinition = "boolean default false") - private boolean archived; - - public Category() { - this(null, null, 0L, null, true); - } - - public Category( - String title, - String description, - Long amount, - Budget budget, - boolean expense - ) { - this.title = title; - this.description = description; - this.amount = amount; - this.budget = budget; - this.expense = expense; - } - - @Override - public int compareTo(Category other) { - return title.compareTo(other.title); - } - - public String getId() { - return id; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public long getAmount() { - return amount; - } - - public void setAmount(long amount) { - this.amount = amount; - } - - public Budget getBudget() { - return budget; - } - - public void setBudget(Budget budget) { - this.budget = budget; - } - - public boolean isExpense() { - return expense; - } - - public void setExpense(boolean expense) { - this.expense = expense; - } - - public boolean isArchived() { - return archived; - } - - public void setArchived(boolean archived) { - this.archived = archived; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryBalanceResponse.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryBalanceResponse.java deleted file mode 100644 index e8243c0..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryBalanceResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.wbrawner.budgetserver.category; - -public class CategoryBalanceResponse { - private final String id; - private final long balance; - - public CategoryBalanceResponse(String id, long balance) { - this.id = id; - this.balance = balance; - } - - public String getId() { - return id; - } - - public long getBalance() { - return balance; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryController.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryController.java deleted file mode 100644 index 819c499..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryController.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.wbrawner.budgetserver.category; - -import com.wbrawner.budgetserver.ErrorResponse; -import com.wbrawner.budgetserver.permission.Permission; -import com.wbrawner.budgetserver.permission.UserPermission; -import com.wbrawner.budgetserver.permission.UserPermissionRepository; -import com.wbrawner.budgetserver.transaction.TransactionRepository; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import javax.transaction.Transactional; -import java.util.List; -import java.util.stream.Collectors; - -import static com.wbrawner.budgetserver.Utils.getCurrentUser; -import static com.wbrawner.budgetserver.Utils.getFirstOfMonth; - -@RestController -@RequestMapping(path = "/categories") -@Transactional -class CategoryController { - private final CategoryRepository categoryRepository; - private final TransactionRepository transactionRepository; - private final UserPermissionRepository userPermissionsRepository; - - CategoryController(CategoryRepository categoryRepository, - TransactionRepository transactionRepository, - UserPermissionRepository userPermissionsRepository) { - this.categoryRepository = categoryRepository; - this.transactionRepository = transactionRepository; - this.userPermissionsRepository = userPermissionsRepository; - } - - @GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity> getCategories( - @RequestParam(name = "budgetIds", required = false) List budgetIds, - @RequestParam(name = "isExpense", required = false) Boolean isExpense, - @RequestParam(name = "includeArchived", required = false) Boolean includeArchived, - @RequestParam(name = "count", required = false) Integer count, - @RequestParam(name = "page", required = false) Integer page, - @RequestParam(name = "false", required = false) String sortBy, - @RequestParam(name = "sortOrder", required = false) Sort.Direction sortOrder - ) { - List userPermissions; - if (budgetIds != null && !budgetIds.isEmpty()) { - userPermissions = userPermissionsRepository.findAllByUserAndBudget_IdIn( - getCurrentUser(), - budgetIds, - PageRequest.of(page != null ? page : 0, count != null ? count : 1000) - ); - } else { - userPermissions = userPermissionsRepository.findAllByUser(getCurrentUser(), null); - } - var budgets = userPermissions.stream() - .map(UserPermission::getBudget) - .collect(Collectors.toList()); - - var pageRequest = PageRequest.of( - Math.min(0, page != null ? page - 1 : 0), - count != null ? count : 1000, - sortOrder != null ? sortOrder : Sort.Direction.ASC, - sortBy != null ? sortBy : "title" - ); - Boolean archived = includeArchived == null || includeArchived == false ? false : null; - List categories = categoryRepository.findAllByBudgetIn(budgets, isExpense, archived, pageRequest); - return ResponseEntity.ok( - categories.stream() - .map(CategoryResponse::new) - .collect(Collectors.toList()) - ); - } - - @GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity getCategory(@PathVariable String id) { - var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null) - .stream() - .map(UserPermission::getBudget) - .collect(Collectors.toList()); - var category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null); - if (category == null) return ResponseEntity.notFound().build(); - return ResponseEntity.ok(new CategoryResponse(category)); - } - - @GetMapping(path = "/{id}/balance", produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity getCategoryBalance(@PathVariable String id) { - var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null) - .stream() - .map(UserPermission::getBudget) - .collect(Collectors.toList()); - var category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null); - if (category == null) { - return ResponseEntity.notFound().build(); - } - var sum = transactionRepository.sumBalanceByCategoryId(category.getId(), getFirstOfMonth()); - return ResponseEntity.ok(new CategoryBalanceResponse(category.getId(), sum)); - } - - @PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity newCategory(@RequestBody NewCategoryRequest request) { - var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()) - .orElse(null); - if (userResponse == null) { - return ResponseEntity.badRequest().body(new ErrorResponse("Invalid budget ID")); - } - if (userResponse.getPermission().isNotAtLeast(Permission.WRITE)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - var budget = userResponse.getBudget(); - return ResponseEntity.ok(new CategoryResponse(categoryRepository.save(new Category( - request.getTitle(), - request.getDescription(), - request.getAmount(), - budget, - request.getExpense() - )))); - } - - @PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity updateCategory(@PathVariable String id, @RequestBody UpdateCategoryRequest request) { - var category = categoryRepository.findById(id).orElse(null); - if (category == null) return ResponseEntity.notFound().build(); - var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null); - if (userPermission == null) return ResponseEntity.notFound().build(); - if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - if (request.getTitle() != null) { - category.setTitle(request.getTitle()); - } - if (request.getDescription() != null) { - category.setDescription(request.getDescription()); - } - if (request.getAmount() != null) { - category.setAmount(request.getAmount()); - } - if (request.getExpense() != null) { - category.setExpense(request.getExpense()); - } - if (request.getArchived() != null) { - category.setArchived(request.getArchived()); - } - return ResponseEntity.ok(new CategoryResponse(categoryRepository.save(category))); - } - - @DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) - ResponseEntity deleteCategory(@PathVariable String id) { - var category = categoryRepository.findById(id).orElse(null); - if (category == null) return ResponseEntity.notFound().build(); - var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null); - if (userPermission == null) return ResponseEntity.notFound().build(); - if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - transactionRepository.findAllByBudgetAndCategory(userPermission.getBudget(), category) - .forEach(transaction -> { - transaction.setCategory(null); - transactionRepository.save(transaction); - }); - categoryRepository.delete(category); - return ResponseEntity.ok().build(); - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryRepository.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryRepository.java deleted file mode 100644 index 91000f3..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.wbrawner.budgetserver.category; - -import com.wbrawner.budgetserver.budget.Budget; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.PagingAndSortingRepository; - -import java.util.List; -import java.util.Optional; - -public interface CategoryRepository extends PagingAndSortingRepository { - List findAllByBudget(Budget budget, Pageable pageable); - - @Query("SELECT c FROM Category c where c.budget IN (:budgets) AND (:expense IS NULL OR c.expense = :expense) AND (:archived IS NULL OR c.archived = :archived)") - List findAllByBudgetIn(List budgets, Boolean expense, Boolean archived, Pageable pageable); - - Optional findByBudgetInAndId(List budgets, String id); - - Optional findByBudgetAndId(Budget budget, String id); - - List findAllByBudgetInAndIdIn(List budgets, List ids, Pageable pageable); -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryResponse.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryResponse.java deleted file mode 100644 index 8a17ec6..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryResponse.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.wbrawner.budgetserver.category; - -import java.util.Objects; - -public class CategoryResponse { - private final String id; - private final String title; - private final String description; - private final long amount; - private final String budgetId; - private final boolean expense; - private final boolean archived; - - public CategoryResponse(Category category) { - this( - Objects.requireNonNull(category.getId()), - category.getTitle(), - category.getDescription(), - category.getAmount(), - Objects.requireNonNull(category.getBudget()).getId(), - category.isExpense(), - category.isArchived() - ); - } - - public CategoryResponse(String id, String title, String description, long amount, String budgetId, boolean expense, boolean archived) { - this.id = id; - this.title = title; - this.description = description; - this.amount = amount; - this.budgetId = budgetId; - this.expense = expense; - this.archived = archived; - } - - public String getId() { - return id; - } - - public String getTitle() { - return title; - } - - public String getDescription() { - return description; - } - - public long getAmount() { - return amount; - } - - public String getBudgetId() { - return budgetId; - } - - public boolean isExpense() { - return expense; - } - - public boolean isArchived() { - return archived; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/NewCategoryRequest.java b/api/src/main/java/com/wbrawner/budgetserver/category/NewCategoryRequest.java deleted file mode 100644 index fa7dc73..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/category/NewCategoryRequest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.wbrawner.budgetserver.category; - -public class NewCategoryRequest { - private final String title; - private final String description; - private final Long amount; - private final String budgetId; - private final Boolean expense; - - public NewCategoryRequest() { - this(null, null, null, null, null); - } - - public NewCategoryRequest(String title, String description, Long amount, String budgetId, Boolean expense) { - this.title = title; - this.description = description; - this.amount = amount; - this.budgetId = budgetId; - this.expense = expense; - } - - public String getTitle() { - return title; - } - - public String getDescription() { - return description; - } - - public Long getAmount() { - return amount; - } - - public String getBudgetId() { - return budgetId; - } - - public Boolean getExpense() { - return expense; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/UpdateCategoryRequest.java b/api/src/main/java/com/wbrawner/budgetserver/category/UpdateCategoryRequest.java deleted file mode 100644 index f69b72f..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/category/UpdateCategoryRequest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.wbrawner.budgetserver.category; - -public class UpdateCategoryRequest { - private final String title; - private final String description; - private final Long amount; - private final Boolean expense; - private final Boolean archived; - - public UpdateCategoryRequest() { - this(null, null, null, null, null); - } - - public UpdateCategoryRequest(String title, String description, Long amount, Boolean expense, Boolean archived) { - this.title = title; - this.description = description; - this.amount = amount; - this.expense = expense; - this.archived = archived; - } - - public String getTitle() { - return title; - } - - public String getDescription() { - return description; - } - - public Long getAmount() { - return amount; - } - - public Boolean getExpense() { - return expense; - } - - public Boolean getArchived() { - return archived; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/JdbcUserDetailsService.java b/api/src/main/java/com/wbrawner/budgetserver/config/JdbcUserDetailsService.java deleted file mode 100644 index 0e18b4f..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/config/JdbcUserDetailsService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.wbrawner.budgetserver.config; - -import com.wbrawner.budgetserver.user.UserRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Component; - -@Component -public class JdbcUserDetailsService implements UserDetailsService { - - private final UserRepository userRepository; - - @Autowired - public JdbcUserDetailsService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - UserDetails userDetails; - userDetails = userRepository.findByUsername(username).orElse(null); - if (userDetails != null) { - return userDetails; - } - userDetails = userRepository.findByEmail(username).orElse(null); - if (userDetails != null) { - return userDetails; - } - throw new UsernameNotFoundException("Unable to find user with username $username"); - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/MethodSecurity.java b/api/src/main/java/com/wbrawner/budgetserver/config/MethodSecurity.java deleted file mode 100644 index c1b784a..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/config/MethodSecurity.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.wbrawner.budgetserver.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; -import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration; - -@Configuration -@EnableGlobalMethodSecurity(prePostEnabled = true) -public class MethodSecurity extends GlobalMethodSecurityConfiguration { -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/SecurityConfig.java b/api/src/main/java/com/wbrawner/budgetserver/config/SecurityConfig.java deleted file mode 100644 index 287355a..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/config/SecurityConfig.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.wbrawner.budgetserver.config; - -import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository; -import com.wbrawner.budgetserver.session.UserSessionRepository; -import com.wbrawner.budgetserver.user.UserRepository; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -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.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.provisioning.JdbcUserDetailsManager; -import org.springframework.web.cors.CorsConfiguration; - -import javax.sql.DataSource; -import java.util.Arrays; -import java.util.stream.Collectors; -import java.util.stream.Stream; - - -@Configuration -@EnableWebSecurity -public class SecurityConfig extends WebSecurityConfigurerAdapter { - - private final Environment env; - private final DataSource datasource; - private final UserSessionRepository userSessionRepository; - private final UserRepository userRepository; - private final PasswordResetRequestRepository passwordResetRequestRepository; - private final JdbcUserDetailsService userDetailsService; - private final Environment environment; - - public SecurityConfig(Environment env, - DataSource datasource, - UserSessionRepository userSessionRepository, - UserRepository userRepository, - PasswordResetRequestRepository passwordResetRequestRepository, - JdbcUserDetailsService userDetailsService, - Environment environment) { - this.env = env; - this.datasource = datasource; - this.userSessionRepository = userSessionRepository; - this.userRepository = userRepository; - this.passwordResetRequestRepository = passwordResetRequestRepository; - this.userDetailsService = userDetailsService; - this.environment = environment; - } - - @Bean - public JdbcUserDetailsManager getUserDetailsManager() { - var userDetailsManager = new JdbcUserDetailsManager(); - userDetailsManager.setDataSource(datasource); - return userDetailsManager; - } - - @Bean - public DaoAuthenticationProvider getAuthenticationProvider() { - var authProvider = new TokenAuthenticationProvider(userSessionRepository, userRepository); - authProvider.setPasswordEncoder(getPasswordEncoder()); - authProvider.setUserDetailsService(userDetailsService); - return authProvider; - } - - @Bean - public PasswordEncoder getPasswordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Override - public void configure(AuthenticationManagerBuilder auth) { - auth.authenticationProvider(getAuthenticationProvider()); - } - - @Override - public void configure(HttpSecurity http) throws Exception { - http.authorizeRequests() - .antMatchers("/users/new", "/users/login") - .permitAll() - .anyRequest() - .authenticated() - .and() - .httpBasic() - .authenticationEntryPoint(new SilentAuthenticationEntryPoint()) - .and() - .cors() - .configurationSource(request -> { - var corsConfig = new CorsConfiguration(); - corsConfig.applyPermitDefaultValues(); - var corsDomains = environment.getProperty("twigs.cors.domains", "*"); - corsConfig.setAllowedOrigins(Arrays.asList(corsDomains.split(","))); - corsConfig.setAllowedMethods( - Stream.of( - HttpMethod.GET, - HttpMethod.POST, - HttpMethod.PUT, - HttpMethod.DELETE, - HttpMethod.OPTIONS - ) - .map(Enum::name) - .collect(Collectors.toList()) - ); - corsConfig.setAllowCredentials(true); - return corsConfig; - }) - .and() - .csrf() - .disable() - .addFilter(new TokenAuthenticationFilter(authenticationManager())) - .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); - } -} - diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/SessionAuthenticationToken.java b/api/src/main/java/com/wbrawner/budgetserver/config/SessionAuthenticationToken.java deleted file mode 100644 index 59cea2e..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/config/SessionAuthenticationToken.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.wbrawner.budgetserver.config; - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; - -import java.util.Collection; - -public class SessionAuthenticationToken extends UsernamePasswordAuthenticationToken { - /** - * Creates a token with the supplied array of authorities. - * - * @param authorities the collection of GrantedAuthoritys for the principal - * represented by this authentication object. - * @param credentials - * @param principal - */ - public SessionAuthenticationToken(Object principal, Object credentials, Collection authorities) { - super(principal, credentials, authorities); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.java b/api/src/main/java/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.java deleted file mode 100644 index d556b9e..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.wbrawner.budgetserver.config; - -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; - -/** - * Used to avoid browser prompts for authentication - */ -public class SilentAuthenticationEntryPoint implements AuthenticationEntryPoint { - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.java b/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.java deleted file mode 100644 index 1e449dc..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.wbrawner.budgetserver.config; - -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Collections; - -public class TokenAuthenticationFilter extends BasicAuthenticationFilter { - - public TokenAuthenticationFilter(AuthenticationManager authenticationManager) { - super(authenticationManager); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { - var authHeader = request.getHeader("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - chain.doFilter(request, response); - return; - } - var token = authHeader.substring(7); - var authentication = getAuthenticationManager().authenticate(new SessionAuthenticationToken(null, token, Collections.emptyList())); - SecurityContextHolder.getContext().setAuthentication(authentication); - chain.doFilter(request, response); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.java b/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.java deleted file mode 100644 index 66027bf..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.wbrawner.budgetserver.config; - -import com.wbrawner.budgetserver.session.UserSessionRepository; -import com.wbrawner.budgetserver.user.UserRepository; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.InternalAuthenticationServiceException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.Date; - -import static com.wbrawner.budgetserver.Utils.twoWeeksFromNow; - -public class TokenAuthenticationProvider extends DaoAuthenticationProvider { - private final UserSessionRepository userSessionRepository; - private final UserRepository userRepository; - - public TokenAuthenticationProvider(UserSessionRepository userSessionRepository, UserRepository userRepository) { - this.userSessionRepository = userSessionRepository; - this.userRepository = userRepository; - } - - @Override - protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { - if (!(authentication instanceof SessionAuthenticationToken)) { - // Additional checks aren't needed since they've already been handled - super.additionalAuthenticationChecks(userDetails, authentication); - } - } - - @Override - public Authentication authenticate(Authentication authentication) throws AuthenticationException { - if (authentication instanceof SessionAuthenticationToken) { - var session = userSessionRepository.findByToken((String) authentication.getCredentials()); - if (session.isEmpty() || session.get().getExpiration().before(new Date())) { - throw new BadCredentialsException("Credentials expired"); - } - var user = userRepository.findById(session.get().getUserId()); - if (user.isEmpty()) { - throw new InternalAuthenticationServiceException("Failed to find user for token"); - } - new Thread(() -> { - // Update the session on a background thread to avoid holding up the request longer than necessary - var updatedSession = session.get(); - updatedSession.setExpiration(twoWeeksFromNow()); - userSessionRepository.save(updatedSession); - }).start(); - return new SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities()); - } else { - return super.authenticate(authentication); - } - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequest.java b/api/src/main/java/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequest.java deleted file mode 100644 index 0528dfa..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.wbrawner.budgetserver.passwordresetrequest; - -import com.wbrawner.budgetserver.user.User; - -import javax.persistence.*; -import java.util.Calendar; -import java.util.GregorianCalendar; -import java.util.UUID; - -import static com.wbrawner.budgetserver.Utils.randomId; - -@Entity -public class PasswordResetRequest { - @Id - private final String id = randomId(); - @ManyToOne - private final User user; - private final Calendar date; - private final String token; - - public PasswordResetRequest() { - this(null); - } - - public PasswordResetRequest(User user) { - this(user, new GregorianCalendar(), randomId()); - } - - public PasswordResetRequest( - User user, - Calendar date, - String token - ) { - this.user = user; - this.date = date; - this.token = token; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequestRepository.java b/api/src/main/java/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequestRepository.java deleted file mode 100644 index 836b30f..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequestRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.wbrawner.budgetserver.passwordresetrequest; - -import org.springframework.data.repository.PagingAndSortingRepository; - -public interface PasswordResetRequestRepository extends PagingAndSortingRepository { -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/permission/Permission.java b/api/src/main/java/com/wbrawner/budgetserver/permission/Permission.java deleted file mode 100644 index 3107965..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/permission/Permission.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.wbrawner.budgetserver.permission; - -public enum Permission { - /** - * The user can read the content but cannot make any modifications. - */ - READ, - /** - * The user can read and write the content but cannot make any modifications to the container of the content. - */ - WRITE, - /** - * The user can read and write the content, and make modifications to the container of the content including things like name, description, and other users' permissions (with the exception of the owner user, whose role can never be removed by a user with only MANAGE permissions). - */ - MANAGE, - /** - * The user has complete control over the resource. There can only be a single owner user at any given time. - */ - OWNER; - - public boolean isNotAtLeast(Permission wanted) { - return ordinal() < wanted.ordinal(); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermission.java b/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermission.java deleted file mode 100644 index 2f51b46..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermission.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.wbrawner.budgetserver.permission; - -import com.wbrawner.budgetserver.budget.Budget; -import com.wbrawner.budgetserver.user.User; - -import javax.persistence.*; - -@Entity -public class UserPermission { - @EmbeddedId - private UserPermissionKey id; - @ManyToOne - @MapsId("budgetId") - @JoinColumn(nullable = false, name = "budget_id") - private Budget budget; - @ManyToOne - @MapsId("userId") - @JoinColumn(nullable = false, name = "user_id") - private User user; - @JoinColumn(nullable = false) - @Enumerated(EnumType.STRING) - private Permission permission; - - public UserPermission() { - this(null, null, null, null); - } - - public UserPermission(Budget budget, User user, Permission permission) { - this(new UserPermissionKey(budget.getId(), user.getId()), budget, user, permission); - } - - public UserPermission(UserPermissionKey userPermissionKey, Budget budget, User user, Permission permission) { - this.id = userPermissionKey; - this.budget = budget; - this.user = user; - this.permission = permission; - } - - public UserPermissionKey getId() { - return id; - } - - public Budget getBudget() { - return budget; - } - - public User getUser() { - return user; - } - - public Permission getPermission() { - return permission; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionKey.java b/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionKey.java deleted file mode 100644 index 58ef34f..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionKey.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.wbrawner.budgetserver.permission; - -import javax.persistence.Embeddable; -import java.io.Serializable; - -@Embeddable -public class UserPermissionKey implements Serializable { - private final String budgetId; - private final String userId; - - public UserPermissionKey() { - this(null, null); - } - - public UserPermissionKey(String budgetId, String userId) { - this.budgetId = budgetId; - this.userId = userId; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionRepository.java b/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionRepository.java deleted file mode 100644 index dccd6f6..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.wbrawner.budgetserver.permission; - -import com.wbrawner.budgetserver.budget.Budget; -import com.wbrawner.budgetserver.user.User; -import org.springframework.data.domain.Pageable; -import org.springframework.data.repository.PagingAndSortingRepository; - -import java.util.List; -import java.util.Optional; - -public interface UserPermissionRepository extends PagingAndSortingRepository { - Optional findByUserAndBudget_Id(User user, String budgetId); - - List findAllByUser(User user, Pageable pageable); - - List findAllByBudget(Budget budget, Pageable pageable); - - List findAllByUserAndBudget(User user, Budget budget, Pageable pageable); - - List findAllByUserAndBudget_IdIn(User user, List budgetIds, Pageable pageable); -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionRequest.java b/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionRequest.java deleted file mode 100644 index a367e6b..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.wbrawner.budgetserver.permission; - -public class UserPermissionRequest { - private final String user; - private final Permission permission; - - public UserPermissionRequest() { - this(null, Permission.READ); - } - - public UserPermissionRequest(String user, Permission permission) { - this.user = user; - this.permission = permission; - } - - public String getUser() { - return user; - } - - public Permission getPermission() { - return permission; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionResponse.java b/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionResponse.java deleted file mode 100644 index 3d85001..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/permission/UserPermissionResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.wbrawner.budgetserver.permission; - -import com.wbrawner.budgetserver.user.UserResponse; - -public class UserPermissionResponse { - private final UserResponse user; - private final Permission permission; - - public UserPermissionResponse(UserPermission userPermission) { - this(new UserResponse(userPermission.getUser()), userPermission.getPermission()); - } - - public UserPermissionResponse(UserResponse userResponse, Permission permission) { - this.user = userResponse; - this.permission = permission; - } - - public UserResponse getUser() { - return user; - } - - public Permission getPermission() { - return permission; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/Session.java b/api/src/main/java/com/wbrawner/budgetserver/session/Session.java deleted file mode 100644 index 487e5a5..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/session/Session.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.wbrawner.budgetserver.session; - -import javax.persistence.Entity; -import javax.persistence.Id; -import java.util.Date; - -import static com.wbrawner.budgetserver.Utils.*; - -@Entity -public class Session { - @Id - private final String id = randomId(); - private final String userId; - private final String token = randomString(255); - private Date expiration = twoWeeksFromNow(); - - public Session() { - this(""); - } - - public Session(String userId) { - this.userId = userId; - } - - public String getId() { - return id; - } - - public String getUserId() { - return userId; - } - - public String getToken() { - return token; - } - - public Date getExpiration() { - return expiration; - } - - public void setExpiration(Date expiration) { - this.expiration = expiration; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/SessionCleanupTask.java b/api/src/main/java/com/wbrawner/budgetserver/session/SessionCleanupTask.java deleted file mode 100644 index d077984..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/session/SessionCleanupTask.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.wbrawner.budgetserver.session; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.util.Date; - -@Component -public class SessionCleanupTask { - private final UserSessionRepository sessionRepository; - - @Autowired - public SessionCleanupTask(UserSessionRepository sessionRepository) { - this.sessionRepository = sessionRepository; - } - - @Scheduled(cron = "0 0 * * * *") - public void cleanup() { - sessionRepository.deleteAllByExpirationBefore(new Date()); - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/SessionResponse.java b/api/src/main/java/com/wbrawner/budgetserver/session/SessionResponse.java deleted file mode 100644 index bfacf4d..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/session/SessionResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.wbrawner.budgetserver.session; - -import java.util.Date; - -public class SessionResponse { - private final String token; - private final String expiration; - - public SessionResponse(Session session) { - this(session.getToken(), session.getExpiration()); - } - - public SessionResponse(String token, Date expiration) { - this.token = token; - this.expiration = expiration.toInstant().toString(); - } - - public String getToken() { - return token; - } - - public String getExpiration() { - return expiration; - } -} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/UserSessionRepository.java b/api/src/main/java/com/wbrawner/budgetserver/session/UserSessionRepository.java deleted file mode 100644 index 19c58c9..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/session/UserSessionRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.wbrawner.budgetserver.session; - -import org.springframework.data.repository.PagingAndSortingRepository; - -import java.util.Date; -import java.util.List; -import java.util.Optional; - -public interface UserSessionRepository extends PagingAndSortingRepository { - List findByUserId(String userId); - - Optional findByToken(String token); - - Optional findByUserIdAndToken(String userId, String token); - - void deleteAllByExpirationBefore(Date expiration); -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/transaction/NewTransactionRequest.java b/api/src/main/java/com/wbrawner/budgetserver/transaction/NewTransactionRequest.java deleted file mode 100644 index e04b53e..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/transaction/NewTransactionRequest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.wbrawner.budgetserver.transaction; - -class NewTransactionRequest { - private final String title; - private final String description; - private final String date; - private final Long amount; - private final String categoryId; - private final Boolean expense; - private final String budgetId; - - NewTransactionRequest() { - this(null, null, null, null, null, null, null); - } - - NewTransactionRequest(String title, String description, String date, Long amount, String categoryId, Boolean expense, String budgetId) { - this.title = title; - this.description = description; - this.date = date; - this.amount = amount; - this.categoryId = categoryId; - this.expense = expense; - this.budgetId = budgetId; - } - - public String getTitle() { - return title; - } - - public String getDescription() { - return description; - } - - public String getDate() { - return date; - } - - public Long getAmount() { - return amount; - } - - public String getCategoryId() { - return categoryId; - } - - public Boolean getExpense() { - return expense; - } - - public String getBudgetId() { - return budgetId; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/transaction/Transaction.java b/api/src/main/java/com/wbrawner/budgetserver/transaction/Transaction.java deleted file mode 100644 index c20697c..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/transaction/Transaction.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.wbrawner.budgetserver.transaction; - -import com.wbrawner.budgetserver.budget.Budget; -import com.wbrawner.budgetserver.category.Category; -import com.wbrawner.budgetserver.user.User; - -import javax.persistence.*; -import java.time.Instant; - -import static com.wbrawner.budgetserver.Utils.randomId; - -@Entity -public class Transaction implements Comparable { - @Id - private final String id = randomId(); - @ManyToOne - @JoinColumn(nullable = false) - private final User createdBy; - private String title; - private String description; - private Instant date; - private Long amount; - @ManyToOne - private Category category; - private Boolean expense; - @ManyToOne - @JoinColumn(nullable = false) - private Budget budget; - - public Transaction() { - this(null, null, null, null, null, null, null, null); - } - - public Transaction(String title, - String description, - Instant date, - Long amount, - Category category, - Boolean expense, - User createdBy, - Budget budget) { - this.title = title; - this.description = description; - this.date = date; - this.amount = amount; - this.category = category; - this.expense = expense; - this.createdBy = createdBy; - this.budget = budget; - } - - public String getId() { - // This should only be set from Hibernate so it shouldn't actually be null ever - //noinspection ConstantConditions - return id; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public Instant getDate() { - return date; - } - - public void setDate(Instant date) { - this.date = date; - } - - public Long getAmount() { - return amount; - } - - public void setAmount(Long amount) { - this.amount = amount; - } - - public Category getCategory() { - return category; - } - - public void setCategory(Category category) { - this.category = category; - } - - public Boolean getExpense() { - return expense; - } - - public void setExpense(Boolean expense) { - this.expense = expense; - } - - public User getCreatedBy() { - return createdBy; - } - - public Budget getBudget() { - return budget; - } - - public void setBudget(Budget budget) { - this.budget = budget; - } - - @Override - public int compareTo(Transaction other) { - return this.date.compareTo(other.date); - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionController.java b/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionController.java deleted file mode 100644 index 56497e9..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionController.java +++ /dev/null @@ -1,209 +0,0 @@ -package com.wbrawner.budgetserver.transaction; - -import com.wbrawner.budgetserver.ErrorResponse; -import com.wbrawner.budgetserver.category.Category; -import com.wbrawner.budgetserver.category.CategoryRepository; -import com.wbrawner.budgetserver.permission.Permission; -import com.wbrawner.budgetserver.permission.UserPermission; -import com.wbrawner.budgetserver.permission.UserPermissionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import javax.transaction.Transactional; -import java.time.Instant; -import java.util.List; -import java.util.stream.Collectors; - -import static com.wbrawner.budgetserver.Utils.*; - -@RestController -@RequestMapping(path = "/transactions") -@Transactional -public class TransactionController { - private final CategoryRepository categoryRepository; - private final TransactionRepository transactionRepository; - private final UserPermissionRepository userPermissionsRepository; - - private final Logger logger = LoggerFactory.getLogger(TransactionController.class); - - public TransactionController(CategoryRepository categoryRepository, - TransactionRepository transactionRepository, - UserPermissionRepository userPermissionsRepository) { - this.categoryRepository = categoryRepository; - this.transactionRepository = transactionRepository; - this.userPermissionsRepository = userPermissionsRepository; - } - - @GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity> getTransactions( - @RequestParam(value = "categoryIds", required = false) List categoryIds, - @RequestParam(value = "budgetIds", required = false) List budgetIds, - @RequestParam(value = "from", required = false) String from, - @RequestParam(value = "to", required = false) String to, - @RequestParam(value = "count", required = false) Integer count, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "sortBy", required = false) String sortBy, - @RequestParam(value = "sortOrder", required = false) Sort.Direction sortOrder - ) { - List userPermissions; - if (budgetIds != null && !budgetIds.isEmpty()) { - userPermissions = userPermissionsRepository.findAllByUserAndBudget_IdIn( - getCurrentUser(), - budgetIds, - PageRequest.of(page != null ? page : 0, count != null ? count : 1000) - ); - } else { - userPermissions = userPermissionsRepository.findAllByUser(getCurrentUser(), null); - } - var budgets = userPermissions.stream() - .map(UserPermission::getBudget) - .collect(Collectors.toList()); - - List categories = null; - if (categoryIds != null && !categoryIds.isEmpty()) { - categories = categoryRepository.findAllByBudgetInAndIdIn(budgets, categoryIds, null); - } - var pageRequest = PageRequest.of( - Math.min(0, page != null ? page - 1 : 0), - count != null ? count : 1000, - sortOrder != null ? sortOrder : Sort.Direction.DESC, - sortBy != null ? sortBy : "date" - ); - Instant fromInstant; - try { - fromInstant = Instant.parse(from); - } catch (Exception e) { - if (!(e instanceof NullPointerException)) - logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e); - fromInstant = getFirstOfMonth().toInstant(); - } - Instant toInstant; - try { - toInstant = Instant.parse(to); - } catch (Exception e) { - if (!(e instanceof NullPointerException)) - logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e); - toInstant = getEndOfMonth().toInstant(); - } - var query = categories == null ? transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan( - budgets, - fromInstant, - toInstant, - pageRequest - ) : transactionRepository.findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan( - budgets, - categories, - fromInstant, - toInstant, - pageRequest - ); - var transactions = query - .stream() - .map(TransactionResponse::new) - .collect(Collectors.toList()); - return ResponseEntity.ok(transactions); - } - - @GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity getTransaction(@PathVariable String id) { - var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null) - .stream() - .map(UserPermission::getBudget) - .collect(Collectors.toList()); - var transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null); - if (transaction == null) return ResponseEntity.notFound().build(); - return ResponseEntity.ok(new TransactionResponse(transaction)); - } - - @PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity newTransaction(@RequestBody NewTransactionRequest request) { - var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()) - .orElse(null); - if (userResponse == null) { - return ResponseEntity.badRequest().body(new ErrorResponse("Invalid budget ID")); - } - if (userResponse.getPermission().isNotAtLeast(Permission.WRITE)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - var budget = userResponse.getBudget(); - Category category = null; - if (request.getCategoryId() != null) { - category = categoryRepository.findByBudgetAndId(budget, request.getCategoryId()).orElse(null); - } - return ResponseEntity.ok(new TransactionResponse(transactionRepository.save(new Transaction( - request.getTitle(), - request.getDescription(), - Instant.parse(request.getDate()), - request.getAmount(), - category, - request.getExpense(), - getCurrentUser(), - budget - )))); - } - - @PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - public ResponseEntity updateTransaction(@PathVariable String id, @RequestBody UpdateTransactionRequest request) { - var transaction = transactionRepository.findById(id).orElse(null); - if (transaction == null) return ResponseEntity.notFound().build(); - var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null); - if (userPermission == null) return ResponseEntity.notFound().build(); - if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - if (request.getTitle() != null) { - transaction.setTitle(request.getTitle()); - } - if (request.getDescription() != null) { - transaction.setDescription(request.getDescription()); - } - if (request.getDate() != null) { - transaction.setDate(Instant.parse(request.getDate())); - } - if (request.getAmount() != null) { - transaction.setAmount(request.getAmount()); - } - if (request.getExpense() != null) { - transaction.setExpense(request.getExpense()); - } - if (request.getBudgetId() != null) { - var newUserPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()).orElse(null); - if (newUserPermission == null || newUserPermission.getPermission().isNotAtLeast(Permission.WRITE)) { - return ResponseEntity - .badRequest() - .body(new ErrorResponse("Invalid budget")); - } - transaction.setBudget(newUserPermission.getBudget()); - } - if (request.getCategoryId() != null) { - var category = categoryRepository.findByBudgetAndId(transaction.getBudget(), request.getCategoryId()).orElse(null); - if (category == null) { - return ResponseEntity - .badRequest() - .body(new ErrorResponse("Invalid category")); - } - transaction.setCategory(category); - } - return ResponseEntity.ok(new TransactionResponse(transactionRepository.save(transaction))); - } - - @DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) - public ResponseEntity deleteTransaction(@PathVariable String id) { - var transaction = transactionRepository.findById(id).orElse(null); - if (transaction == null) return ResponseEntity.notFound().build(); - // Check that the transaction belongs to an budget that the user has access to before deleting it - var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null); - if (userPermission == null) return ResponseEntity.notFound().build(); - if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - transactionRepository.delete(transaction); - return ResponseEntity.ok().build(); - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionRepository.java b/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionRepository.java deleted file mode 100644 index 5efe481..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionRepository.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.wbrawner.budgetserver.transaction; - -import com.wbrawner.budgetserver.budget.Budget; -import com.wbrawner.budgetserver.category.Category; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.PagingAndSortingRepository; - -import java.time.Instant; -import java.util.Date; -import java.util.List; -import java.util.Optional; - -public interface TransactionRepository extends PagingAndSortingRepository { - Optional findByIdAndBudgetIn(String id, List budgets); - - List findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan( - List budgets, - List categories, - Instant start, - Instant end, - Pageable pageable - ); - - List findAllByBudgetInAndDateGreaterThanAndDateLessThan( - List budgets, - Instant start, - Instant end, - Pageable pageable - ); - - List findAllByBudgetAndCategory(Budget budget, Category category); - - @Query( - nativeQuery = true, - value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 0 AND date >= :from AND date <= :to), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 1 AND date >= :from AND date <= :to), 0));" - ) - Long sumBalanceByBudgetId(String BudgetId, Instant from, Instant to); - - @Query( - nativeQuery = true, - value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 0 AND date > :start), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 1 AND date > :start), 0));" - ) - Long sumBalanceByCategoryId(String categoryId, Date start); -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionResponse.java b/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionResponse.java deleted file mode 100644 index 50e7ecd..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/transaction/TransactionResponse.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.wbrawner.budgetserver.transaction; - -class TransactionResponse { - private final String id; - private final String title; - private final String description; - private final String date; - private final Long amount; - private final Boolean expense; - private final String budgetId; - private final String categoryId; - private final String createdBy; - - TransactionResponse(String id, - String title, - String description, - String date, - Long amount, - Boolean expense, - String budgetId, - String categoryId, - String createdBy) { - this.id = id; - this.title = title; - this.description = description; - this.date = date; - this.amount = amount; - this.expense = expense; - this.budgetId = budgetId; - this.categoryId = categoryId; - this.createdBy = createdBy; - } - - TransactionResponse(Transaction transaction) { - this( - transaction.getId(), - transaction.getTitle(), - transaction.getDescription(), - transaction.getDate().toString(), - transaction.getAmount(), - transaction.getExpense(), - transaction.getBudget().getId(), - transaction.getCategory() != null ? transaction.getCategory().getId() : null, - transaction.getCreatedBy().getId() - ); - } - - public String getId() { - return id; - } - - public String getTitle() { - return title; - } - - public String getDescription() { - return description; - } - - public String getDate() { - return date; - } - - public Long getAmount() { - return amount; - } - - public Boolean getExpense() { - return expense; - } - - public String getBudgetId() { - return budgetId; - } - - public String getCategoryId() { - return categoryId; - } - - public String getCreatedBy() { - return createdBy; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/transaction/UpdateTransactionRequest.java b/api/src/main/java/com/wbrawner/budgetserver/transaction/UpdateTransactionRequest.java deleted file mode 100644 index 51f34c0..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/transaction/UpdateTransactionRequest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.wbrawner.budgetserver.transaction; - -class UpdateTransactionRequest { - private final String title; - private final String description; - private final String date; - private final Long amount; - private final String categoryId; - private final Boolean expense; - private final String budgetId; - private final String createdBy; - - UpdateTransactionRequest() { - this(null, null, null, null, null, null, null, null); - } - - UpdateTransactionRequest(String title, String description, String date, Long amount, String categoryId, Boolean expense, String budgetId, String createdBy) { - this.title = title; - this.description = description; - this.date = date; - this.amount = amount; - this.categoryId = categoryId; - this.expense = expense; - this.budgetId = budgetId; - this.createdBy = createdBy; - } - - public String getTitle() { - return title; - } - - public String getDescription() { - return description; - } - - public String getDate() { - return date; - } - - public Long getAmount() { - return amount; - } - - public String getCategoryId() { - return categoryId; - } - - public Boolean getExpense() { - return expense; - } - - public String getBudgetId() { - return budgetId; - } - - public String getCreatedBy() { - return createdBy; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/LoginRequest.java b/api/src/main/java/com/wbrawner/budgetserver/user/LoginRequest.java deleted file mode 100644 index aae3955..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/user/LoginRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.wbrawner.budgetserver.user; - -public class LoginRequest { - private final String username; - private final String password; - - public LoginRequest() { - this(null, null); - } - - public LoginRequest(String username, String password) { - this.username = username; - this.password = password; - } - - public String getUsername() { - return username; - } - - public String getPassword() { - return password; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/NewUserRequest.java b/api/src/main/java/com/wbrawner/budgetserver/user/NewUserRequest.java deleted file mode 100644 index c8b3934..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/user/NewUserRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.wbrawner.budgetserver.user; - -public class NewUserRequest { - private final String username; - private final String password; - private final String email; - - public NewUserRequest() { - this(null, null, null); - } - - public NewUserRequest(String username, String password, String email) { - this.username = username; - this.password = password; - this.email = email; - } - - public String getUsername() { - return username; - } - - public String getPassword() { - return password; - } - - public String getEmail() { - return email; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/UpdateUserRequest.java b/api/src/main/java/com/wbrawner/budgetserver/user/UpdateUserRequest.java deleted file mode 100644 index 571f3b2..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/user/UpdateUserRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.wbrawner.budgetserver.user; - -public class UpdateUserRequest { - private final String username; - private final String password; - private final String email; - - public UpdateUserRequest() { - this(null, null, null); - } - - public UpdateUserRequest(String username, String password, String email) { - this.username = username; - this.password = password; - this.email = email; - } - - public String getUsername() { - return username; - } - - public String getPassword() { - return password; - } - - public String getEmail() { - return email; - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/User.java b/api/src/main/java/com/wbrawner/budgetserver/user/User.java deleted file mode 100644 index eedae7c..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/user/User.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.wbrawner.budgetserver.user; - -import org.springframework.lang.NonNull; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Transient; -import java.util.Collection; -import java.util.Collections; -import java.util.List; - -import static com.wbrawner.budgetserver.Utils.randomId; - -@Entity -public class User implements UserDetails { - @Id - private final String id = randomId(); - @Transient - private final List authorities = Collections.singletonList(new SimpleGrantedAuthority("USER")); - private String username; - private String password; - private String email; - - public User() { - this(null, null, null); - } - - public User(String username, String password, String email) { - this.username = username; - this.password = password; - this.email = email; - } - - @NonNull - public String getId() { - // This shouldn't ever need to be set manually, only through Hibernate - //noinspection ConstantConditions - return id; - } - - @Override - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - @Override - public String getPassword() { - return password; - } - - public void setPassword(String password) { - this.password = password; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } - - @Override - public Collection getAuthorities() { - return authorities; - } -} - - diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/UserController.java b/api/src/main/java/com/wbrawner/budgetserver/user/UserController.java deleted file mode 100644 index e79bd30..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/user/UserController.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.wbrawner.budgetserver.user; - -import com.wbrawner.budgetserver.ErrorResponse; -import com.wbrawner.budgetserver.budget.BudgetRepository; -import com.wbrawner.budgetserver.permission.UserPermissionRepository; -import com.wbrawner.budgetserver.permission.UserPermissionResponse; -import com.wbrawner.budgetserver.session.Session; -import com.wbrawner.budgetserver.session.SessionResponse; -import com.wbrawner.budgetserver.session.UserSessionRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.web.bind.annotation.*; - -import javax.transaction.Transactional; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -import static com.wbrawner.budgetserver.Utils.getCurrentUser; - -@RestController -@RequestMapping("/users") -@Transactional -public class UserController { - private final BudgetRepository budgetRepository; - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final UserPermissionRepository userPermissionsRepository; - private final UserSessionRepository userSessionRepository; - private final DaoAuthenticationProvider authenticationProvider; - - @Autowired - public UserController(BudgetRepository budgetRepository, - UserRepository userRepository, - UserSessionRepository userSessionRepository, - PasswordEncoder passwordEncoder, - UserPermissionRepository userPermissionsRepository, - DaoAuthenticationProvider authenticationProvider) { - this.budgetRepository = budgetRepository; - this.userRepository = userRepository; - this.userSessionRepository = userSessionRepository; - this.passwordEncoder = passwordEncoder; - this.userPermissionsRepository = userPermissionsRepository; - this.authenticationProvider = authenticationProvider; - } - - - @GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity> getUsers(String budgetId) { - var budget = budgetRepository.findById(budgetId).orElse(null); - if (budget == null) { - return ResponseEntity.notFound().build(); - } - var userPermissions = userPermissionsRepository.findAllByBudget(budget, null); - - var userInBudget = userPermissions.stream() - .anyMatch(userPermission -> - userPermission.getUser().getId().equals(getCurrentUser().getId())); - if (!userInBudget) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(userPermissions.stream().map(UserPermissionResponse::new).collect(Collectors.toList())); - } - - @PostMapping(path = "/login", produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity login(@RequestBody LoginRequest request) { - var authReq = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); - Authentication auth; - try { - auth = authenticationProvider.authenticate(authReq); - } catch (AuthenticationException e) { - return ResponseEntity.notFound().build(); - } - SecurityContextHolder.getContext().setAuthentication(auth); - var user = Objects.requireNonNull(getCurrentUser()); - var session = userSessionRepository.save(new Session(user.getId())); - return ResponseEntity.ok(new SessionResponse(session)); - } - - @GetMapping(path = "/me", produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity getProfile() { - var user = getCurrentUser(); - if (user == null) return ResponseEntity.status(401).build(); - return ResponseEntity.ok(new UserResponse(user)); - } - - @GetMapping(path = "/search", produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity> searchUsers(String query) { - return ResponseEntity.ok( - userRepository.findByUsernameContains(query) - .stream() - .map(UserResponse::new) - .collect(Collectors.toList()) - ); - } - - @GetMapping(path = "/{id}") - ResponseEntity getUser(@PathVariable String id) { - var user = userRepository.findById(id).orElse(null); - if (user == null) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(new UserResponse(user)); - } - - @PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity newUser(@RequestBody NewUserRequest request) { - if (userRepository.findByUsername(request.getUsername()).isPresent()) - return ResponseEntity.badRequest().body(new ErrorResponse("Username taken")); - if (userRepository.findByEmail(request.getEmail()).isPresent()) - return ResponseEntity.badRequest().body(new ErrorResponse("Email taken")); - if (request.getPassword().isBlank()) - return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password")); - return ResponseEntity.ok(new UserResponse(userRepository.save(new User( - request.getUsername(), - passwordEncoder.encode(request.getPassword()), - request.getEmail() - )))); - } - - @PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) - ResponseEntity updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) { - if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build(); - var user = userRepository.findById(getCurrentUser().getId()).orElse(null); - if (user == null) return ResponseEntity.notFound().build(); - if (request.getUsername() != null) { - if (userRepository.findByUsername(request.getUsername()).isPresent()) - return ResponseEntity.badRequest().body(new ErrorResponse("Username taken")); - user.setUsername(request.getUsername()); - } - if (request.getEmail() != null) { - if (userRepository.findByEmail(request.getEmail()).isPresent()) - return ResponseEntity.badRequest().body(new ErrorResponse("Email taken")); - user.setEmail(request.getEmail()); - } - if (request.getPassword() != null) { - if (request.getPassword().isBlank()) - return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password")); - user.setPassword(passwordEncoder.encode(request.getPassword())); - } - return ResponseEntity.ok(new UserResponse(userRepository.save(user))); - } - - @DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) - ResponseEntity deleteUser(@PathVariable String id) { - if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build(); - userRepository.deleteById(id); - return ResponseEntity.ok().build(); - } -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/UserRepository.java b/api/src/main/java/com/wbrawner/budgetserver/user/UserRepository.java deleted file mode 100644 index 8954a7f..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/user/UserRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.wbrawner.budgetserver.user; - -import org.springframework.data.repository.PagingAndSortingRepository; - -import java.util.List; -import java.util.Optional; - -public interface UserRepository extends PagingAndSortingRepository { - Optional findByUsername(String username); - - Optional findByUsernameAndPassword(String username, String password); - - List findByUsernameContains(String username); - - Optional findByEmail(String email); -} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/UserResponse.java b/api/src/main/java/com/wbrawner/budgetserver/user/UserResponse.java deleted file mode 100644 index 565d5d3..0000000 --- a/api/src/main/java/com/wbrawner/budgetserver/user/UserResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.wbrawner.budgetserver.user; - -public class UserResponse { - private final String id; - private final String username; - private final String email; - - public UserResponse(User user) { - this(user.getId(), user.getUsername(), user.getEmail()); - } - - public UserResponse(String id, String username, String email) { - this.id = id; - this.username = username; - this.email = email; - } - - public String getId() { - return id; - } - - public String getUsername() { - return username; - } - - public String getEmail() { - return email; - } -} diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/ErrorResponse.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/ErrorResponse.kt new file mode 100644 index 0000000..f5b0dc0 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/ErrorResponse.kt @@ -0,0 +1,3 @@ +package com.wbrawner.budgetserver + +data class ErrorResponse(val message: String) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/TwigsServerApplication.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/TwigsServerApplication.kt new file mode 100644 index 0000000..71711a0 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/TwigsServerApplication.kt @@ -0,0 +1,16 @@ +package com.wbrawner.budgetserver + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@EnableScheduling +open class TwigsServerApplication { + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(TwigsServerApplication::class.java, *args) + } + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/Utils.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/Utils.kt new file mode 100644 index 0000000..3ddd436 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/Utils.kt @@ -0,0 +1,74 @@ +package com.wbrawner.budgetserver + +import com.wbrawner.budgetserver.budget.Budget +import com.wbrawner.budgetserver.permission.Permission +import com.wbrawner.budgetserver.permission.UserPermissionRepository +import com.wbrawner.budgetserver.transaction.TransactionRepository +import com.wbrawner.budgetserver.user.User +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.context.SecurityContextHolder +import java.util.* + +private val CALENDAR_FIELDS = intArrayOf( + Calendar.MILLISECOND, + Calendar.SECOND, + Calendar.MINUTE, + Calendar.HOUR_OF_DAY, + Calendar.DATE +) + +val firstOfMonth: Date + get() = GregorianCalendar().run { + for (calField in CALENDAR_FIELDS) { + set(calField, getActualMinimum(calField)) + } + time + } + +val endOfMonth: Date + get() = GregorianCalendar().run { + for (calField in CALENDAR_FIELDS) { + set(calField, getActualMaximum(calField)) + } + time + } + +val twoWeeksFromNow: Date + get() = GregorianCalendar().run { + add(Calendar.DATE, 14) + time + } + +val currentUser: User? + get() = SecurityContextHolder.getContext().authentication.principal as? User + +private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +fun randomString(length: Int = 32): String { + val id = StringBuilder() + for (i in 0 until length) { + id.append(CHARACTERS.random()) + } + return id.toString() +} + +fun getBudgetWithPermission( + transactionRepository: TransactionRepository, + userPermissionsRepository: UserPermissionRepository, + transactionId: String, + permission: Permission, + action: (Budget) -> ResponseEntity +): ResponseEntity { + val transaction = transactionRepository.findById(transactionId).orElse(null) + ?: return ResponseEntity.notFound().build() + val userPermission = userPermissionsRepository.findByUserAndBudget_Id( + currentUser, + transaction.budget!!.id + ).orElse(null) + ?: return ResponseEntity.notFound().build() + if (userPermission.permission.isNotAtLeast(permission)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + return action(userPermission.budget!!) +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/budget/Budget.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/budget/Budget.kt new file mode 100644 index 0000000..aca093a --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/budget/Budget.kt @@ -0,0 +1,49 @@ +package com.wbrawner.budgetserver.budget + +import com.wbrawner.budgetserver.category.Category +import com.wbrawner.budgetserver.permission.UserPermission +import com.wbrawner.budgetserver.permission.UserPermissionRequest +import com.wbrawner.budgetserver.permission.UserPermissionResponse +import com.wbrawner.budgetserver.randomString +import com.wbrawner.budgetserver.transaction.Transaction +import java.util.* +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.OneToMany + +@Entity +data class Budget( + @Id + var id: String = randomString(), + var name: String? = null, + var description: String? = null, + var currencyCode: String? = "USD", + @OneToMany(mappedBy = "budget") + val transactions: Set = TreeSet(), + @OneToMany(mappedBy = "budget") + val categories: Set = TreeSet(), + @OneToMany(mappedBy = "budget") + val users: Set = HashSet() +) + +data class BudgetRequest( + val name: String = "", + val description: String = "", + val users: Set = emptySet() +) + +data class BudgetResponse( + val id: String, + val name: String?, + val description: String?, + private val users: List +) { + constructor(budget: Budget, users: List) : this( + Objects.requireNonNull(budget.id), + budget.name, + budget.description, + users.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) } + ) +} + +data class BudgetBalanceResponse(val id: String, val balance: Long) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/budget/BudgetController.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/budget/BudgetController.kt new file mode 100644 index 0000000..7be2860 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/budget/BudgetController.kt @@ -0,0 +1,165 @@ +package com.wbrawner.budgetserver.budget + +import com.wbrawner.budgetserver.currentUser +import com.wbrawner.budgetserver.permission.Permission +import com.wbrawner.budgetserver.permission.UserPermission +import com.wbrawner.budgetserver.permission.UserPermissionRepository +import com.wbrawner.budgetserver.permission.UserPermissionRequest +import com.wbrawner.budgetserver.transaction.TransactionRepository +import com.wbrawner.budgetserver.user.User +import com.wbrawner.budgetserver.user.UserRepository +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.time.Instant +import java.util.function.Consumer +import java.util.function.Function +import javax.transaction.Transactional + +@RestController +@RequestMapping(value = ["/budgets"]) +@Transactional +open class BudgetController( + private val budgetRepository: BudgetRepository, + private val transactionRepository: TransactionRepository, + private val userRepository: UserRepository, + private val userPermissionsRepository: UserPermissionRepository +) { + private val logger = LoggerFactory.getLogger(BudgetController::class.java) + + @GetMapping(value = [""], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getBudgets(page: Int?, count: Int?): ResponseEntity> { + val user = currentUser ?: return ResponseEntity.status(401).build() + val budgets: List = userPermissionsRepository.findAllByUser( + user, + PageRequest.of( + page ?: 0, + count ?: 1000 + ) + ).mapNotNull { userPermission: UserPermission -> + val budget = userPermission.budget ?: return@mapNotNull null + BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null)) + } + return ResponseEntity.ok(budgets) + } + + @GetMapping(value = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getBudget(@PathVariable id: String): ResponseEntity { + return getBudgetWithPermission(id, Permission.READ) { budget: Budget -> + ResponseEntity.ok(BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null))) + } + } + + @GetMapping(value = ["/{id}/balance"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getBudgetBalance( + @PathVariable id: String, + @RequestParam(value = "from", required = false) from: String? = null, + @RequestParam(value = "to", required = false) to: String? = null + ): ResponseEntity { + return getBudgetWithPermission(id, Permission.READ) { budget: Budget -> + val fromInstant: Instant = try { + Instant.parse(from) + } catch (e: Exception) { + if (e !is NullPointerException) logger.error( + "Failed to parse '$from' to Instant for 'from' parameter", + e + ) + Instant.ofEpochSecond(0) + } + val toInstant: Instant = try { + Instant.parse(to) + } catch (e: Exception) { + if (e !is NullPointerException) logger.error("Failed to parse '$to' to Instant for 'to' parameter", e) + Instant.now() + } + val balance = transactionRepository.sumBalanceByBudgetId(budget.id, fromInstant, toInstant) + ResponseEntity.ok(BudgetBalanceResponse(budget.id, balance)) + } + } + + @PostMapping( + value = [""], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun newBudget(@RequestBody request: BudgetRequest): ResponseEntity { + val budget = budgetRepository.save(Budget(request.name, request.description)) + val users: MutableSet = request.users + .mapNotNull { userPermissionRequest: UserPermissionRequest -> + val user = userRepository.findById(userPermissionRequest.user!!).orElse(null) ?: return@mapNotNull null + userPermissionsRepository.save( + UserPermission(budget, user, userPermissionRequest.permission) + ) + } + .toMutableSet() + val currentUserIncluded = users.any { userPermission: UserPermission -> userPermission.user!!.id == currentUser!!.id } + if (!currentUserIncluded) { + users.add( + userPermissionsRepository.save( + UserPermission(budget, currentUser!!, Permission.OWNER) + ) + ) + } + return ResponseEntity.ok(BudgetResponse(budget, ArrayList(users))) + } + + @PutMapping( + value = ["/{id}"], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun updateBudget( + @PathVariable id: String, + @RequestBody request: BudgetRequest + ): ResponseEntity { + return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget -> + budget.name = request.name + budget.description = request.description + val users = ArrayList() + if (request.users.isNotEmpty()) { + request.users.forEach(Consumer { userPermissionRequest: UserPermissionRequest -> + userRepository.findById(userPermissionRequest.user!!).ifPresent { requestedUser: User -> + users.add( + userPermissionsRepository.save( + UserPermission( + budget, + requestedUser, + userPermissionRequest.permission + ) + ) + ) + } + }) + } else { + users.addAll(userPermissionsRepository.findAllByBudget(budget, null)) + } + ResponseEntity.ok(BudgetResponse(budgetRepository.save(budget), users)) + } + } + + @DeleteMapping(value = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE]) + open fun deleteBudget(@PathVariable id: String): ResponseEntity { + return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget -> + budgetRepository.delete(budget) + ResponseEntity.ok().build() + } + } + + private fun getBudgetWithPermission( + budgetId: String, + permission: Permission, + callback: Function> + ): ResponseEntity { + val user = currentUser ?: return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build() + val userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null) + ?: return ResponseEntity.notFound().build() + if (userPermission.permission.isNotAtLeast(permission)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + val budget = userPermission.budget ?: return ResponseEntity.notFound().build() + return callback.apply(budget) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/budget/BudgetRepository.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/budget/BudgetRepository.kt new file mode 100644 index 0000000..6e0b1fb --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/budget/BudgetRepository.kt @@ -0,0 +1,5 @@ +package com.wbrawner.budgetserver.budget + +import org.springframework.data.repository.PagingAndSortingRepository + +interface BudgetRepository : PagingAndSortingRepository \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/category/Category.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/category/Category.kt new file mode 100644 index 0000000..6630a01 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/category/Category.kt @@ -0,0 +1,58 @@ +package com.wbrawner.budgetserver.category + +import com.wbrawner.budgetserver.budget.Budget +import com.wbrawner.budgetserver.randomString +import javax.persistence.* + +@Entity +data class Category( + @Id + val id: String = randomString(), + var title: String= "", + var description: String? = null, + var amount: Long = 0L, + @field:ManyToOne + @field:JoinColumn(nullable = false) + var budget: Budget? = null, + var expense: Boolean = true, + @field:Column(nullable = false, columnDefinition = "boolean default false") + var archived: Boolean = false +) + +data class NewCategoryRequest( + val title: String, + val description: String? = null, + val amount: Long, + val budgetId: String, + val expense: Boolean +) + +data class UpdateCategoryRequest( + val title: String? = null, + val description: String? = null, + val amount: Long? = null, + val expense: Boolean? = null, + val archived: Boolean? = null +) + +data class CategoryResponse( + val id: String, + val title: String, + val description: String?, + val amount: Long, + val budgetId: String, + val isExpense: Boolean, + val isArchived: Boolean +) { + constructor(category: Category) : this( + category.id, + category.title, + category.description, + category.amount, + category.budget!!.id, + category.expense, + category.archived + ) +} + +data class CategoryBalanceResponse(val id: String, val balance: Long) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/category/CategoryController.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/category/CategoryController.kt new file mode 100644 index 0000000..15b5471 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/category/CategoryController.kt @@ -0,0 +1,171 @@ +package com.wbrawner.budgetserver.category + +import com.wbrawner.budgetserver.ErrorResponse +import com.wbrawner.budgetserver.currentUser +import com.wbrawner.budgetserver.firstOfMonth +import com.wbrawner.budgetserver.permission.Permission +import com.wbrawner.budgetserver.permission.UserPermission +import com.wbrawner.budgetserver.permission.UserPermissionRepository +import com.wbrawner.budgetserver.transaction.Transaction +import com.wbrawner.budgetserver.transaction.TransactionRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.util.function.Consumer +import java.util.stream.Collectors +import javax.transaction.Transactional + +@RestController +@RequestMapping(path = ["/categories"]) +@Transactional +open class CategoryController( + private val categoryRepository: CategoryRepository, + private val transactionRepository: TransactionRepository, + private val userPermissionsRepository: UserPermissionRepository +) { + @GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getCategories( + @RequestParam(name = "budgetIds", required = false) budgetIds: List?, + @RequestParam(name = "isExpense", required = false) isExpense: Boolean?, + @RequestParam(name = "includeArchived", required = false) includeArchived: Boolean?, + @RequestParam(name = "count", required = false) count: Int?, + @RequestParam(name = "page", required = false) page: Int?, + @RequestParam(name = "false", required = false) sortBy: String?, + @RequestParam(name = "sortOrder", required = false) sortOrder: Sort.Direction? + ): ResponseEntity> { + val userPermissions: List + userPermissions = if (budgetIds != null && !budgetIds.isEmpty()) { + userPermissionsRepository.findAllByUserAndBudget_IdIn( + currentUser, + budgetIds, + PageRequest.of(page ?: 0, count ?: 1000) + ) + } else { + userPermissionsRepository.findAllByUser(currentUser, null) + } + val budgets = userPermissions.stream() + .map { obj: UserPermission -> obj.budget } + .collect(Collectors.toList()) + val pageRequest = PageRequest.of( + Math.min(0, if (page != null) page - 1 else 0), + count ?: 1000, + sortOrder ?: Sort.Direction.ASC, + sortBy ?: "title" + ) + val archived = if (includeArchived == null || includeArchived == false) false else null + val categories = categoryRepository.findAllByBudgetIn(budgets, isExpense, archived, pageRequest) + return ResponseEntity.ok( + categories.stream() + .map { category: Category -> CategoryResponse(category) } + .collect(Collectors.toList()) + ) + } + + @GetMapping(path = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getCategory(@PathVariable id: String?): ResponseEntity { + val budgets = userPermissionsRepository.findAllByUser(currentUser, null) + .stream() + .map { obj: UserPermission -> obj.budget } + .collect(Collectors.toList()) + val category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(CategoryResponse(category)) + } + + @GetMapping(path = ["/{id}/balance"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getCategoryBalance(@PathVariable id: String?): ResponseEntity { + val budgets = userPermissionsRepository.findAllByUser(currentUser, null) + .stream() + .map { obj: UserPermission -> obj.budget } + .collect(Collectors.toList()) + val category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null) + ?: return ResponseEntity.notFound().build() + val sum = transactionRepository.sumBalanceByCategoryId(category.id, firstOfMonth) + return ResponseEntity.ok(CategoryBalanceResponse(category.id, sum)) + } + + @PostMapping( + path = [""], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun newCategory(@RequestBody request: NewCategoryRequest): ResponseEntity { + val userResponse = userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId) + .orElse(null) ?: return ResponseEntity.badRequest().body(ErrorResponse("Invalid budget ID")) + if (userResponse.permission.isNotAtLeast(Permission.WRITE)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + val budget = userResponse.budget + return ResponseEntity.ok( + CategoryResponse( + categoryRepository.save( + Category( + title = request.title, + description = request.description, + amount = request.amount, + budget = budget, + expense = request.expense + ) + ) + ) + ) + } + + @PutMapping( + path = ["/{id}"], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun updateCategory( + @PathVariable id: String, + @RequestBody request: UpdateCategoryRequest + ): ResponseEntity { + val category = categoryRepository.findById(id).orElse(null) + ?: return ResponseEntity.notFound().build() + val userPermission = userPermissionsRepository.findByUserAndBudget_Id( + currentUser, + category.budget!!.id + ).orElse(null) + ?: return ResponseEntity.notFound().build() + if (userPermission.permission.isNotAtLeast(Permission.WRITE)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + if (request.title != null) { + category.title = request.title + } + if (request.description != null) { + category.description = request.description + } + if (request.amount != null) { + category.amount = request.amount + } + if (request.expense != null) { + category.expense = request.expense + } + if (request.archived != null) { + category.archived = request.archived + } + return ResponseEntity.ok(CategoryResponse(categoryRepository.save(category))) + } + + @DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE]) + open fun deleteCategory(@PathVariable id: String): ResponseEntity { + val category = categoryRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build() + val userPermission = + userPermissionsRepository.findByUserAndBudget_Id(currentUser, category.budget!!.id).orElse(null) + ?: return ResponseEntity.notFound().build() + if (userPermission.permission.isNotAtLeast(Permission.WRITE)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + transactionRepository.findAllByBudgetAndCategory(userPermission.budget, category) + .forEach(Consumer { transaction: Transaction -> + transaction.category = null + transactionRepository.save(transaction) + }) + categoryRepository.delete(category) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/category/CategoryRepository.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/category/CategoryRepository.kt new file mode 100644 index 0000000..683147c --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/category/CategoryRepository.kt @@ -0,0 +1,23 @@ +package com.wbrawner.budgetserver.category + +import com.wbrawner.budgetserver.budget.Budget +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.PagingAndSortingRepository +import java.util.* + +interface CategoryRepository : PagingAndSortingRepository { + fun findAllByBudget(budget: Budget?, pageable: Pageable?): List + + @Query("SELECT c FROM Category c where c.budget IN (:budgets) AND (:expense IS NULL OR c.expense = :expense) AND (:archived IS NULL OR c.archived = :archived)") + fun findAllByBudgetIn( + budgets: List?, + expense: Boolean?, + archived: Boolean?, + pageable: Pageable? + ): List + + fun findByBudgetInAndId(budgets: List?, id: String?): Optional + fun findByBudgetAndId(budget: Budget?, id: String?): Optional + fun findAllByBudgetInAndIdIn(budgets: List?, ids: List?, pageable: Pageable?): List +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/config/JdbcUserDetailsService.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/config/JdbcUserDetailsService.kt new file mode 100644 index 0000000..d50057d --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/config/JdbcUserDetailsService.kt @@ -0,0 +1,25 @@ +package com.wbrawner.budgetserver.config + +import com.wbrawner.budgetserver.user.UserRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Component + +@Component +open class JdbcUserDetailsService @Autowired constructor(private val userRepository: UserRepository) : UserDetailsService { + @Throws(UsernameNotFoundException::class) + override fun loadUserByUsername(username: String): UserDetails { + var userDetails: UserDetails? + userDetails = userRepository.findByName(username).orElse(null) + if (userDetails != null) { + return userDetails + } + userDetails = userRepository.findByEmail(username).orElse(null) + if (userDetails != null) { + return userDetails + } + throw UsernameNotFoundException("Unable to find user with username \$username") + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/config/MethodSecurity.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/config/MethodSecurity.kt new file mode 100644 index 0000000..137f8af --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/config/MethodSecurity.kt @@ -0,0 +1,9 @@ +package com.wbrawner.budgetserver.config + +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity +import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration + +@Configuration +@EnableGlobalMethodSecurity(prePostEnabled = true) +open class MethodSecurity : GlobalMethodSecurityConfiguration() \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/config/SecurityConfig.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/config/SecurityConfig.kt new file mode 100644 index 0000000..62749ee --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/config/SecurityConfig.kt @@ -0,0 +1,92 @@ +package com.wbrawner.budgetserver.config + +import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository +import com.wbrawner.budgetserver.session.UserSessionRepository +import com.wbrawner.budgetserver.user.UserRepository +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.env.Environment +import org.springframework.http.HttpMethod +import org.springframework.security.authentication.dao.DaoAuthenticationProvider +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +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.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.provisioning.JdbcUserDetailsManager +import org.springframework.web.cors.CorsConfiguration +import java.util.* +import javax.sql.DataSource + +@Configuration +@EnableWebSecurity +open class SecurityConfig( + private val env: Environment, + private val datasource: DataSource, + private val userSessionRepository: UserSessionRepository, + private val userRepository: UserRepository, + private val passwordResetRequestRepository: PasswordResetRequestRepository, + private val userDetailsService: JdbcUserDetailsService, + private val environment: Environment +) : WebSecurityConfigurerAdapter() { + @get:Bean + open val userDetailsManager: JdbcUserDetailsManager + get() { + val userDetailsManager = JdbcUserDetailsManager() + userDetailsManager.dataSource = datasource + return userDetailsManager + } + + @get:Bean + open val authenticationProvider: DaoAuthenticationProvider + get() { + val authProvider = TokenAuthenticationProvider(userSessionRepository, userRepository) + authProvider.setPasswordEncoder(passwordEncoder) + authProvider.setUserDetailsService(userDetailsService) + return authProvider + } + + @get:Bean + open val passwordEncoder: PasswordEncoder + get() = BCryptPasswordEncoder() + + public override fun configure(auth: AuthenticationManagerBuilder) { + auth.authenticationProvider(authenticationProvider) + } + + @Throws(Exception::class) + public override fun configure(http: HttpSecurity) { + http.authorizeRequests() + .antMatchers("/users/new", "/users/login") + .permitAll() + .anyRequest() + .authenticated() + .and() + .httpBasic() + .authenticationEntryPoint(SilentAuthenticationEntryPoint()) + .and() + .cors() + .configurationSource { + val corsConfig = CorsConfiguration() + corsConfig.applyPermitDefaultValues() + val corsDomains = environment.getProperty("twigs.cors.domains", "*") + corsConfig.allowedOrigins = Arrays.asList(*corsDomains.split(",").toTypedArray()) + corsConfig.allowedMethods = listOf( + HttpMethod.GET, + HttpMethod.POST, + HttpMethod.PUT, + HttpMethod.DELETE, + HttpMethod.OPTIONS + ).map { obj: HttpMethod -> obj.name } + corsConfig.allowCredentials = true + corsConfig + } + .and() + .csrf() + .disable() + .addFilter(TokenAuthenticationFilter(authenticationManager())) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/config/SessionAuthenticationToken.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/config/SessionAuthenticationToken.kt new file mode 100644 index 0000000..c351981 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/config/SessionAuthenticationToken.kt @@ -0,0 +1,18 @@ +package com.wbrawner.budgetserver.config + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.GrantedAuthority + +/** + * Creates a token with the supplied array of authorities. + * + * @param authorities the collection of GrantedAuthoritys for the principal + * represented by this authentication object. + * @param credentials + * @param principal + */ +class SessionAuthenticationToken( + principal: Any?, + credentials: Any?, + authorities: Collection +) : UsernamePasswordAuthenticationToken(principal, credentials, authorities) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.kt new file mode 100644 index 0000000..29f96d8 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.kt @@ -0,0 +1,22 @@ +package com.wbrawner.budgetserver.config + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import java.io.IOException +import javax.servlet.ServletException +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * Used to avoid browser prompts for authentication + */ +class SilentAuthenticationEntryPoint : AuthenticationEntryPoint { + @Throws(IOException::class, ServletException::class) + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.kt new file mode 100644 index 0000000..d3b2f84 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.kt @@ -0,0 +1,26 @@ +package com.wbrawner.budgetserver.config + +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter +import java.io.IOException +import javax.servlet.FilterChain +import javax.servlet.ServletException +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class TokenAuthenticationFilter(authenticationManager: AuthenticationManager?) : + BasicAuthenticationFilter(authenticationManager) { + @Throws(IOException::class, ServletException::class) + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { + val authHeader = request.getHeader("Authorization") + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + chain.doFilter(request, response) + return + } + val token = authHeader.substring(7) + val authentication = authenticationManager.authenticate(SessionAuthenticationToken(null, token, emptyList())) + SecurityContextHolder.getContext().authentication = authentication + chain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.kt new file mode 100644 index 0000000..26c1064 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.kt @@ -0,0 +1,53 @@ +package com.wbrawner.budgetserver.config + +import com.wbrawner.budgetserver.session.UserSessionRepository +import com.wbrawner.budgetserver.twoWeeksFromNow +import com.wbrawner.budgetserver.user.UserRepository +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.InternalAuthenticationServiceException +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.authentication.dao.DaoAuthenticationProvider +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.userdetails.UserDetails +import java.util.* + +class TokenAuthenticationProvider( + private val userSessionRepository: UserSessionRepository, + private val userRepository: UserRepository +) : DaoAuthenticationProvider() { + @Throws(AuthenticationException::class) + override fun additionalAuthenticationChecks( + userDetails: UserDetails, + authentication: UsernamePasswordAuthenticationToken + ) { + if (authentication !is SessionAuthenticationToken) { + // Additional checks aren't needed since they've already been handled + super.additionalAuthenticationChecks(userDetails, authentication) + } + } + + @Throws(AuthenticationException::class) + override fun authenticate(authentication: Authentication): Authentication { + return if (authentication is SessionAuthenticationToken) { + val session = userSessionRepository.findByToken(authentication.getCredentials() as String) + if (session!!.isEmpty || session.get().expiration.before(Date())) { + throw BadCredentialsException("Credentials expired") + } + val user = userRepository.findById(session.get().userId) + if (user.isEmpty) { + throw InternalAuthenticationServiceException("Failed to find user for token") + } + Thread { + + // Update the session on a background thread to avoid holding up the request longer than necessary + val updatedSession = session.get() + updatedSession.expiration = twoWeeksFromNow + userSessionRepository.save(updatedSession) + }.start() + SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities()) + } else { + super.authenticate(authentication) + } + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequest.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequest.kt new file mode 100644 index 0000000..f440a89 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequest.kt @@ -0,0 +1,17 @@ +package com.wbrawner.budgetserver.passwordresetrequest + +import com.wbrawner.budgetserver.randomString +import com.wbrawner.budgetserver.user.User +import java.util.* +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.ManyToOne + +@Entity +data class PasswordResetRequest( + @Id + val id: String = randomString(), + @field:ManyToOne private val user: User? = null, + private val date: Calendar = GregorianCalendar(), + private val token: String = randomString() +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequestRepository.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequestRepository.kt new file mode 100644 index 0000000..89b1b55 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/passwordresetrequest/PasswordResetRequestRepository.kt @@ -0,0 +1,5 @@ +package com.wbrawner.budgetserver.passwordresetrequest + +import org.springframework.data.repository.PagingAndSortingRepository + +interface PasswordResetRequestRepository : PagingAndSortingRepository \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/permission/Permission.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/permission/Permission.kt new file mode 100644 index 0000000..92ba26f --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/permission/Permission.kt @@ -0,0 +1,87 @@ +package com.wbrawner.budgetserver.permission + +import com.wbrawner.budgetserver.budget.Budget +import com.wbrawner.budgetserver.user.User +import com.wbrawner.budgetserver.user.UserResponse +import java.io.Serializable +import javax.persistence.* + +enum class Permission { + /** + * The user can read the content but cannot make any modifications. + */ + READ, + + /** + * The user can read and write the content but cannot make any modifications to the container of the content. + */ + WRITE, + + /** + * The user can read and write the content, and make modifications to the container of the content including things like name, description, and other users' permissions (with the exception of the owner user, whose role can never be removed by a user with only MANAGE permissions). + */ + MANAGE, + + /** + * The user has complete control over the resource. There can only be a single owner user at any given time. + */ + OWNER; + + fun isNotAtLeast(wanted: Permission): Boolean { + return ordinal < wanted.ordinal + } +} + +@Entity +data class UserPermission( + @field:EmbeddedId + val id: UserPermissionKey? = null, + @field:JoinColumn( + nullable = false, + name = "budget_id" + ) + @field:MapsId( + "budgetId" + ) + @field:ManyToOne + val budget: Budget? = null, + @field:JoinColumn( + nullable = false, + name = "user_id" + ) + @field:MapsId("userId") + @field:ManyToOne + val user: User? = null, + @field:Enumerated( + EnumType.STRING + ) + @field:JoinColumn( + nullable = false + ) + val permission: Permission = Permission.READ +) { + constructor(budget: Budget, user: User, permission: Permission) : this( + UserPermissionKey(budget.id, user.id), + budget, + user, + permission + ) +} + +@Embeddable +data class UserPermissionKey( + private val budgetId: String? = null, + private val userId: String? = null +) : Serializable + +data class UserPermissionRequest( + val user: String? = null, + val permission: Permission = Permission.READ +) + +data class UserPermissionResponse(val user: UserResponse, val permission: Permission?) { + constructor(userPermission: UserPermission) : this( + UserResponse(userPermission.user!!), + userPermission.permission + ) +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/permission/UserPermissionRepository.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/permission/UserPermissionRepository.kt new file mode 100644 index 0000000..b67d237 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/permission/UserPermissionRepository.kt @@ -0,0 +1,15 @@ +package com.wbrawner.budgetserver.permission + +import com.wbrawner.budgetserver.budget.Budget +import com.wbrawner.budgetserver.user.User +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.PagingAndSortingRepository +import java.util.* + +interface UserPermissionRepository : PagingAndSortingRepository { + fun findByUserAndBudget_Id(user: User?, budgetId: String?): Optional + fun findAllByUser(user: User?, pageable: Pageable?): List + fun findAllByBudget(budget: Budget?, pageable: Pageable?): List + fun findAllByUserAndBudget(user: User?, budget: Budget?, pageable: Pageable?): List + fun findAllByUserAndBudget_IdIn(user: User?, budgetIds: List?, pageable: Pageable?): List +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/session/Session.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/session/Session.kt new file mode 100644 index 0000000..cc464a6 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/session/Session.kt @@ -0,0 +1,18 @@ +package com.wbrawner.budgetserver.session + +import com.wbrawner.budgetserver.randomString +import com.wbrawner.budgetserver.twoWeeksFromNow +import javax.persistence.Entity +import javax.persistence.Id + +@Entity +data class Session(val userId: String = "") { + @Id + val id = randomString() + val token = randomString(255) + var expiration = twoWeeksFromNow +} + +data class SessionResponse(val token: String, val expiration: String) { + constructor(session: Session) : this(session.token, session.expiration.toInstant().toString()) +} diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/session/SessionCleanupTask.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/session/SessionCleanupTask.kt new file mode 100644 index 0000000..d9b7e59 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/session/SessionCleanupTask.kt @@ -0,0 +1,14 @@ +package com.wbrawner.budgetserver.session + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.util.* + +@Component +open class SessionCleanupTask @Autowired constructor(private val sessionRepository: UserSessionRepository) { + @Scheduled(cron = "0 0 * * * *") + open fun cleanup() { + sessionRepository.deleteAllByExpirationBefore(Date()) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/session/UserSessionRepository.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/session/UserSessionRepository.kt new file mode 100644 index 0000000..5fe2912 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/session/UserSessionRepository.kt @@ -0,0 +1,11 @@ +package com.wbrawner.budgetserver.session + +import org.springframework.data.repository.PagingAndSortingRepository +import java.util.* + +interface UserSessionRepository : PagingAndSortingRepository { + fun findByUserId(userId: String?): List? + fun findByToken(token: String?): Optional? + fun findByUserIdAndToken(userId: String?, token: String?): Optional? + fun deleteAllByExpirationBefore(expiration: Date?) +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/Transaction.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/Transaction.kt new file mode 100644 index 0000000..6d77b88 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/Transaction.kt @@ -0,0 +1,70 @@ +package com.wbrawner.budgetserver.transaction + +import com.wbrawner.budgetserver.budget.Budget +import com.wbrawner.budgetserver.category.Category +import com.wbrawner.budgetserver.randomString +import com.wbrawner.budgetserver.user.User +import java.time.Instant +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class Transaction( + @Id + val id: String = randomString(), + var title: String? = null, + var description: String? = null, + var date: Instant? = null, + var amount: Long? = null, + @field:ManyToOne var category: Category? = null, + var expense: Boolean? = null, + @field:JoinColumn(nullable = false) @field:ManyToOne val createdBy: User? = null, + @field:JoinColumn(nullable = false) @field:ManyToOne var budget: Budget? = null +) + +data class NewTransactionRequest( + val title: String, + val description: String? = null, + val date: String, + val amount: Long, + val categoryId: String? = null, + val expense: Boolean, + val budgetId: String +) + +data class UpdateTransactionRequest( + val title: String? = null, + val description: String? = null, + val date: String? = null, + val amount: Long? = null, + val categoryId: String? = null, + val expense: Boolean? = null, + val budgetId: String? = null, + val createdBy: String? = null +) + +data class TransactionResponse( + val id: String, + val title: String?, + val description: String?, + val date: String, + val amount: Long?, + val expense: Boolean?, + val budgetId: String, + val categoryId: String?, + val createdBy: String +) { + constructor(transaction: Transaction) : this( + transaction.id, + transaction.title, + transaction.description, + transaction.date.toString(), + transaction.amount, + transaction.expense, + transaction.budget!!.id, + transaction.category?.id, + transaction.createdBy!!.id + ) +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/TransactionController.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/TransactionController.kt new file mode 100644 index 0000000..762c389 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/TransactionController.kt @@ -0,0 +1,209 @@ +package com.wbrawner.budgetserver.transaction + +import com.wbrawner.budgetserver.ErrorResponse +import com.wbrawner.budgetserver.category.Category +import com.wbrawner.budgetserver.category.CategoryRepository +import com.wbrawner.budgetserver.currentUser +import com.wbrawner.budgetserver.endOfMonth +import com.wbrawner.budgetserver.firstOfMonth +import com.wbrawner.budgetserver.permission.Permission +import com.wbrawner.budgetserver.permission.UserPermission +import com.wbrawner.budgetserver.permission.UserPermissionRepository +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.time.Instant +import java.util.stream.Collectors +import javax.transaction.Transactional +import kotlin.math.min + +@RestController +@RequestMapping(path = ["/transactions"]) +@Transactional +open class TransactionController( + private val categoryRepository: CategoryRepository, + private val transactionRepository: TransactionRepository, + private val userPermissionsRepository: UserPermissionRepository +) { + private val logger = LoggerFactory.getLogger(TransactionController::class.java) + @GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getTransactions( + @RequestParam(value = "categoryIds", required = false) categoryIds: List?, + @RequestParam(value = "budgetIds", required = false) budgetIds: List?, + @RequestParam(value = "from", required = false) from: String?, + @RequestParam(value = "to", required = false) to: String?, + @RequestParam(value = "count", required = false) count: Int?, + @RequestParam(value = "page", required = false) page: Int?, + @RequestParam(value = "sortBy", required = false) sortBy: String?, + @RequestParam(value = "sortOrder", required = false) sortOrder: Sort.Direction? + ): ResponseEntity> { + val userPermissions: List = if (budgetIds != null && budgetIds.isNotEmpty()) { + userPermissionsRepository.findAllByUserAndBudget_IdIn( + currentUser, + budgetIds, + PageRequest.of(page ?: 0, count ?: 1000) + ) + } else { + userPermissionsRepository.findAllByUser(currentUser, null) + } + val budgets = userPermissions.stream() + .map { obj: UserPermission -> obj.budget } + .collect(Collectors.toList()) + var categories: List? = null + if (categoryIds != null && categoryIds.isNotEmpty()) { + categories = categoryRepository.findAllByBudgetInAndIdIn(budgets, categoryIds, null) + } + val pageRequest = PageRequest.of( + min(0, if (page != null) page - 1 else 0), + count ?: 1000, + sortOrder ?: Sort.Direction.DESC, + sortBy ?: "date" + ) + val fromInstant: Instant = try { + Instant.parse(from) + } catch (e: Exception) { + if (e !is NullPointerException) logger.error("Failed to parse '$from' to Instant for 'from' parameter", e) + firstOfMonth.toInstant() + } + val toInstant: Instant = try { + Instant.parse(to) + } catch (e: Exception) { + if (e !is NullPointerException) logger.error("Failed to parse '$to' to Instant for 'to' parameter", e) + endOfMonth.toInstant() + } + val query = if (categories == null) { + transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan( + budgets, + fromInstant, + toInstant, + pageRequest + ) + } else { + transactionRepository.findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan( + budgets, + categories, + fromInstant, + toInstant, + pageRequest + ) + } + val transactions = query.map { transaction: Transaction -> TransactionResponse(transaction) } + return ResponseEntity.ok(transactions) + } + + @GetMapping(path = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getTransaction(@PathVariable id: String?): ResponseEntity { + val budgets = userPermissionsRepository.findAllByUser(currentUser, null) + .stream() + .map { obj: UserPermission -> obj.budget } + .collect(Collectors.toList()) + val transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(TransactionResponse(transaction)) + } + + @PostMapping( + path = [""], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun newTransaction(@RequestBody request: NewTransactionRequest): ResponseEntity { + val userResponse = userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId) + .orElse(null) ?: return ResponseEntity.badRequest().body(ErrorResponse("Invalid budget ID")) + if (userResponse.permission.isNotAtLeast(Permission.WRITE)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + val budget = userResponse.budget + var category: Category? = null + if (request.categoryId != null) { + category = categoryRepository.findByBudgetAndId(budget, request.categoryId).orElse(null) + } + return ResponseEntity.ok( + TransactionResponse( + transactionRepository.save( + Transaction( + title = request.title, + description = request.description, + date = Instant.parse(request.date), + amount = request.amount, + category = category, + expense = request.expense, + createdBy = currentUser, + budget = budget + ) + ) + ) + ) + } + + @PutMapping( + path = ["/{id}"], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun updateTransaction( + @PathVariable id: String, + @RequestBody request: UpdateTransactionRequest + ): ResponseEntity { + val transaction = transactionRepository.findById(id).orElse(null) + ?: return ResponseEntity.notFound().build() + userPermissionsRepository.findByUserAndBudget_Id( + currentUser, + transaction.budget!!.id + ).orElse(null) + ?: return ResponseEntity.notFound().build() + if (request.title != null) { + transaction.title = request.title + } + if (request.description != null) { + transaction.description = request.description + } + if (request.date != null) { + transaction.date = Instant.parse(request.date) + } + if (request.amount != null) { + transaction.amount = request.amount + } + if (request.expense != null) { + transaction.expense = request.expense + } + if (request.budgetId != null) { + val newUserPermission = + userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId).orElse(null) + if (newUserPermission == null || newUserPermission.permission.isNotAtLeast(Permission.WRITE)) { + return ResponseEntity + .badRequest() + .body(ErrorResponse("Invalid budget")) + } + transaction.budget = newUserPermission.budget + } + if (request.categoryId != null) { + val category = categoryRepository.findByBudgetAndId(transaction.budget, request.categoryId).orElse(null) + ?: return ResponseEntity + .badRequest() + .body(ErrorResponse("Invalid category")) + transaction.category = category + } + return ResponseEntity.ok(TransactionResponse(transactionRepository.save(transaction))) + } + + @DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE]) + open fun deleteTransaction(@PathVariable id: String): ResponseEntity { + val transaction = transactionRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build() + // Check that the transaction belongs to an budget that the user has access to before deleting it + val userPermission = userPermissionsRepository.findByUserAndBudget_Id( + currentUser, + transaction.budget!!.id + ).orElse(null) + ?: return ResponseEntity.notFound().build() + if (userPermission.permission.isNotAtLeast(Permission.WRITE)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + transactionRepository.delete(transaction) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/TransactionRepository.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/TransactionRepository.kt new file mode 100644 index 0000000..6f2d660 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/transaction/TransactionRepository.kt @@ -0,0 +1,41 @@ +package com.wbrawner.budgetserver.transaction + +import com.wbrawner.budgetserver.budget.Budget +import com.wbrawner.budgetserver.category.Category +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.PagingAndSortingRepository +import java.time.Instant +import java.util.* + +interface TransactionRepository : PagingAndSortingRepository { + fun findByIdAndBudgetIn(id: String?, budgets: List?): Optional + fun findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan( + budgets: List?, + categories: List?, + start: Instant?, + end: Instant?, + pageable: Pageable? + ): List + + fun findAllByBudgetInAndDateGreaterThanAndDateLessThan( + budgets: List?, + start: Instant?, + end: Instant?, + pageable: Pageable? + ): List + + fun findAllByBudgetAndCategory(budget: Budget?, category: Category?): List + + @Query( + nativeQuery = true, + value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 0 AND date >= :from AND date <= :to), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 1 AND date >= :from AND date <= :to), 0));" + ) + fun sumBalanceByBudgetId(BudgetId: String?, from: Instant?, to: Instant?): Long + + @Query( + nativeQuery = true, + value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 0 AND date > :start), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 1 AND date > :start), 0));" + ) + fun sumBalanceByCategoryId(categoryId: String?, start: Date?): Long +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/user/User.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/user/User.kt new file mode 100644 index 0000000..6fae9b2 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/user/User.kt @@ -0,0 +1,66 @@ +package com.wbrawner.budgetserver.user + +import com.wbrawner.budgetserver.randomString +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Transient + +@Entity +data class User( + @Id + val id: String = randomString(), + @field:Column(name = "username") + var name: String = "", + @field:Column(name = "password") + var passphrase: String = "", + @Transient + private val _authorities: Collection = listOf(SimpleGrantedAuthority("USER")), + var email: String? = null +) : UserDetails { + + override fun getUsername(): String = name + + override fun getPassword(): String = passphrase + + override fun isAccountNonExpired(): Boolean { + return true + } + + override fun isAccountNonLocked(): Boolean { + return true + } + + override fun isCredentialsNonExpired(): Boolean { + return true + } + + override fun isEnabled(): Boolean { + return true + } + + override fun getAuthorities(): Collection { + return _authorities + } +} + +data class NewUserRequest( + val username: String, + val password: String, + val email: String? = null +) + +data class UpdateUserRequest( + val username: String? = null, + val password: String? = null, + val email: String? = null +) + +data class LoginRequest(val username: String? = null, val password: String? = null) + +data class UserResponse(val id: String, val username: String, val email: String?) { + constructor(user: User) : this(user.id, user.username, user.email) +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/user/UserController.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/user/UserController.kt new file mode 100644 index 0000000..ad97dec --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/user/UserController.kt @@ -0,0 +1,140 @@ +package com.wbrawner.budgetserver.user + +import com.wbrawner.budgetserver.ErrorResponse +import com.wbrawner.budgetserver.budget.BudgetRepository +import com.wbrawner.budgetserver.currentUser +import com.wbrawner.budgetserver.permission.UserPermission +import com.wbrawner.budgetserver.permission.UserPermissionRepository +import com.wbrawner.budgetserver.permission.UserPermissionResponse +import com.wbrawner.budgetserver.session.Session +import com.wbrawner.budgetserver.session.SessionResponse +import com.wbrawner.budgetserver.session.UserSessionRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.authentication.dao.DaoAuthenticationProvider +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.web.bind.annotation.* +import javax.transaction.Transactional + +@RestController +@RequestMapping("/users") +@Transactional +open class UserController @Autowired constructor( + private val budgetRepository: BudgetRepository, + private val userRepository: UserRepository, + private val userSessionRepository: UserSessionRepository, + private val passwordEncoder: PasswordEncoder, + private val userPermissionsRepository: UserPermissionRepository, + private val authenticationProvider: DaoAuthenticationProvider +) { + @GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getUsers(budgetId: String): ResponseEntity> { + val budget = budgetRepository.findById(budgetId).orElse(null) + ?: return ResponseEntity.notFound().build() + val userPermissions = userPermissionsRepository.findAllByBudget(budget, null) + val userInBudget = userPermissions.stream() + .anyMatch { userPermission: UserPermission -> userPermission.user!!.id == currentUser!!.id } + return if (!userInBudget) { + ResponseEntity.notFound().build() + } else ResponseEntity.ok( + userPermissions.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) } + ) + } + + @PostMapping(path = ["/login"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun login(@RequestBody request: LoginRequest): ResponseEntity { + val authReq = UsernamePasswordAuthenticationToken(request.username, request.password) + val auth: Authentication + auth = try { + authenticationProvider.authenticate(authReq) + } catch (e: AuthenticationException) { + return ResponseEntity.notFound().build() + } + SecurityContextHolder.getContext().authentication = auth + val session = userSessionRepository.save(Session(currentUser!!.id)) + return ResponseEntity.ok(SessionResponse(session)) + } + + @GetMapping(path = ["/me"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun getProfile(): ResponseEntity { + val user = currentUser + ?: return ResponseEntity.status(401).build() + return ResponseEntity.ok(UserResponse(user)) + } + + @GetMapping(path = ["/search"], produces = [MediaType.APPLICATION_JSON_VALUE]) + open fun searchUsers(query: String?): ResponseEntity> { + return ResponseEntity.ok( + userRepository.findByNameContains(query) + .map { user: User -> UserResponse(user) } + ) + } + + @GetMapping(path = ["/{id}"]) + open fun getUser(@PathVariable id: String): ResponseEntity { + val user = userRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(UserResponse(user)) + } + + @PostMapping( + path = [""], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + fun newUser(@RequestBody request: NewUserRequest): ResponseEntity { + if (userRepository.findByName(request.username).isPresent) return ResponseEntity.badRequest() + .body(ErrorResponse("Username taken")) + if (userRepository.findByEmail(request.email).isPresent) return ResponseEntity.badRequest() + .body(ErrorResponse("Email taken")) + return if (request.password.isBlank()) ResponseEntity.badRequest() + .body(ErrorResponse("Invalid password")) else ResponseEntity.ok( + UserResponse( + userRepository.save( + User( + name = request.username, + passphrase = passwordEncoder.encode(request.password), + email = request.email + ) + ) + ) + ) + } + + @PutMapping( + path = ["/{id}"], + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + fun updateUser(@PathVariable id: String, @RequestBody request: UpdateUserRequest): ResponseEntity { + if (currentUser?.id != id) return ResponseEntity.status(403).build() + var user = userRepository.findById(currentUser!!.id).orElse(null) + ?: return ResponseEntity.notFound().build() + if (request.username != null) { + if (userRepository.findByName(request.username).isPresent) return ResponseEntity.badRequest() + .body(ErrorResponse("Username taken")) + user = user.copy(name = request.username) + } + if (request.email != null) { + if (userRepository.findByEmail(request.email).isPresent) return ResponseEntity.badRequest() + .body(ErrorResponse("Email taken")) + user = user.copy(email = request.email) + } + if (request.password != null) { + if (request.password.isBlank()) return ResponseEntity.badRequest().body(ErrorResponse("Invalid password")) + user = user.copy(passphrase = passwordEncoder.encode(request.password)) + } + return ResponseEntity.ok(UserResponse(userRepository.save(user))) + } + + @DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE]) + fun deleteUser(@PathVariable id: String): ResponseEntity { + if (currentUser?.id != id) return ResponseEntity.status(403).build() + userRepository.deleteById(id) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/budgetserver/user/UserRepository.kt b/api/src/main/kotlin/com/wbrawner/budgetserver/user/UserRepository.kt new file mode 100644 index 0000000..235696e --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/budgetserver/user/UserRepository.kt @@ -0,0 +1,10 @@ +package com.wbrawner.budgetserver.user + +import org.springframework.data.repository.PagingAndSortingRepository +import java.util.* + +interface UserRepository : PagingAndSortingRepository { + fun findByName(name: String?): Optional + fun findByNameContains(name: String?): List + fun findByEmail(email: String?): Optional +} \ No newline at end of file diff --git a/build.gradle b/build.gradle deleted file mode 100644 index fd68fce..0000000 --- a/build.gradle +++ /dev/null @@ -1,29 +0,0 @@ -buildscript { - repositories { - mavenLocal() - mavenCentral() - maven { url "http://repo.spring.io/snapshot" } - maven { url "http://repo.spring.io/milestone" } - } - - dependencies { - classpath "org.springframework.boot:spring-boot-gradle-plugin:2.2.2.RELEASE" - } -} - -apply plugin: 'java' - -allprojects { - repositories { - mavenLocal() - mavenCentral() - maven { url "http://repo.spring.io/snapshot" } - maven { url "http://repo.spring.io/milestone" } - maven { url "http://repo.maven.apache.org/maven2" } - } - - jar { - group = "com.wbrawner" - archiveVersion.set("0.0.1-SNAPSHOT") - } -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..1b232dc --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,37 @@ +import java.net.URI +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +buildscript { + val kotlinVersion: String by extra("1.5.10") + repositories { + mavenLocal() + mavenCentral() + maven { url = java.net.URI("https://repo.spring.io/snapshot") } + maven { url = java.net.URI("https://repo.spring.io/milestone") } + } + + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + classpath("org.springframework.boot:spring-boot-gradle-plugin:2.2.4.RELEASE") + } +} + +plugins { + java + kotlin("jvm") version "1.5.10" +} + +allprojects { + repositories { + mavenLocal() + mavenCentral() + maven { + url = URI("http://repo.maven.apache.org/maven2") + } + } + group = "com.wbrawner" + version = "0.0.1-SNAPSHOT" + tasks.withType { + kotlinOptions.jvmTarget = "14" + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 26a6fa6..e559476 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ #Fri Feb 07 18:11:46 CST 2020 -distributionUrl=https\://services.gradle.org/distributions/gradle-6.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-all.zip distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 7881ed3..0000000 --- a/settings.gradle +++ /dev/null @@ -1,6 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - */ - -rootProject.name = 'twigs' -include ':api' \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..4b2f3a7 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "twigs" +include(":api") \ No newline at end of file