Convert code to Kotlin
Convert ErrorResponse to Kotlin Convert TwigsServerApplication to Kotlin Convert BudgetController to Kotlin Convert CategoryController to Kotlin Convert UserController to Kotlin Convert TransactionController to Kotlin Convert Budget models to Kotlin Convert Category models to Kotlin Convert PasswordResetRequest models to Kotlin Convert Transaction models to Kotlin Convert user models to Kotlin Convert permission models to Kotlin Conver session models to Kotlin Finish Kotlin conversions Fix more Kotlin conversion issues Remove swagger Use Kotlin DSL for Gradle scripts Signed-off-by: William Brawner <me@wbrawner.com> Resolve some warnings Move source to src/main/kotlin Move source to src/main/kotlin
This commit is contained in:
parent
9b3b4fac17
commit
8e6454cb72
84 changed files with 1595 additions and 2538 deletions
|
@ -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
|
|
43
api/build.gradle.kts
Normal file
43
api/build.gradle.kts
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Transaction> transactions = new TreeSet<>();
|
|
||||||
@OneToMany(mappedBy = "budget")
|
|
||||||
private final Set<Category> categories = new TreeSet<>();
|
|
||||||
@OneToMany(mappedBy = "budget")
|
|
||||||
private final Set<Transaction> 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<Transaction> getTransactions() {
|
|
||||||
return transactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Set<Category> getCategories() {
|
|
||||||
return categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Set<Transaction> getUsers() {
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<List<BudgetResponse>> getBudgets(Integer page, Integer count) {
|
|
||||||
User user = getCurrentUser();
|
|
||||||
if (user == null) {
|
|
||||||
return ResponseEntity.status(401).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
List<BudgetResponse> 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<BudgetResponse> 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<BudgetBalanceResponse> 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<BudgetResponse> 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<BudgetResponse> 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<UserPermission>();
|
|
||||||
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<Void> deleteBudget(@PathVariable String id) {
|
|
||||||
return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> {
|
|
||||||
budgetRepository.delete(budget);
|
|
||||||
return ResponseEntity.ok().build();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T> ResponseEntity<T> getBudgetWithPermission(
|
|
||||||
String budgetId,
|
|
||||||
Permission permission,
|
|
||||||
Function<Budget, ResponseEntity<T>> 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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
package com.wbrawner.budgetserver.budget;
|
|
||||||
|
|
||||||
import org.springframework.data.repository.PagingAndSortingRepository;
|
|
||||||
|
|
||||||
public interface BudgetRepository extends PagingAndSortingRepository<Budget, String> {
|
|
||||||
}
|
|
|
@ -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<UserPermissionRequest> users = new HashSet<>();
|
|
||||||
|
|
||||||
public BudgetRequest() {
|
|
||||||
// Required empty constructor
|
|
||||||
this("", "", Collections.emptySet());
|
|
||||||
}
|
|
||||||
|
|
||||||
public BudgetRequest(String name, String description, Set<UserPermissionRequest> users) {
|
|
||||||
this.name = name;
|
|
||||||
this.description = description;
|
|
||||||
this.users.addAll(users);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
public Set<UserPermissionRequest> getUsers() {
|
|
||||||
return Set.copyOf(users);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<UserPermissionResponse> users;
|
|
||||||
|
|
||||||
public BudgetResponse(String id, String name, String description, List<UserPermissionResponse> users) {
|
|
||||||
this.id = id;
|
|
||||||
this.name = name;
|
|
||||||
this.description = description;
|
|
||||||
this.users = users;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BudgetResponse(Budget budget, List<UserPermission> users) {
|
|
||||||
this(
|
|
||||||
Objects.requireNonNull(budget.getId()),
|
|
||||||
budget.getName(),
|
|
||||||
budget.getDescription(),
|
|
||||||
users.stream()
|
|
||||||
.map(UserPermissionResponse::new)
|
|
||||||
.collect(Collectors.toList())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<UserPermissionResponse> getUsers() {
|
|
||||||
return List.copyOf(users);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Category> {
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<List<CategoryResponse>> getCategories(
|
|
||||||
@RequestParam(name = "budgetIds", required = false) List<String> 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<UserPermission> 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<Category> 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<CategoryResponse> 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<CategoryBalanceResponse> 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<Object> 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<CategoryResponse> 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<Void> 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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Category, String> {
|
|
||||||
List<Category> 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<Category> findAllByBudgetIn(List<Budget> budgets, Boolean expense, Boolean archived, Pageable pageable);
|
|
||||||
|
|
||||||
Optional<Category> findByBudgetInAndId(List<Budget> budgets, String id);
|
|
||||||
|
|
||||||
Optional<Category> findByBudgetAndId(Budget budget, String id);
|
|
||||||
|
|
||||||
List<Category> findAllByBudgetInAndIdIn(List<Budget> budgets, List<String> ids, Pageable pageable);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 {
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 <tt>GrantedAuthority</tt>s for the principal
|
|
||||||
* represented by this authentication object.
|
|
||||||
* @param credentials
|
|
||||||
* @param principal
|
|
||||||
*/
|
|
||||||
public SessionAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
|
|
||||||
super(principal, credentials, authorities);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
package com.wbrawner.budgetserver.passwordresetrequest;
|
|
||||||
|
|
||||||
import org.springframework.data.repository.PagingAndSortingRepository;
|
|
||||||
|
|
||||||
public interface PasswordResetRequestRepository extends PagingAndSortingRepository<PasswordResetRequest, Long> {
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<UserPermission, UserPermissionKey> {
|
|
||||||
Optional<UserPermission> findByUserAndBudget_Id(User user, String budgetId);
|
|
||||||
|
|
||||||
List<UserPermission> findAllByUser(User user, Pageable pageable);
|
|
||||||
|
|
||||||
List<UserPermission> findAllByBudget(Budget budget, Pageable pageable);
|
|
||||||
|
|
||||||
List<UserPermission> findAllByUserAndBudget(User user, Budget budget, Pageable pageable);
|
|
||||||
|
|
||||||
List<UserPermission> findAllByUserAndBudget_IdIn(User user, List<String> budgetIds, Pageable pageable);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Session, String> {
|
|
||||||
List<Session> findByUserId(String userId);
|
|
||||||
|
|
||||||
Optional<Session> findByToken(String token);
|
|
||||||
|
|
||||||
Optional<Session> findByUserIdAndToken(String userId, String token);
|
|
||||||
|
|
||||||
void deleteAllByExpirationBefore(Date expiration);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Transaction> {
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<List<TransactionResponse>> getTransactions(
|
|
||||||
@RequestParam(value = "categoryIds", required = false) List<String> categoryIds,
|
|
||||||
@RequestParam(value = "budgetIds", required = false) List<String> 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<UserPermission> 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<Category> 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<TransactionResponse> 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<Object> 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<Object> 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<Void> 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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Transaction, String> {
|
|
||||||
Optional<Transaction> findByIdAndBudgetIn(String id, List<Budget> budgets);
|
|
||||||
|
|
||||||
List<Transaction> findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
|
|
||||||
List<Budget> budgets,
|
|
||||||
List<Category> categories,
|
|
||||||
Instant start,
|
|
||||||
Instant end,
|
|
||||||
Pageable pageable
|
|
||||||
);
|
|
||||||
|
|
||||||
List<Transaction> findAllByBudgetInAndDateGreaterThanAndDateLessThan(
|
|
||||||
List<Budget> budgets,
|
|
||||||
Instant start,
|
|
||||||
Instant end,
|
|
||||||
Pageable pageable
|
|
||||||
);
|
|
||||||
|
|
||||||
List<Transaction> 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);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<GrantedAuthority> 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<? extends GrantedAuthority> getAuthorities() {
|
|
||||||
return authorities;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
@ -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<List<UserPermissionResponse>> 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<SessionResponse> 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<UserResponse> 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<List<UserResponse>> searchUsers(String query) {
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
userRepository.findByUsernameContains(query)
|
|
||||||
.stream()
|
|
||||||
.map(UserResponse::new)
|
|
||||||
.collect(Collectors.toList())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(path = "/{id}")
|
|
||||||
ResponseEntity<UserResponse> 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<Object> 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<Object> 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<Void> deleteUser(@PathVariable String id) {
|
|
||||||
if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build();
|
|
||||||
userRepository.deleteById(id);
|
|
||||||
return ResponseEntity.ok().build();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<User, String> {
|
|
||||||
Optional<User> findByUsername(String username);
|
|
||||||
|
|
||||||
Optional<User> findByUsernameAndPassword(String username, String password);
|
|
||||||
|
|
||||||
List<User> findByUsernameContains(String username);
|
|
||||||
|
|
||||||
Optional<User> findByEmail(String email);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.wbrawner.budgetserver
|
||||||
|
|
||||||
|
data class ErrorResponse(val message: String)
|
|
@ -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<String>) {
|
||||||
|
SpringApplication.run(TwigsServerApplication::class.java, *args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
74
api/src/main/kotlin/com/wbrawner/budgetserver/Utils.kt
Normal file
74
api/src/main/kotlin/com/wbrawner/budgetserver/Utils.kt
Normal file
|
@ -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 <T> getBudgetWithPermission(
|
||||||
|
transactionRepository: TransactionRepository,
|
||||||
|
userPermissionsRepository: UserPermissionRepository,
|
||||||
|
transactionId: String,
|
||||||
|
permission: Permission,
|
||||||
|
action: (Budget) -> ResponseEntity<T>
|
||||||
|
): ResponseEntity<T> {
|
||||||
|
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!!)
|
||||||
|
}
|
|
@ -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<Transaction> = TreeSet(),
|
||||||
|
@OneToMany(mappedBy = "budget")
|
||||||
|
val categories: Set<Category> = TreeSet(),
|
||||||
|
@OneToMany(mappedBy = "budget")
|
||||||
|
val users: Set<Transaction> = HashSet()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BudgetRequest(
|
||||||
|
val name: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val users: Set<UserPermissionRequest> = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BudgetResponse(
|
||||||
|
val id: String,
|
||||||
|
val name: String?,
|
||||||
|
val description: String?,
|
||||||
|
private val users: List<UserPermissionResponse>
|
||||||
|
) {
|
||||||
|
constructor(budget: Budget, users: List<UserPermission>) : this(
|
||||||
|
Objects.requireNonNull<String>(budget.id),
|
||||||
|
budget.name,
|
||||||
|
budget.description,
|
||||||
|
users.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetBalanceResponse(val id: String, val balance: Long)
|
|
@ -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<List<BudgetResponse>> {
|
||||||
|
val user = currentUser ?: return ResponseEntity.status(401).build()
|
||||||
|
val budgets: List<BudgetResponse> = 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<BudgetResponse> {
|
||||||
|
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<BudgetBalanceResponse> {
|
||||||
|
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<BudgetResponse> {
|
||||||
|
val budget = budgetRepository.save(Budget(request.name, request.description))
|
||||||
|
val users: MutableSet<UserPermission> = 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<BudgetResponse> {
|
||||||
|
return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget ->
|
||||||
|
budget.name = request.name
|
||||||
|
budget.description = request.description
|
||||||
|
val users = ArrayList<UserPermission>()
|
||||||
|
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<Void?> {
|
||||||
|
return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget ->
|
||||||
|
budgetRepository.delete(budget)
|
||||||
|
ResponseEntity.ok().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> getBudgetWithPermission(
|
||||||
|
budgetId: String,
|
||||||
|
permission: Permission,
|
||||||
|
callback: Function<Budget, ResponseEntity<T>>
|
||||||
|
): ResponseEntity<T> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.wbrawner.budgetserver.budget
|
||||||
|
|
||||||
|
import org.springframework.data.repository.PagingAndSortingRepository
|
||||||
|
|
||||||
|
interface BudgetRepository : PagingAndSortingRepository<Budget, String>
|
|
@ -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)
|
|
@ -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<String?>?,
|
||||||
|
@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<List<CategoryResponse>> {
|
||||||
|
val userPermissions: List<UserPermission>
|
||||||
|
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<CategoryResponse> {
|
||||||
|
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<CategoryBalanceResponse> {
|
||||||
|
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<Any> {
|
||||||
|
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<CategoryResponse> {
|
||||||
|
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<Void> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Category, String> {
|
||||||
|
fun findAllByBudget(budget: Budget?, pageable: Pageable?): List<Category>
|
||||||
|
|
||||||
|
@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<Budget?>?,
|
||||||
|
expense: Boolean?,
|
||||||
|
archived: Boolean?,
|
||||||
|
pageable: Pageable?
|
||||||
|
): List<Category>
|
||||||
|
|
||||||
|
fun findByBudgetInAndId(budgets: List<Budget?>?, id: String?): Optional<Category>
|
||||||
|
fun findByBudgetAndId(budget: Budget?, id: String?): Optional<Category>
|
||||||
|
fun findAllByBudgetInAndIdIn(budgets: List<Budget?>?, ids: List<String?>?, pageable: Pageable?): List<Category>
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 <tt>GrantedAuthority</tt>s for the principal
|
||||||
|
* represented by this authentication object.
|
||||||
|
* @param credentials
|
||||||
|
* @param principal
|
||||||
|
*/
|
||||||
|
class SessionAuthenticationToken(
|
||||||
|
principal: Any?,
|
||||||
|
credentials: Any?,
|
||||||
|
authorities: Collection<GrantedAuthority>
|
||||||
|
) : UsernamePasswordAuthenticationToken(principal, credentials, authorities)
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
)
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.wbrawner.budgetserver.passwordresetrequest
|
||||||
|
|
||||||
|
import org.springframework.data.repository.PagingAndSortingRepository
|
||||||
|
|
||||||
|
interface PasswordResetRequestRepository : PagingAndSortingRepository<PasswordResetRequest, Long>
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<UserPermission, UserPermissionKey> {
|
||||||
|
fun findByUserAndBudget_Id(user: User?, budgetId: String?): Optional<UserPermission>
|
||||||
|
fun findAllByUser(user: User?, pageable: Pageable?): List<UserPermission>
|
||||||
|
fun findAllByBudget(budget: Budget?, pageable: Pageable?): List<UserPermission>
|
||||||
|
fun findAllByUserAndBudget(user: User?, budget: Budget?, pageable: Pageable?): List<UserPermission>
|
||||||
|
fun findAllByUserAndBudget_IdIn(user: User?, budgetIds: List<String?>?, pageable: Pageable?): List<UserPermission>
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.wbrawner.budgetserver.session
|
||||||
|
|
||||||
|
import org.springframework.data.repository.PagingAndSortingRepository
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
interface UserSessionRepository : PagingAndSortingRepository<Session, String> {
|
||||||
|
fun findByUserId(userId: String?): List<Session?>?
|
||||||
|
fun findByToken(token: String?): Optional<Session?>?
|
||||||
|
fun findByUserIdAndToken(userId: String?, token: String?): Optional<Session?>?
|
||||||
|
fun deleteAllByExpirationBefore(expiration: Date?)
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<String>?,
|
||||||
|
@RequestParam(value = "budgetIds", required = false) budgetIds: List<String>?,
|
||||||
|
@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<List<TransactionResponse>> {
|
||||||
|
val userPermissions: List<UserPermission> = 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<Category?>? = 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<TransactionResponse> {
|
||||||
|
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<Any> {
|
||||||
|
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<Any> {
|
||||||
|
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<Void> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Transaction, String> {
|
||||||
|
fun findByIdAndBudgetIn(id: String?, budgets: List<Budget?>?): Optional<Transaction>
|
||||||
|
fun findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
|
||||||
|
budgets: List<Budget?>?,
|
||||||
|
categories: List<Category?>?,
|
||||||
|
start: Instant?,
|
||||||
|
end: Instant?,
|
||||||
|
pageable: Pageable?
|
||||||
|
): List<Transaction>
|
||||||
|
|
||||||
|
fun findAllByBudgetInAndDateGreaterThanAndDateLessThan(
|
||||||
|
budgets: List<Budget?>?,
|
||||||
|
start: Instant?,
|
||||||
|
end: Instant?,
|
||||||
|
pageable: Pageable?
|
||||||
|
): List<Transaction>
|
||||||
|
|
||||||
|
fun findAllByBudgetAndCategory(budget: Budget?, category: Category?): List<Transaction>
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
66
api/src/main/kotlin/com/wbrawner/budgetserver/user/User.kt
Normal file
66
api/src/main/kotlin/com/wbrawner/budgetserver/user/User.kt
Normal file
|
@ -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<GrantedAuthority> = 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<GrantedAuthority> {
|
||||||
|
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)
|
||||||
|
}
|
|
@ -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<List<UserPermissionResponse>> {
|
||||||
|
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<SessionResponse> {
|
||||||
|
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<UserResponse> {
|
||||||
|
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<List<UserResponse>> {
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
userRepository.findByNameContains(query)
|
||||||
|
.map { user: User -> UserResponse(user) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(path = ["/{id}"])
|
||||||
|
open fun getUser(@PathVariable id: String): ResponseEntity<UserResponse> {
|
||||||
|
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<Any> {
|
||||||
|
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<Any> {
|
||||||
|
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<Void> {
|
||||||
|
if (currentUser?.id != id) return ResponseEntity.status(403).build()
|
||||||
|
userRepository.deleteById(id)
|
||||||
|
return ResponseEntity.ok().build()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.wbrawner.budgetserver.user
|
||||||
|
|
||||||
|
import org.springframework.data.repository.PagingAndSortingRepository
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
interface UserRepository : PagingAndSortingRepository<User, String> {
|
||||||
|
fun findByName(name: String?): Optional<User>
|
||||||
|
fun findByNameContains(name: String?): List<User>
|
||||||
|
fun findByEmail(email: String?): Optional<User>
|
||||||
|
}
|
29
build.gradle
29
build.gradle
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
37
build.gradle.kts
Normal file
37
build.gradle.kts
Normal file
|
@ -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<KotlinCompile> {
|
||||||
|
kotlinOptions.jvmTarget = "14"
|
||||||
|
}
|
||||||
|
}
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,5 +1,5 @@
|
||||||
#Fri Feb 07 18:11:46 CST 2020
|
#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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
/*
|
|
||||||
* This file was generated by the Gradle 'init' task.
|
|
||||||
*/
|
|
||||||
|
|
||||||
rootProject.name = 'twigs'
|
|
||||||
include ':api'
|
|
2
settings.gradle.kts
Normal file
2
settings.gradle.kts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
rootProject.name = "twigs"
|
||||||
|
include(":api")
|
Loading…
Reference in a new issue