package platform import ( "encoding/json" "net/http" "sort" "time" ) func (s *service) moveSubgraphMembers(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 } frame, key := r.PathValue("frame"), canvasIdempotencyKey(r) if !canvasID.MatchString(frame) || !uuidSlugPattern.MatchString(key) { reject(w, 400, "invalid_subgraph_request") return nil } var input struct { Source string `json:"source_board_id"` Target string `json:"target_board_id"` Nodes []string `json:"node_ids"` Expected map[string]int64 `json:"expected_versions"` Positions map[string]memberDropPosition `json:"positions,omitempty"` } if !decode(w, r, &input) { return nil } if !uuidSlugPattern.MatchString(input.Source) || !uuidSlugPattern.MatchString(input.Target) || input.Source == input.Target || len(input.Nodes) == 0 || len(input.Nodes) > 500 { reject(w, 400, "invalid_subgraph_request") return nil } for _, node := range input.Nodes { if !canvasID.MatchString(node) { reject(w, 400, "invalid_subgraph_request") return nil } } if !validMemberDropPositions(input.Nodes, input.Positions) { reject(w, 400, "invalid_subgraph_request") return nil } if len(input.Expected) == 0 { reject(w, 428, "version_required") return nil } workspace, parent := 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, parent, account) if err != nil { return err } if role == "viewer" { reject(w, 403, "forbidden") return nil } action := "move_members/" + frame hash := payloadDigest(input) done, err := transactionRetry(w, r, tx, project, account, parent, action, key, hash) if err != nil { return err } if done { return nil } var associated string err = tx.QueryRowContext(r.Context(), `SELECT child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND frame_id=$4 AND archived_at IS NULL`, project, workspace, parent, frame).Scan(&associated) if err != nil { return err } if input.Source != associated && input.Target != associated { reject(w, 409, "ownership_conflict") return nil } participants := map[string]bool{parent: true, input.Source: true, input.Target: true} for _, board := range []string{input.Source, input.Target} { if board == parent { continue } relation, err := readSubgraphRelation(r, tx, project, workspace, board) if err != nil { return err } if relation.Parent != parent || relation.Archived { reject(w, 409, "ownership_conflict") return nil } } sourceSnapshot, err := loadCanvas(r, tx, project, workspace, input.Source) if err != nil { return err } sourceNodes := canvasNodeData(sourceSnapshot.Document) changes := []subgraphRelationChange{} for _, node := range input.Nodes { data := sourceNodes[node] if data == nil { reject(w, 409, "ownership_conflict") return nil } sub, _ := data["subgraph"].(map[string]any) child, _ := sub["boardId"].(string) if child == "" { continue } relation, err := readSubgraphRelation(r, tx, project, workspace, child) if err != nil { return err } if relation.Parent != input.Source || relation.Frame != node || relation.Archived { reject(w, 409, "ownership_conflict") return nil } if child == input.Target { reject(w, 409, "subgraph_cycle") return nil } after := relation after.Parent = input.Target changes = append(changes, subgraphRelationChange{Before: relation, After: after}) rows, err := tx.QueryContext(r.Context(), `WITH RECURSIVE descendants(id,depth) AS(SELECT $3::uuid,0 UNION ALL SELECT s.child_board_id,d.depth+1 FROM descendants d JOIN canvas_subgraphs s ON s.project=$1 AND s.workspace_id=$2 AND s.parent_board_id=d.id AND s.archived_at IS NULL WHERE d.depth<8) SELECT id::text FROM descendants`, project, workspace, child) if err != nil { return err } for rows.Next() { var id string if err = rows.Scan(&id); err != nil { break } if id == input.Target { rows.Close() reject(w, 409, "subgraph_cycle") return nil } participants[id] = true } if err == nil { err = rows.Err() } rows.Close() if err != nil { return err } } parentSnapshot, err := loadCanvas(r, tx, project, workspace, parent) if err != nil { return err } repair, err := planAncestorPortRepair(r, tx, project, workspace, parent, input.Source, account, input.Nodes, parentSnapshot.Document) if err != nil { return err } if repair != nil { participants[repair.Ancestor] = true } if len(input.Expected) != len(participants) { reject(w, 428, "version_required") return nil } ids := []string{} for id := range participants { ids = append(ids, id) } sort.Strings(ids) snapshots := map[string]canvasSnapshot{} before := []canvasSnapshot{} for _, id := range ids { expected, exists := input.Expected[id] if !exists { reject(w, 428, "version_required") return nil } _, 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 } if snapshot.Version != expected { reject(w, 412, "version_conflict") return nil } snapshots[id] = snapshot before = append(before, snapshot) } raw := map[string]json.RawMessage{parent: snapshots[parent].Document, input.Source: snapshots[input.Source].Document, input.Target: snapshots[input.Target].Document} if input.Positions != nil { positioned, err := positionMemberDrop(raw[input.Source], input.Positions) if err != nil { reject(w, 409, "ownership_conflict") return nil } raw[input.Source] = positioned } moved, err := moveMembersDocuments(parent, input.Source, input.Target, input.Nodes, raw) if err != nil { if err == errGraphOwnership { reject(w, 409, "ownership_conflict") } else { reject(w, 413, "graph_limit_exceeded") } return nil } if repair != nil { moved[repair.Ancestor] = snapshots[repair.Ancestor].Document if err = applyAncestorPortRepair(repair, parent, input.Target, moved); err != nil { return err } } for _, change := range changes { if err = writeSubgraphRelation(r, tx, project, workspace, change.After); err != nil { return err } } updated := []canvasSnapshot{} versions := map[string]int64{} for _, id := range ids { snapshot := snapshots[id] if document, exists := moved[id]; exists { snapshot.Document = document } snapshot.Version++ if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil { return err } updated = append(updated, snapshot) versions[id] = snapshot.Version snapshots[id] = snapshot } var id string var receiptExpiry time.Time err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text,now()+interval '7 days'`).Scan(&id, &receiptExpiry) if err != nil { return err } result := response{"transaction_id": id, "parent": snapshots[parent], "snapshots": updated, "undo_receipt": response{"transaction_id": id, "valid_for_seconds": 604800, "expires_at": receiptExpiry}} encoded, _ := json.Marshal(result) beforeJSON, _ := json.Marshal(before) versionsJSON, _ := json.Marshal(versions) changesJSON, _ := json.Marshal(changes) _, 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,relation_changes) VALUES($1,$2,$3,$4,$5,$6,$7,$8,200,$9,$10,$11,$12)`, project, workspace, parent, id, account, action, key, hash, encoded, beforeJSON, versionsJSON, changesJSON) if err != nil { return err } if err = tx.Commit(); err != nil { return err } setVersion(w, versions[parent]) reply(w, 200, result) return nil }