package platform import ( "database/sql" "encoding/json" "net/http" "strings" ) func (s *service) undoCanvasTransaction(w http.ResponseWriter, r *http.Request, project, account string) error { if r.Method != http.MethodPost { reject(w, 405, "method_not_allowed") return nil } if !acceptsCanvasV2(r) { reject(w, 409, "canvas_schema_upgrade_required") return nil } key := canvasIdempotencyKey(r) id := r.PathValue("transaction") if !uuidSlugPattern.MatchString(key) || !uuidSlugPattern.MatchString(id) { reject(w, 400, "invalid_transaction_request") return nil } var input struct { ExpectedVersions map[string]int64 `json:"expected_versions"` } if !decode(w, r, &input) { return nil } if len(input.ExpectedVersions) == 0 { reject(w, 428, "version_required") return nil } workspace, board := r.PathValue("workspace"), r.PathValue("material") tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() _, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) if err != nil { return err } if role == "viewer" { reject(w, 403, "forbidden") return nil } action := "undo/" + id hash := payloadDigest(input) done, err := transactionRetry(w, r, tx, project, account, board, action, key, hash) if err != nil { return err } if done { return nil } var beforeRaw, versionsRaw, relationsRaw, afterResponse json.RawMessage var originalAction string var expired bool var undone sql.NullTime err = tx.QueryRowContext(r.Context(), `SELECT action,before_state,after_versions,relation_changes,response_body,expires_at<=now(),undone_at FROM canvas_transactions WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 FOR UPDATE`, project, workspace, board, id).Scan(&originalAction, &beforeRaw, &versionsRaw, &relationsRaw, &afterResponse, &expired, &undone) if err != nil { return err } if expired { reject(w, 409, "undo_receipt_expired") return nil } if undone.Valid { reject(w, 409, "transaction_already_undone") return nil } if originalAction != "create_subgraph" && originalAction != "migrate_subgraphs" && !strings.HasPrefix(originalAction, "dissolve_subgraph/") && !strings.HasPrefix(originalAction, "move_members/") { reject(w, 409, "undo_not_supported") return nil } var before []canvasSnapshot var versions map[string]int64 if err = json.Unmarshal(beforeRaw, &before); err != nil { return err } if err = json.Unmarshal(versionsRaw, &versions); err != nil { return err } if len(versions) != len(input.ExpectedVersions) { reject(w, 428, "version_required") return nil } var changes []subgraphRelationChange if err = json.Unmarshal(relationsRaw, &changes); err != nil { return err } archivedParticipants := map[string]bool{} for _, change := range changes { actual, err := readSubgraphRelation(r, tx, project, workspace, change.After.Child) if err != nil { return err } if actual != change.After { reject(w, 412, "version_conflict") return nil } if actual.Archived { _, role, err := lockMaterial(r.Context(), tx, project, workspace, actual.Parent, account) if err != nil { return err } if role == "viewer" { reject(w, 403, "forbidden") return nil } archivedParticipants[actual.Child] = true } } var recorded struct { Parent canvasSnapshot `json:"parent"` Child *canvasSnapshot `json:"child"` Children []canvasSnapshot `json:"children"` Snapshots []canvasSnapshot `json:"snapshots"` } if err = json.Unmarshal(afterResponse, &recorded); err != nil { return err } afterStates := map[string]json.RawMessage{} if recorded.Parent.BoardID != "" { afterStates[recorded.Parent.BoardID] = recorded.Parent.Document } if recorded.Child != nil { afterStates[recorded.Child.BoardID] = recorded.Child.Document } for _, snapshot := range append(recorded.Children, recorded.Snapshots...) { afterStates[snapshot.BoardID] = snapshot.Document } current := map[string]canvasSnapshot{} for id := range versions { if !archivedParticipants[id] { _, role, err := lockMaterial(r.Context(), tx, project, workspace, id, account) if err != nil { return err } if role == "viewer" { reject(w, 403, "forbidden") return nil } } snapshot, err := loadCanvas(r, tx, project, workspace, id) if err != nil { return err } expected, exists := input.ExpectedVersions[id] if !exists { reject(w, 428, "version_required") return nil } if expected != snapshot.Version || !sameCanvasDocument(snapshot.Document, afterStates[id]) { reject(w, 412, "version_conflict") return nil } current[id] = snapshot } for _, change := range changes { if err = writeSubgraphRelation(r, tx, project, workspace, change.Before); err != nil { return err } } beforeIDs := map[string]bool{} updated := []canvasSnapshot{} appliedVersions := map[string]int64{} for _, snapshot := range before { beforeIDs[snapshot.BoardID] = true snapshot.Version = current[snapshot.BoardID].Version + 1 if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil { return err } updated = append(updated, snapshot) appliedVersions[snapshot.BoardID] = snapshot.Version } archived := []string{} for id, snapshot := range current { if beforeIDs[id] { continue } _, err = tx.ExecContext(r.Context(), `UPDATE canvas_subgraphs SET archived_at=now() WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND child_board_id=$4 AND archived_at IS NULL`, project, workspace, board, id) if err != nil { return err } // Keep the complete child for recovery, but invalidate its old version. snapshot.Version++ if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil { return err } archived = append(archived, id) appliedVersions[id] = snapshot.Version } _, err = tx.ExecContext(r.Context(), `UPDATE canvas_transactions SET undone_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id) if err != nil { return err } var newID string err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&newID) if err != nil { return err } result := response{"transaction_id": newID, "undone_transaction_id": id, "snapshots": updated, "archived_board_ids": archived, "applied_versions": appliedVersions} for _, snapshot := range updated { if snapshot.BoardID == board { result["parent"] = snapshot } } encoded, _ := json.Marshal(result) prior, _ := json.Marshal(current) after, _ := json.Marshal(appliedVersions) _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions) VALUES($1,$2,$3,$4,$5,$6,$7,$8,200,$9,$10,$11)`, project, workspace, board, newID, account, action, key, hash, encoded, prior, after) if err != nil { return err } if err = tx.Commit(); err != nil { return err } setVersion(w, appliedVersions[board]) reply(w, 200, result) return nil } func sameCanvasDocument(a, b json.RawMessage) bool { var first, second any if json.Unmarshal(a, &first) != nil || json.Unmarshal(b, &second) != nil { return false } return payloadDigest(first) == payloadDigest(second) }