aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_rooms_bulk.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 19:55:21 -0400
committerhistoria <[not public]>2026-09-15 19:55:21 -0400
commit6f3c43aca62a7d33a19f14f6ad7c16891952673a (patch)
tree0b0665ef624ef442df87ec7131129aaecc3420bd /internal/admin/api_rooms_bulk.go
parentcd95de60364aba8f5bdf8ec86f7234a9903eacc8 (diff)
downloadthehouseoficarus-6f3c43aca62a7d33a19f14f6ad7c16891952673a.tar.gz
fix: outstanding-stake ledger, owner backpressure, editor state preservation
Diffstat (limited to 'internal/admin/api_rooms_bulk.go')
-rw-r--r--internal/admin/api_rooms_bulk.go146
1 files changed, 65 insertions, 81 deletions
diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go
index 6283067..a7fcee7 100644
--- a/internal/admin/api_rooms_bulk.go
+++ b/internal/admin/api_rooms_bulk.go
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strconv"
+
+ "thehouseoficarus/internal/persistence"
)
type bulkEditPatch struct {
@@ -22,6 +24,17 @@ type bulkEditRequest struct {
IDs []int `json:"ids"`
}
+// handleBulkEdit applies one patch to many rooms as a single mutation
+// plan:
+//
+// 1. Preflight reads, parses, patches, and marshals every selected room in
+// memory — including destination-directory and conflict checks for
+// moves. Any failure aborts BEFORE any file is touched: a failed bulk
+// edit never leaves partial changes behind.
+// 2. The writes apply as one journal transaction (in-place replaces, and
+// move+replace for real directory moves) that rolls back on failure.
+// 3. One undo record captures every before/after image, so undo restores
+// the whole plan.
func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -53,43 +66,27 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) {
patch := req.Patch
- roomPaths := make([]string, 0, len(req.IDs))
- for _, id := range req.IDs {
- path, ok := s.world.GetRoomPath(id)
- if !ok {
- writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)})
- return
- }
- roomPaths = append(roomPaths, path)
- }
-
type roomWrite struct {
- oldContent []byte
- newContent []byte
- oldPath string
- newPath string
- wasMoved bool
+ id int
+ oldPath, newPath string
+ oldContent, newContent []byte
}
+ // ---- Preflight: everything that can fail happens here, before any
+ // file is modified. ----
writes := make([]roomWrite, 0, len(req.IDs))
- dirMovePaths := make(map[string]struct{})
-
- for i, id := range req.IDs {
- path := roomPaths[i]
-
- oldContent, snapErr := snapshotFile(path)
- if snapErr != nil {
- writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room %d: %v", id, snapErr)})
+ for _, id := range req.IDs {
+ path, ok := s.world.GetRoomPath(id)
+ if !ok {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)})
return
}
-
- data, readErr := os.ReadFile(path)
- if readErr != nil {
- writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, readErr)})
+ oldContent, err := os.ReadFile(path)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, err)})
return
}
-
- m, yamlErr := yamlToMap(data)
+ m, yamlErr := yamlToMap(oldContent)
if yamlErr != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("parse room %d: %v", id, yamlErr)})
return
@@ -109,63 +106,50 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) {
}
}
+ newPath := path
if patch.Dir != nil && *patch.Dir != "" {
destDir := filepath.Join(s.dataDir, "rooms", *patch.Dir)
- if _, err := os.Stat(destDir); os.IsNotExist(err) {
+ if _, statErr := os.Stat(destDir); os.IsNotExist(statErr) {
writeJSON(w, map[string]any{"error": fmt.Sprintf("directory does not exist: %s", *patch.Dir)})
return
}
-
- fileName := strconv.Itoa(id) + ".yaml"
- newPath := filepath.Join(destDir, fileName)
+ newPath = filepath.Join(destDir, strconv.Itoa(id)+".yaml")
if newPath != path {
- if _, err := os.Stat(newPath); err == nil {
+ if _, statErr := os.Stat(newPath); statErr == nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists in target directory", id)})
return
}
}
+ // newPath == path is an in-place edit: the room already lives
+ // in the target directory and must NOT be removed after its
+ // write (that deleted existing rooms).
+ }
- newContent, writeErr := writeMapAsYAML(newPath, m)
- if writeErr != nil {
- writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)})
- return
- }
-
- writes = append(writes, roomWrite{
- oldContent: oldContent,
- newContent: newContent,
- oldPath: path,
- newPath: newPath,
- wasMoved: true,
- })
- dirMovePaths[path] = struct{}{}
- } else {
- newContent, writeErr := writeMapAsYAML(path, m)
- if writeErr != nil {
- writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)})
- return
- }
-
- writes = append(writes, roomWrite{
- oldContent: oldContent,
- newContent: newContent,
- oldPath: path,
- newPath: path,
- wasMoved: false,
- })
+ newContent, marshalErr := marshalMapYAML(m)
+ if marshalErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("marshal room %d: %v", id, marshalErr)})
+ return
}
+ writes = append(writes, roomWrite{id: id, oldPath: path, newPath: newPath, oldContent: oldContent, newContent: newContent})
}
- for _, rw := range writes {
- if rw.wasMoved {
- if _, alreadyRemoved := dirMovePaths[rw.oldPath]; alreadyRemoved {
- delete(dirMovePaths, rw.oldPath)
- if err := os.Remove(rw.oldPath); err != nil {
- writeJSON(w, map[string]any{"error": fmt.Sprintf("remove old room: %v", err)})
- return
- }
+ // ---- Apply as one journaled transaction. ----
+ txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal"))
+ for _, wr := range writes {
+ if wr.newPath != wr.oldPath {
+ if err := txn.Move(wr.oldPath, wr.newPath); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("move room %d: %v", wr.id, err)})
+ return
}
}
+ if err := txn.Replace(wr.newPath, wr.newContent, 0644); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", wr.id, err)})
+ return
+ }
+ }
+ if err := txn.Commit(); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("commit bulk edit: %v", err)})
+ return
}
s.world.RebuildRoomIndex(s.dataDir)
@@ -179,24 +163,24 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) {
var extraFiles []ExtraFile
var firstDesc ChangeDesc
- for i, rw := range writes {
+ for i, wr := range writes {
ef := ExtraFile{
- FilePath: rw.oldPath,
- OldContent: rw.oldContent,
- NewContent: rw.newContent,
+ FilePath: wr.oldPath,
+ OldContent: wr.oldContent,
+ NewContent: wr.newContent,
}
- if rw.wasMoved {
- ef.NewFilePath = rw.newPath
+ if wr.newPath != wr.oldPath {
+ ef.NewFilePath = wr.newPath
}
if i == 0 {
firstDesc = ChangeDesc{
Description: desc,
- FilePath: rw.oldPath,
- OldContent: rw.oldContent,
- NewContent: rw.newContent,
+ FilePath: wr.oldPath,
+ OldContent: wr.oldContent,
+ NewContent: wr.newContent,
}
- if rw.wasMoved {
- firstDesc.NewFilePath = rw.newPath
+ if wr.newPath != wr.oldPath {
+ firstDesc.NewFilePath = wr.newPath
}
} else {
extraFiles = append(extraFiles, ef)