diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..0aa927d --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,54 @@ +name: Build and Deploy Spring Boot Server + +on: + push: + branches: + - dev + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # 1. Checkout the code + - name: Checkout code + uses: actions/checkout@v2 + + # 2. Set up Java and Maven + - name: Set up JDK (Eclipse Temurin) + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" + cache: maven + + # 3. Verify Maven installation + - name: Install Maven + run: | + sudo apt-get update + sudo apt-get install -y maven + mvn -version + + # 4. Build the Spring Boot application + - name: Build Spring Boot Application + run: | + mvn clean package -DskipTests + + # 5. Set up Docker + - name: Set up Docker + run: | + docker --version + + # 6. Build the Docker image + - name: Build and Package Docker Image + run: | + docker build -t tea.zendric.de/cedric/xpensely-server:latest . + + # 7. Docker login + - name: Login to Docker Registry + run: | + echo "${{ secrets.TEAPASSWORD }}" | docker login tea.zendric.de -u ${{ secrets.TEAUSER }} --password-stdin + # 8. Push Docker image + - name: Push the Docker Image to registry + run: | + docker push tea.zendric.de/cedric/xpensely-server:latest diff --git a/.gitea/workflows/tag_release.yml b/.gitea/workflows/tag_release.yml new file mode 100644 index 0000000..7a4092c --- /dev/null +++ b/.gitea/workflows/tag_release.yml @@ -0,0 +1,62 @@ +name: Build and Deploy Versioned Spring Boot Server + +on: + push: + tags: # Match all tags + - "*" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # 1. Checkout the code + - name: Checkout code + uses: actions/checkout@v2 + + # 2. Set up Java and Maven + - name: Set up JDK (Eclipse Temurin) + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" + cache: maven + + # 3. Verify Maven installation + - name: Install Maven + run: | + sudo apt-get update + sudo apt-get install -y maven + mvn -version + + # 4. Build the Spring Boot application + - name: Build Spring Boot Application + run: | + mvn clean package -DskipTests + + # 5. Set up Docker + - name: Set up Docker + run: | + docker --version + + # 6. Extract the tag name + - name: Extract Tag Version + id: extract_version + run: | + TAG_VERSION=${GITEA_REF#refs/tags/} + echo "TAG_VERSION=$TAG_VERSION" >> $GITHUB_ENV + + # 7. Build the Docker image with the tag + - name: Build and Package Docker Image + run: | + docker build -t tea.zendric.de/cedric/xpensely-server:${{ env.TAG_VERSION }} . + + # 8. Docker login + - name: Login to Docker Registry + run: | + echo "${{ secrets.TEAPASSWORD }}" | docker login tea.zendric.de -u ${{ secrets.TEAUSER }} --password-stdin + + # 9. Push the Docker image with the tag + - name: Push the Docker Image to registry + run: | + docker push tea.zendric.de/cedric/xpensely-server:${{ env.TAG_VERSION }} diff --git a/.gitignore b/.gitignore index f8185d3..37fdfda 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ +.env ### STS ### .apt_generated diff --git a/apiDesign.drawio b/apiDesign.drawio new file mode 100644 index 0000000..e276b67 --- /dev/null +++ b/apiDesign.drawio @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..886656c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3.8" +services: + xpensely-server: + image: tea.zendric.de/cedric/xpensely-server:latest + container_name: xpensely-server + ports: + - 3636:8080 + environment: + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + DB_PORT: 5434 + DB_NAME: ${DB_P_NAME} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + depends_on: + postgresdb: + condition: service_healthy + networks: + - xpensely-network + postgresdb: + image: postgres:14 + container_name: postgresdb + ports: + - 5434:5432 + environment: + POSTGRES_DB: ${DB_P_NAME} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + networks: + - xpensely-network + volumes: + - db_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"] + interval: 10s + timeout: 5s + retries: 5 +volumes: + db_data: null +networks: + xpensely-network: null diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..f7e9a76 --- /dev/null +++ b/dockerfile @@ -0,0 +1,7 @@ +FROM openjdk:17-jdk-slim + +COPY ./target/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/pom.xml b/pom.xml index fdac1ff..df740a9 100644 --- a/pom.xml +++ b/pom.xml @@ -10,9 +10,9 @@ de.zendric.app XpenselyServer - 0.0.1-SNAPSHOT + 1.0.0 XpenselyServer - Demo project for Spring Boot + XpenselyServer used to handle the Xpensely App @@ -38,11 +38,18 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-oauth2-client + org.springframework.boot spring-boot-starter-web - org.springframework.boot spring-boot-devtools diff --git a/src/main/java/de/zendric/app/XpenselyServer/XpenselyServerApplication.java b/src/main/java/de/zendric/app/xpensely_server/XpenselyServerApplication.java similarity index 70% rename from src/main/java/de/zendric/app/XpenselyServer/XpenselyServerApplication.java rename to src/main/java/de/zendric/app/xpensely_server/XpenselyServerApplication.java index 0fe260e..ae78652 100644 --- a/src/main/java/de/zendric/app/XpenselyServer/XpenselyServerApplication.java +++ b/src/main/java/de/zendric/app/xpensely_server/XpenselyServerApplication.java @@ -1,9 +1,11 @@ -package de.zendric.app.XpenselyServer; +package de.zendric.app.xpensely_server; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class XpenselyServerApplication { public static void main(String[] args) { diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java new file mode 100644 index 0000000..e0b9994 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java @@ -0,0 +1,73 @@ +package de.zendric.app.xpensely_server.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.AppUserCreateRequest; +import de.zendric.app.xpensely_server.model.UsernameAlreadyExistsException; +import de.zendric.app.xpensely_server.services.UserService; + +@RestController +@RequestMapping("/api/users") +public class AppUserController { + + private UserService userService; + + @Autowired + public AppUserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + public AppUser getUser(@RequestParam Long id) { + return userService.getUser(id); + } + + @GetMapping("/byName") + public AppUser getUserByName(@RequestParam String username) { + return userService.getUserByName(username); + } + + @GetMapping("/byGoogleId") + public ResponseEntity getUserByGoogleId(@RequestParam String id) { + try { + AppUser userByGoogleId = userService.getUserByGoogleId(id); + return new ResponseEntity<>(userByGoogleId, HttpStatus.OK); + + } catch (IllegalArgumentException e) { + return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PostMapping("/createUser") + public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { + try { + AppUser convertedUser = userRequest.convertToAppUser(); + + AppUser nUser = userService.createUser(convertedUser); + return new ResponseEntity<>(nUser, HttpStatus.CREATED); + } catch (UsernameAlreadyExistsException e) { + return new ResponseEntity<>(null, HttpStatus.CONFLICT); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); + } + + } + + @DeleteMapping + public String deleteUser(@RequestParam Long id) { + AppUser user = userService.deleteUserById(id); + return "User deleted : " + user.getUsername(); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java new file mode 100644 index 0000000..3a407fb --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -0,0 +1,212 @@ +package de.zendric.app.xpensely_server.controller; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Expense; +import de.zendric.app.xpensely_server.model.ExpenseChangeRequest; +import de.zendric.app.xpensely_server.model.ExpenseInput; +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.model.InviteRequest; +import de.zendric.app.xpensely_server.services.ExpenseListService; +import de.zendric.app.xpensely_server.services.UserService; + +@RestController +@RequestMapping("/api/expenselist") +class ExpenseListController { + + private ExpenseListService expenseListService; + private UserService userService; + + @Autowired + public ExpenseListController(ExpenseListService expenseListService, UserService userService) { + this.expenseListService = expenseListService; + this.userService = userService; + } + + @GetMapping("/all") + public ResponseEntity> getAll() { + try { + List items = new ArrayList<>(); + + expenseListService.findAll().forEach(items::add); + + if (items.isEmpty()) + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + + return new ResponseEntity<>(items, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/byUser") + public ResponseEntity> getByUser(@RequestParam Long userId) { + try { + List items = expenseListService.findByUserId(userId); + + if (items.isEmpty()) + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + + return new ResponseEntity<>(items, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/byUsername") + public ResponseEntity> getByUser(@RequestParam String username) { + try { + List items = expenseListService.findByUsername(username); + + if (items.isEmpty()) + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + + return new ResponseEntity<>(items, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/byId") + public ResponseEntity getById(@RequestParam Long id) { + Optional existingItemOptional = expenseListService.findById(id); + + if (existingItemOptional.isPresent()) { + return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @PostMapping("/create") + public ResponseEntity create(@RequestBody ExpenseList expenseList) { + try { + if (expenseList.getOwner() != null) { + AppUser existingOwner = userService.getUser(expenseList.getOwner().getId()); + if (existingOwner == null) { + throw new IllegalArgumentException("Owner does not exist."); + } + expenseList.setOwner(existingOwner); + } else { + throw new IllegalArgumentException("Owner is required."); + } + + expenseList.setSharedWith(null); + + ExpenseList savedItem = expenseListService.createList(expenseList); + return new ResponseEntity<>(savedItem, HttpStatus.CREATED); + } catch (Exception e) { + e.printStackTrace(); + return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); + } + } + + @DeleteMapping("{id}") + public ResponseEntity delete(@PathVariable("id") Long id) { + try { + expenseListService.deleteById(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.EXPECTATION_FAILED); + } + } + + @PostMapping("/{id}/add") + public ResponseEntity addExpenseToList( + @PathVariable("id") Long expenseListId, + @RequestBody ExpenseInput expenseInput) { + try { + AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); + Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); + + Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); + return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PutMapping("/{id}/update") + public ResponseEntity updateExpenseInList( + @PathVariable("id") Long expenseListId, + @RequestBody ExpenseChangeRequest expenseChangeRequest) { + try { + AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); + Optional expenseList = expenseListService.findById(expenseListId); + if (expenseList.isPresent()) { + Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseList.get()); + + Expense addedExpense = expenseListService.updateExpense(expenseListId, expense); + return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); + } + return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); + + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); + } + } + + @DeleteMapping("/{id}/delete") + public ResponseEntity deleteExpenseFromList( + @PathVariable("id") Long expenseListId, + @RequestParam("expenseId") Long expenseId) { + try { + expenseListService.deleteExpenseFromList(expenseListId, expenseId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); + } + } + + @PostMapping("/{listId}/invite") + public ResponseEntity generateInvite(@PathVariable Long listId) { + String inviteCode = expenseListService.generateInviteCode(listId); + return ResponseEntity.ok(inviteCode); + } + + @PostMapping("/accept-invite") + public ResponseEntity acceptInvite(@RequestBody InviteRequest inviteRequest) { + ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); + + if (list == null || list.getInviteCodeExpiration() == null || + list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Invalid or expired invite code"); + } + if (list.getSharedWith() != null) { + return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); + } + if (list.getOwner().getId() == inviteRequest.getUserId()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cant join your own List"); + } + AppUser user = null; + try { + user = userService.getUser(inviteRequest.getUserId()); + } catch (Exception e) { + throw new RuntimeException("User not found"); + } + if (user != null) { + list.setSharedWith(user); + expenseListService.save(list); + } else { + throw new RuntimeException("User not found"); + } + return ResponseEntity.ok("User added to the list"); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/HomeController.java b/src/main/java/de/zendric/app/xpensely_server/controller/HomeController.java new file mode 100644 index 0000000..12a8948 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/controller/HomeController.java @@ -0,0 +1,13 @@ +package de.zendric.app.xpensely_server.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class HomeController { + + @GetMapping("/") + public String getAll() { + return "Welcome"; + } +} \ No newline at end of file diff --git a/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java b/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java new file mode 100644 index 0000000..90a0cd9 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java @@ -0,0 +1,40 @@ +package de.zendric.app.xpensely_server.model; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@EqualsAndHashCode +public class AppUser { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "username", nullable = false, unique = true) + private String username; + + @JsonIgnore + private String googleId; + + @Column(updatable = false) + @CreationTimestamp + @JsonIgnore + private LocalDateTime createdAt; + +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java new file mode 100644 index 0000000..3ae678d --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java @@ -0,0 +1,21 @@ +package de.zendric.app.xpensely_server.model; + +import jakarta.persistence.Column; +import lombok.Data; + +@Data +public class AppUserCreateRequest { + + @Column(name = "username", nullable = false, unique = true) + private String username; + + private String googleId; + + public AppUser convertToAppUser() { + AppUser appUser = new AppUser(); + appUser.setGoogleId(googleId); + appUser.setUsername(username); + + return appUser; + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Expense.java b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java new file mode 100644 index 0000000..ab7bab7 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java @@ -0,0 +1,44 @@ +package de.zendric.app.xpensely_server.model; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonBackReference; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Entity +@AllArgsConstructor +@NoArgsConstructor +public class Expense { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne + private AppUser owner; + + private Double amount; + private Double personalUseAmount; + private Double otherPersonAmount; + + private LocalDate date; + + @ManyToOne + @JoinColumn(name = "expense_list_id", nullable = false) + @JsonBackReference + private ExpenseList expenseList; +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java new file mode 100644 index 0000000..c028b00 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -0,0 +1,39 @@ +package de.zendric.app.xpensely_server.model; + +import java.time.LocalDate; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ExpenseChangeRequest { + + private Long id; + private String title; + private String ownerName; + private Double amount; + private Double personalUseAmount; + private Double otherPersonAmount; + private LocalDate date; + + public Expense convertToExpense(Long userId, ExpenseList expenseList) { + AppUser appUser = new AppUser(); + appUser.setId(userId); + appUser.setUsername(ownerName); + + Expense expense = new Expense(); + expense.setAmount(amount); + expense.setDate(date); + expense.setPersonalUseAmount(personalUseAmount); + expense.setOtherPersonAmount(otherPersonAmount); + expense.setExpenseList(expenseList); + expense.setId(id); + expense.setOwner(appUser); + expense.setTitle(title); + + return expense; + } +} \ No newline at end of file diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java new file mode 100644 index 0000000..ac2e7a6 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java @@ -0,0 +1,52 @@ +package de.zendric.app.xpensely_server.model; + +import java.time.LocalDate; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class ExpenseInput { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String owner; + + private Double amount; + private Double personalUseAmount; + private Double otherPersonAmount; + + private LocalDate date; + + private ExpenseList expenseList; + + public Expense convertToExpense(Long userId) { + AppUser appUser = new AppUser(); + appUser.setId(userId); + appUser.setUsername(owner); + + Expense expense = new Expense(); + expense.setAmount(amount); + expense.setDate(date); + expense.setPersonalUseAmount(personalUseAmount); + expense.setOtherPersonAmount(otherPersonAmount); + expense.setExpenseList(expenseList); + expense.setId(id); + expense.setOwner(appUser); + expense.setTitle(title); + + return expense; + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseList.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseList.java new file mode 100644 index 0000000..a31f9de --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseList.java @@ -0,0 +1,58 @@ +package de.zendric.app.xpensely_server.model; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonManagedReference; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class ExpenseList { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private String inviteCode; + @JsonIgnore + private LocalDateTime inviteCodeExpiration; + + @ManyToOne + private AppUser owner; + + @ManyToOne + private AppUser sharedWith; + + @OneToMany(mappedBy = "expenseList", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference + @jakarta.persistence.OrderBy("date ASC, id ASC") + private List expenses; + + public void addExpense(Expense expense) { + expense.setExpenseList(this); + expenses.add(expense); + + } + + public void removeExpense(Expense expense) { + expenses.remove(expense); + expense.setExpenseList(null); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java new file mode 100644 index 0000000..81ac35a --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java @@ -0,0 +1,13 @@ +package de.zendric.app.xpensely_server.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class InviteRequest { + private String inviteCode; + private Long userId; +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/UsernameAlreadyExistsException.java b/src/main/java/de/zendric/app/xpensely_server/model/UsernameAlreadyExistsException.java new file mode 100644 index 0000000..b0645d8 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/UsernameAlreadyExistsException.java @@ -0,0 +1,11 @@ +package de.zendric.app.xpensely_server.model; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.CONFLICT) +public class UsernameAlreadyExistsException extends RuntimeException { + public UsernameAlreadyExistsException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java new file mode 100644 index 0000000..c42b820 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java @@ -0,0 +1,15 @@ +package de.zendric.app.xpensely_server.repo; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import de.zendric.app.xpensely_server.model.ExpenseList; + +@Repository +public interface ExpenseListRepository extends JpaRepository { + List findByOwnerId(Long ownerId); + + ExpenseList findByInviteCode(String inviteCode); +} \ No newline at end of file diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java new file mode 100644 index 0000000..fe7a610 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java @@ -0,0 +1,13 @@ +package de.zendric.app.xpensely_server.repo; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import de.zendric.app.xpensely_server.model.Expense; + +@Repository +public interface ExpenseRepository extends JpaRepository { + List findAllByOrderByDateAsc(); +} diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java new file mode 100644 index 0000000..a35ba4f --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java @@ -0,0 +1,17 @@ +package de.zendric.app.xpensely_server.repo; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import de.zendric.app.xpensely_server.model.AppUser; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + + Optional findByGoogleId(String id); + + Boolean existsByUsername(String username); +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java new file mode 100644 index 0000000..b4883a3 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java @@ -0,0 +1,35 @@ +package de.zendric.app.xpensely_server.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + // @Bean + // public SecurityFilterChain securityFilterChain(HttpSecurity http) throws + // Exception { + // http.authorizeHttpRequests(auth -> auth + // .anyRequest().permitAll()).csrf().disable(); + // ; + + // return http.build(); + // } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(Customizer.withDefaults())) + .oauth2Login(Customizer.withDefaults()) + .csrf().disable(); + + return http.build(); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/services/CleanupService.java b/src/main/java/de/zendric/app/xpensely_server/services/CleanupService.java new file mode 100644 index 0000000..cbfd06c --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/services/CleanupService.java @@ -0,0 +1,34 @@ +package de.zendric.app.xpensely_server.services; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; + +@Service + +public class CleanupService { + + @Autowired + private ExpenseListRepository expenseListRepository; + + @Scheduled(cron = "0 0 0 * * ?") // Runs daily at midnight + public void cleanupExpiredInvites() { + List expiredLists = expenseListRepository.findAll().stream() + .filter(list -> list.getInviteCodeExpiration() != null && + list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) + .collect(Collectors.toList()); + + for (ExpenseList list : expiredLists) { + list.setInviteCode(null); + list.setInviteCodeExpiration(null); + expenseListRepository.save(list); + } + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java new file mode 100644 index 0000000..445ab1f --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -0,0 +1,184 @@ +package de.zendric.app.xpensely_server.services; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Expense; +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import de.zendric.app.xpensely_server.repo.ExpenseRepository; +import jakarta.persistence.EntityManager; + +@Service +@Transactional +public class ExpenseListService { + + private ExpenseListRepository repository; + private final ExpenseRepository expenseRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository) { + this.repository = repository; + this.expenseRepository = expenseRepository; + } + + public List getAllLists() { + return repository.findAll(); + } + + public ExpenseList createList(ExpenseList list) { + return repository.save(list); + } + + public void deleteList(Long id) { + repository.deleteById(id); + } + + public void deleteById(Long id) { + repository.deleteById(id); + } + + public Optional findById(Long id) { + return repository.findById(id); + } + + public Iterable findAll() { + return repository.findAll(); + } + + public ExpenseList save(ExpenseList expenseList) { + return repository.save(expenseList); + } + + public List findByUserId(Long id) { + List allLists = repository.findAll(); + List userSpecificList = new ArrayList<>(); + for (ExpenseList expenseList : allLists) { + AppUser sharedWith = expenseList.getSharedWith(); + + if (expenseList.getOwner().getId().equals(id)) { + userSpecificList.add(expenseList); + } else { + if (sharedWith != null && sharedWith.getId().equals(id)) { + userSpecificList.add(expenseList); + } + } + } + return userSpecificList; + } + + public List findByUsername(String username) { + List allLists = repository.findAll(); + List userSpecificList = new ArrayList<>(); + for (ExpenseList expenseList : allLists) { + AppUser sharedWith = expenseList.getSharedWith(); + + if (expenseList.getOwner().getUsername().equals(username)) { + userSpecificList.add(expenseList); + } else { + if (sharedWith != null && sharedWith.getUsername().equals(username)) { + userSpecificList.add(expenseList); + } + } + } + return userSpecificList; + + } + + public Expense addExpenseToList(Long expenseListId, Expense expense) { + // find expenseList + ExpenseList expenseList = repository.findById(expenseListId) + .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); + // get all added expenses + HashSet existingId = new HashSet<>(); + for (Expense e : expenseList.getExpenses()) { + existingId.add(e.getId()); + } + // add the new expense + expenseList.addExpense(expense); + // save + repository.save(expenseList); + + Expense newExpense = new Expense(); + for (Expense e : expenseList.getExpenses()) { + if (!existingId.contains(e.getId())) { + newExpense = e; + break; + } + } + return newExpense; + } + + public void deleteExpenseFromList(Long expenseListId, Long expenseId) { + ExpenseList expenseList = repository.findById(expenseListId) + .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); + Expense expenseToRemove = null; + for (Expense expense : expenseList.getExpenses()) { + if (expense.getId().equals(expenseId)) { + expenseToRemove = expense; + break; + } + } + if (expenseToRemove != null) { + expenseList.removeExpense(expenseToRemove); + } else { + throw new RuntimeException("Expense not found with id: " + expenseId); + } + repository.save(expenseList); + } + + public String generateInviteCode(Long listId) { + ExpenseList list = repository.findById(listId) + .orElseThrow(() -> new RuntimeException("List not found")); + String inviteCode; + if (list.getInviteCode() == null || list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { + + inviteCode = UUID.randomUUID().toString().substring(0, 6).toUpperCase(); + LocalDateTime expirationTime = LocalDateTime.now().plusWeeks(1); + + list.setInviteCode(inviteCode); + list.setInviteCodeExpiration(expirationTime); + repository.save(list); + } else { + inviteCode = list.getInviteCode(); + } + return inviteCode; + } + + public ExpenseList findByInviteCode(String inviteCode) { + return repository.findByInviteCode(inviteCode); + } + + public Expense updateExpense(Long expenseListId, Expense updatedExpense) { + ExpenseList expenseList = repository.findById(expenseListId) + .orElseThrow(() -> new IllegalArgumentException("ExpenseList not found")); + + if (!expenseList.getExpenses().stream() + .anyMatch(expense -> expense.getId().equals(updatedExpense.getId()))) { + throw new IllegalArgumentException("Expense does not belong to the specified ExpenseList"); + } + + Expense existingExpense = expenseRepository.findById(updatedExpense.getId()) + .orElseThrow(() -> new IllegalArgumentException("Expense not found")); + existingExpense.setTitle(updatedExpense.getTitle()); + existingExpense.setAmount(updatedExpense.getAmount()); + existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount()); + existingExpense.setOtherPersonAmount(updatedExpense.getOtherPersonAmount()); + existingExpense.setDate(updatedExpense.getDate()); + existingExpense.setOwner(updatedExpense.getOwner()); + + return expenseRepository.save(existingExpense); + } +} \ No newline at end of file diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseService.java new file mode 100644 index 0000000..3d45353 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseService.java @@ -0,0 +1,19 @@ +package de.zendric.app.xpensely_server.services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import de.zendric.app.xpensely_server.repo.ExpenseRepository; + +@Service +@Transactional +public class ExpenseService { + + @Autowired + private ExpenseRepository repository; + + public ExpenseService(ExpenseRepository repository) { + this.repository = repository; + } +} \ No newline at end of file diff --git a/src/main/java/de/zendric/app/xpensely_server/services/UserService.java b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java new file mode 100644 index 0000000..58298dc --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java @@ -0,0 +1,64 @@ +package de.zendric.app.xpensely_server.services; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.UsernameAlreadyExistsException; +import de.zendric.app.xpensely_server.repo.UserRepository; + +@Service +public class UserService { + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public List getAllUsers() { + return userRepository.findAll(); + } + + public AppUser createUser(AppUser user) { + if (Boolean.TRUE.equals(userRepository.existsByUsername(user.getUsername()))) { + throw new UsernameAlreadyExistsException("Username already exists"); + } + return userRepository.save(user); + } + + public AppUser getUser(Long id) { + Optional user = userRepository.findById(id); + if (user.isPresent()) { + return user.get(); + } else + return null; + } + + public AppUser deleteUserById(Long id) { + Optional user = userRepository.findById(id); + if (user.isPresent()) { + userRepository.deleteById(id); + return user.get(); + } else + return null; + } + + public AppUser getUserByName(String username) { + Optional optUser = userRepository.findByUsername(username); + if (optUser.isPresent()) { + return optUser.get(); + } else + return null; + } + + public AppUser getUserByGoogleId(String id) { + Optional optUser = userRepository.findByGoogleId(id); + if (optUser.isPresent()) { + return optUser.get(); + } else + return null; + } + +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bc19518..61116e5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,18 @@ +#Server spring.application.name=XpenselyServer + +#Security +spring.security.enabled=false +spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://accounts.google.com + +# PostgreSQL Configuration +spring.datasource.url=jdbc:postgresql://postgresdb:${DB_PORT}/${DB_P_NAME} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver + +# Hibernate configuration +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect diff --git a/src/main/resources/static/xpensely_icon.png b/src/main/resources/static/xpensely_icon.png new file mode 100644 index 0000000..b90a600 Binary files /dev/null and b/src/main/resources/static/xpensely_icon.png differ diff --git a/src/main/resources/static/xpensely_logo.png b/src/main/resources/static/xpensely_logo.png new file mode 100644 index 0000000..a77322b Binary files /dev/null and b/src/main/resources/static/xpensely_logo.png differ diff --git a/src/test/java/de/zendric/app/XpenselyServer/XpenselyServerApplicationTests.java b/src/test/java/de/zendric/app/xpensely_Server/XpenselyServerApplicationTests.java similarity index 82% rename from src/test/java/de/zendric/app/XpenselyServer/XpenselyServerApplicationTests.java rename to src/test/java/de/zendric/app/xpensely_Server/XpenselyServerApplicationTests.java index 5f228b2..3c78751 100644 --- a/src/test/java/de/zendric/app/XpenselyServer/XpenselyServerApplicationTests.java +++ b/src/test/java/de/zendric/app/xpensely_Server/XpenselyServerApplicationTests.java @@ -1,4 +1,4 @@ -package de.zendric.app.XpenselyServer; +package de.zendric.app.xpensely_Server; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest;