From ad2e157c89e5acd2ac1bc0502c4f39b0bb3b3b42 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sun, 1 Mar 2026 16:17:37 +0100 Subject: [PATCH] Add data migration export/import endpoints Add GET /api/export to the Go backend that dumps all users, allowances, history, and tasks (including completed) as a single JSON snapshot. Add POST /api/import to the Spring backend that accepts the same JSON, wipes existing data, inserts all records with original IDs preserved via native SQL, and resets PostgreSQL sequences to avoid future collisions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controller/ApiController.java | 15 ++- .../allowanceplanner/dto/MigrationDto.java | 21 ++++ .../service/MigrationService.java | 104 ++++++++++++++++++ backend/db.go | 56 ++++++++++ backend/dto.go | 42 +++++++ backend/main.go | 11 ++ 6 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 backend-spring/src/main/java/be/seeseepuff/allowanceplanner/dto/MigrationDto.java create mode 100644 backend-spring/src/main/java/be/seeseepuff/allowanceplanner/service/MigrationService.java 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,