diff --git a/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/controller/ApiController.java b/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/controller/ApiController.java index 72dcb63..6f8e126 100644 --- a/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/controller/ApiController.java +++ b/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/controller/ApiController.java @@ -2,6 +2,7 @@ package be.seeseepuff.allowanceplanner.controller; import be.seeseepuff.allowanceplanner.dto.*; import be.seeseepuff.allowanceplanner.service.AllowanceService; +import be.seeseepuff.allowanceplanner.service.MigrationService; import be.seeseepuff.allowanceplanner.service.TaskService; import be.seeseepuff.allowanceplanner.service.TransferService; import be.seeseepuff.allowanceplanner.service.UserService; @@ -21,16 +22,19 @@ public class ApiController private final AllowanceService allowanceService; private final TaskService taskService; private final TransferService transferService; + private final MigrationService migrationService; public ApiController(UserService userService, AllowanceService allowanceService, TaskService taskService, - TransferService transferService) + TransferService transferService, + MigrationService migrationService) { this.userService = userService; this.allowanceService = allowanceService; this.taskService = taskService; this.transferService = transferService; + this.migrationService = migrationService; } // ---- Users ---- @@ -503,4 +507,13 @@ public class ApiController ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(result.message())); }; } + + // ---- Migration ---- + + @PostMapping("/import") + public ResponseEntity importData(@RequestBody MigrationDto data) + { + migrationService.importData(data); + return ResponseEntity.ok(new MessageResponse("Import successful")); + } } diff --git a/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/dto/MigrationDto.java b/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/dto/MigrationDto.java new file mode 100644 index 0000000..cda8f22 --- /dev/null +++ b/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/dto/MigrationDto.java @@ -0,0 +1,21 @@ +package be.seeseepuff.allowanceplanner.dto; + +import java.util.List; + +public record MigrationDto( + List users, + List allowances, + List history, + List tasks +) +{ + public record MigrationUserDto(int id, String name, long balance, double weight) {} + + public record MigrationAllowanceDto(int id, int userId, String name, long target, long balance, double weight, + Integer colour) {} + + public record MigrationHistoryDto(int id, int userId, long timestamp, long amount, String description) {} + + public record MigrationTaskDto(int id, String name, long reward, Integer assigned, String schedule, + Long completed, Long nextRun) {} +} diff --git a/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/service/MigrationService.java b/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/service/MigrationService.java new file mode 100644 index 0000000..e717ffb --- /dev/null +++ b/backend-spring/src/main/java/be/seeseepuff/allowanceplanner/service/MigrationService.java @@ -0,0 +1,104 @@ +package be.seeseepuff.allowanceplanner.service; + +import be.seeseepuff.allowanceplanner.dto.MigrationDto; +import be.seeseepuff.allowanceplanner.repository.AllowanceRepository; +import be.seeseepuff.allowanceplanner.repository.HistoryRepository; +import be.seeseepuff.allowanceplanner.repository.TaskRepository; +import be.seeseepuff.allowanceplanner.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MigrationService +{ + private final UserRepository userRepository; + private final AllowanceRepository allowanceRepository; + private final HistoryRepository historyRepository; + private final TaskRepository taskRepository; + private final EntityManager entityManager; + + public MigrationService(UserRepository userRepository, + AllowanceRepository allowanceRepository, + HistoryRepository historyRepository, + TaskRepository taskRepository, + EntityManager entityManager) + { + this.userRepository = userRepository; + this.allowanceRepository = allowanceRepository; + this.historyRepository = historyRepository; + this.taskRepository = taskRepository; + this.entityManager = entityManager; + } + + @Transactional + public void importData(MigrationDto data) + { + // Delete in dependency order + taskRepository.deleteAll(); + historyRepository.deleteAll(); + allowanceRepository.deleteAll(); + userRepository.deleteAll(); + + // Insert users with original IDs using native SQL to bypass auto-increment + for (MigrationDto.MigrationUserDto u : data.users()) + { + entityManager.createNativeQuery( + "INSERT INTO users (id, name, balance, weight) VALUES (:id, :name, :balance, :weight)") + .setParameter("id", u.id()) + .setParameter("name", u.name()) + .setParameter("balance", u.balance()) + .setParameter("weight", u.weight()) + .executeUpdate(); + } + + // Insert allowances with original IDs + for (MigrationDto.MigrationAllowanceDto a : data.allowances()) + { + entityManager.createNativeQuery( + "INSERT INTO allowances (id, user_id, name, target, balance, weight, colour) VALUES (:id, :userId, :name, :target, :balance, :weight, :colour)") + .setParameter("id", a.id()) + .setParameter("userId", a.userId()) + .setParameter("name", a.name()) + .setParameter("target", a.target()) + .setParameter("balance", a.balance()) + .setParameter("weight", a.weight()) + .setParameter("colour", a.colour()) + .executeUpdate(); + } + + // Insert history with original IDs + for (MigrationDto.MigrationHistoryDto h : data.history()) + { + entityManager.createNativeQuery( + "INSERT INTO history (id, user_id, timestamp, amount, description) VALUES (:id, :userId, :timestamp, :amount, :description)") + .setParameter("id", h.id()) + .setParameter("userId", h.userId()) + .setParameter("timestamp", h.timestamp()) + .setParameter("amount", h.amount()) + .setParameter("description", h.description()) + .executeUpdate(); + } + + // Insert tasks with original IDs + for (MigrationDto.MigrationTaskDto t : data.tasks()) + { + entityManager.createNativeQuery( + "INSERT INTO tasks (id, name, reward, assigned, schedule, completed, next_run) VALUES (:id, :name, :reward, :assigned, :schedule, :completed, :nextRun)") + .setParameter("id", t.id()) + .setParameter("name", t.name()) + .setParameter("reward", t.reward()) + .setParameter("assigned", t.assigned()) + .setParameter("schedule", t.schedule()) + .setParameter("completed", t.completed()) + .setParameter("nextRun", t.nextRun()) + .executeUpdate(); + } + + // Reset sequences so new inserts don't collide with migrated IDs + entityManager.createNativeQuery("SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 0))").getSingleResult(); + entityManager.createNativeQuery("SELECT setval('allowances_id_seq', COALESCE((SELECT MAX(id) FROM allowances), 0))").getSingleResult(); + entityManager.createNativeQuery("SELECT setval('history_id_seq', COALESCE((SELECT MAX(id) FROM history), 0))").getSingleResult(); + entityManager.createNativeQuery("SELECT setval('tasks_id_seq', COALESCE((SELECT MAX(id) FROM tasks), 0))").getSingleResult(); + } +} diff --git a/backend/db.go b/backend/db.go index 46a0442..e6ac2a1 100644 --- a/backend/db.go +++ b/backend/db.go @@ -779,3 +779,59 @@ func (db *Db) TransferAllowance(fromId int, toId int, amount float64) error { return tx.Commit() } + +func (db *Db) ExportAllData() (*ExportData, error) { + var err error + data := &ExportData{ + Users: make([]ExportUser, 0), + Allowances: make([]ExportAllowance, 0), + History: make([]ExportHistory, 0), + Tasks: make([]ExportTask, 0), + } + + for row := range db.db.Query("select id, name, balance, weight from users").Range(&err) { + u := ExportUser{} + if err = row.Scan(&u.ID, &u.Name, &u.Balance, &u.Weight); err != nil { + return nil, err + } + data.Users = append(data.Users, u) + } + if err != nil { + return nil, err + } + + for row := range db.db.Query("select id, user_id, name, target, balance, weight, colour from allowances").Range(&err) { + a := ExportAllowance{} + if err = row.Scan(&a.ID, &a.UserID, &a.Name, &a.Target, &a.Balance, &a.Weight, &a.Colour); err != nil { + return nil, err + } + data.Allowances = append(data.Allowances, a) + } + if err != nil { + return nil, err + } + + for row := range db.db.Query("select id, user_id, timestamp, amount, description from history").Range(&err) { + h := ExportHistory{} + if err = row.Scan(&h.ID, &h.UserID, &h.Timestamp, &h.Amount, &h.Description); err != nil { + return nil, err + } + data.History = append(data.History, h) + } + if err != nil { + return nil, err + } + + for row := range db.db.Query("select id, name, reward, assigned, schedule, completed, next_run from tasks").Range(&err) { + t := ExportTask{} + if err = row.Scan(&t.ID, &t.Name, &t.Reward, &t.Assigned, &t.Schedule, &t.Completed, &t.NextRun); err != nil { + return nil, err + } + data.Tasks = append(data.Tasks, t) + } + if err != nil { + return nil, err + } + + return data, nil +} diff --git a/backend/dto.go b/backend/dto.go index bea039d..05d2014 100644 --- a/backend/dto.go +++ b/backend/dto.go @@ -86,3 +86,45 @@ type TransferRequest struct { To int `json:"to"` Amount float64 `json:"amount"` } + +type ExportUser struct { + ID int `json:"id"` + Name string `json:"name"` + Balance int64 `json:"balance"` + Weight float64 `json:"weight"` +} + +type ExportAllowance struct { + ID int `json:"id"` + UserID int `json:"userId"` + Name string `json:"name"` + Target int64 `json:"target"` + Balance int64 `json:"balance"` + Weight float64 `json:"weight"` + Colour *int `json:"colour"` +} + +type ExportHistory struct { + ID int `json:"id"` + UserID int `json:"userId"` + Timestamp int64 `json:"timestamp"` + Amount int64 `json:"amount"` + Description string `json:"description"` +} + +type ExportTask struct { + ID int `json:"id"` + Name string `json:"name"` + Reward int64 `json:"reward"` + Assigned *int `json:"assigned"` + Schedule *string `json:"schedule"` + Completed *int64 `json:"completed"` + NextRun *int64 `json:"nextRun"` +} + +type ExportData struct { + Users []ExportUser `json:"users"` + Allowances []ExportAllowance `json:"allowances"` + History []ExportHistory `json:"history"` + Tasks []ExportTask `json:"tasks"` +} diff --git a/backend/main.go b/backend/main.go index 132a4db..474aad6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -51,6 +51,16 @@ const DefaultDomain = "localhost:8080" // The domain that the server is reachable at. var domain = DefaultDomain +func exportData(c *gin.Context) { + data, err := db.ExportAllData() + if err != nil { + log.Printf("Error exporting data: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + c.IndentedJSON(http.StatusOK, data) +} + func getUsers(c *gin.Context) { users, err := db.GetUsers() if err != nil { @@ -713,6 +723,7 @@ func start(ctx context.Context, config *ServerConfig) { router.DELETE("/api/task/:taskId", deleteTask) router.POST("/api/task/:taskId/complete", completeTask) router.POST("/api/transfer", transfer) + router.GET("/api/export", exportData) srv := &http.Server{ Addr: config.Addr,