package platform import ( "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "net/http" "time" ) type canvasSnapshot struct { BoardID string `json:"board_id"` Version int64 `json:"version"` Document json.RawMessage `json:"document"` } func loadCanvas(r *http.Request, tx *sql.Tx, project, workspace, board string) (canvasSnapshot, error) { result := canvasSnapshot{BoardID: board} err := tx.QueryRowContext(r.Context(), `SELECT canvas_version,COALESCE(canvas_document,'{"schema_version":1,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 FOR UPDATE`, project, workspace, board).Scan(&result.Version, &result.Document) return result, err } func (s *service) createSubgraph(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) if !uuidSlugPattern.MatchString(key) { reject(w, 400, "invalid_idempotency_key") return nil } expected, ok := expectedVersion(w, r) if !ok { return nil } var input struct { Frame struct { ID string `json:"id"` Title string `json:"title"` Position map[string]any `json:"position"` Width float64 `json:"width"` Height float64 `json:"height"` LabelMode string `json:"labelMode"` } `json:"frame"` } if !decode(w, r, &input) { return nil } f := input.Frame if !canvasID.MatchString(f.ID) || !canvasString(f.Title, 300) || !canvasPoint(f.Position) || !canvasNumber(f.Width, 32, 100000) || !canvasNumber(f.Height, 32, 100000) || !canvasChoice(f.LabelMode, "hidden", "fixed", "scaled") { reject(w, 400, "invalid_canvas") return nil } payload, _ := json.Marshal(input) digest := sha256.Sum256(payload) hash := hex.EncodeToString(digest[:]) tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() workspace, board := r.PathValue("workspace"), r.PathValue("material") kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) if err != nil { return err } if kind != "board" { reject(w, 400, "invalid_material") return nil } if role == "viewer" { reject(w, 403, "forbidden") return nil } var previousHash string var previous json.RawMessage var status int err = tx.QueryRowContext(r.Context(), `SELECT payload_hash,response_status,response_body FROM canvas_transactions WHERE project=$1 AND account_id=$2 AND board_id=$3 AND action='create_subgraph' AND idempotency_key=$4 AND workspace_id=$5`, project, account, board, key, workspace).Scan(&previousHash, &status, &previous) if err == nil { if previousHash != hash { reject(w, 409, "idempotency_conflict") return nil } var result struct { Parent canvasSnapshot `json:"parent"` } _ = json.Unmarshal(previous, &result) setVersion(w, result.Parent.Version) reply(w, status, previous) return nil } if err != sql.ErrNoRows { return err } parent, err := loadCanvas(r, tx, project, workspace, board) if err != nil { return err } if expected != parent.Version { reject(w, 412, "version_conflict") return nil } var doc map[string]any if err = json.Unmarshal(parent.Document, &doc); err != nil { return err } nodes := doc["nodes"].([]any) for _, v := range nodes { if v.(map[string]any)["id"] == f.ID { reject(w, 409, "ownership_conflict") return nil } } if len(nodes) >= 500 { reject(w, 413, "graph_limit_exceeded") return nil } var childID, slug string err = tx.QueryRowContext(r.Context(), `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board',$3,$4,'{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}') RETURNING id::text,slug`, project, workspace, f.Title, account).Scan(&childID, &slug) if err != nil { return err } if err = validateSubgraphPlacement(r, tx, project, workspace, board, childID); err != nil { return err } _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_subgraphs(project,workspace_id,parent_board_id,frame_id,child_board_id) VALUES($1,$2,$3,$4,$5)`, project, workspace, board, f.ID, childID) if err != nil { return err } node := map[string]any{"id": f.ID, "type": "frame", "position": f.Position, "width": f.Width, "height": f.Height, "data": map[string]any{ "kind": "frame", "title": f.Title, "text": "", "rotation": 0, "labelMode": f.LabelMode, "subgraph": map[string]any{"boardId": childID}, "ports": []any{}, "presentation": map[string]any{"collapsed": false, "contentScale": 1, "expandedSize": map[string]any{"width": f.Width, "height": f.Height}, "collapsedSize": map[string]any{"width": 240, "height": 140}}, }} doc["nodes"] = append(nodes, node) doc["schema_version"] = 2 raw, _ := json.Marshal(doc) if len(raw) > canvasLimit { reject(w, 413, "canvas_too_large") return nil } normalized, ok := normalizeCanvas(raw) if !ok { reject(w, 400, "invalid_canvas") return nil } before := parent parent.Document = normalized parent.Version++ _, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=$5 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, board, parent.Document, parent.Version) if err != nil { return err } child, err := loadCanvas(r, tx, project, workspace, childID) if err != nil { return err } var transaction string var receiptExpiry time.Time err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text,now()+interval '7 days'`).Scan(&transaction, &receiptExpiry) if err != nil { return err } result := response{"transaction_id": transaction, "frame_id": f.ID, "child_board": response{"id": childID, "slug": slug, "workspace_id": workspace}, "parent": parent, "child": child, "undo_receipt": response{"transaction_id": transaction, "valid_for_seconds": 604800, "expires_at": receiptExpiry}} encoded, _ := json.Marshal(result) savedBefore, _ := json.Marshal([]canvasSnapshot{before}) versions, _ := json.Marshal(map[string]int64{board: parent.Version, childID: child.Version}) _, 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,'create_subgraph',$6,$7,201,$8,$9,$10)`, project, workspace, board, transaction, account, key, hash, encoded, savedBefore, versions) if err != nil { return err } if err = tx.Commit(); err != nil { return err } setVersion(w, parent.Version) reply(w, 201, result) return nil }