233 lines
7.2 KiB
Go
233 lines
7.2 KiB
Go
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" && originalAction != "create_connection" && !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)
|
|
}
|