Files
foundry-core/internal/platform/layouts.go
T
codex-bot bfcea1b3af
Foundry Go checks / check (push) Successful in 1m51s
Foundry Go checks / check (pull_request) Successful in 1m56s
Add structured materials, canvas layouts, tags and content permissions
2026-11-05 18:36:53 +08:00

159 lines
5.2 KiB
Go

package platform
import (
"database/sql"
"encoding/json"
"math"
"net/http"
)
type placement struct {
MaterialID string `json:"material_id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ZIndex int `json:"z_index"`
}
func (p placement) valid() bool {
return !math.IsNaN(p.X) && !math.IsNaN(p.Y) && !math.IsNaN(p.Width) && !math.IsNaN(p.Height) &&
p.X >= -1000000 && p.X <= 1000000 && p.Y >= -1000000 && p.Y <= 1000000 &&
p.Width >= 1 && p.Width <= 100000 && p.Height >= 1 && p.Height <= 100000 &&
p.ZIndex >= -1000000 && p.ZIndex <= 1000000
}
func (s *service) layouts(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPatch && r.Method != http.MethodDelete {
reject(w, 405, "method_not_allowed")
return nil
}
workspace, board, layout := r.PathValue("workspace"), r.PathValue("material"), r.PathValue("layout")
if (layout == "" && (r.Method == http.MethodPatch || r.Method == http.MethodDelete)) || (layout != "" && (r.Method == http.MethodGet || r.Method == http.MethodPost)) {
reject(w, 405, "method_not_allowed")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
if err != nil {
return err
}
if kind != "board" {
reject(w, 400, "not_a_board")
return nil
}
var version int64
if err = tx.QueryRowContext(r.Context(), "SELECT version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board).Scan(&version); err != nil {
return err
}
if r.Method == http.MethodGet {
rows, err := tx.QueryContext(r.Context(), `
SELECT jsonb_build_object('id',l.id,'material_id',l.material_id,'x',l.x,'y',l.y,'width',l.width,'height',l.height,'z_index',l.z_index,'version',l.version)
FROM board_layouts l WHERE l.project=$1 AND l.workspace_id=$2 AND l.board_id=$3
AND (EXISTS(SELECT 1 FROM workspace_members m WHERE m.project=l.project AND m.workspace_id=l.workspace_id AND m.account_id=$4)
OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(l.project,l.workspace_id,l.material_id) AND g.account_id=$4))
ORDER BY l.z_index,l.id LIMIT 2000
`, project, workspace, board, account)
if err != nil {
return err
}
defer rows.Close()
items := []json.RawMessage{}
for rows.Next() {
var item json.RawMessage
if err := rows.Scan(&item); err != nil {
return err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return err
}
setVersion(w, version)
reply(w, 200, response{"items": items, "board_version": version})
return nil
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
expected, ok := expectedVersion(w, r)
if !ok {
return nil
}
if expected != version {
reject(w, 412, "version_conflict")
return nil
}
var input placement
if r.Method != http.MethodDelete {
if !decode(w, r, &input) {
return nil
}
if !input.valid() || input.MaterialID == board {
reject(w, 400, "invalid_layout")
return nil
}
refKind, _, err := lockMaterial(r.Context(), tx, project, workspace, input.MaterialID, account)
if err != nil {
return err
}
if refKind == "board" {
reject(w, 400, "nested_board_not_supported")
return nil
}
}
var result sql.Result
switch r.Method {
case http.MethodPost:
var count int
if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3", project, workspace, board).Scan(&count); err != nil {
return err
}
if count >= 2000 {
reject(w, 409, "board_full")
return nil
}
err = tx.QueryRowContext(r.Context(), `
INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height,z_index)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text
`, project, workspace, board, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex).Scan(&layout)
case http.MethodPatch:
result, err = tx.ExecContext(r.Context(), `
UPDATE board_layouts SET material_id=$5,x=$6,y=$7,width=$8,height=$9,z_index=$10,version=version+1
WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4
`, project, workspace, board, layout, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex)
case http.MethodDelete:
result, err = tx.ExecContext(r.Context(), "DELETE FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4", project, workspace, board, layout)
}
if err != nil {
return err
}
if result != nil {
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
}
_, err = tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, version+1)
if r.Method == http.MethodPost {
reply(w, 201, response{"id": layout, "board_version": version + 1})
} else {
reply(w, 204, nil)
}
return nil
}