commit 8d98aaa42f272fe5873aa1059df14056627867bd
parent 8ccf324d76ea9df82b191ba61b76fc3ce016331f
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Sun, 4 Aug 2024 21:47:08 -0700
Add some floorplan data and functions
Diffstat:
5 files changed, 439 insertions(+), 0 deletions(-)
diff --git a/cmd/api/floorplans.go b/cmd/api/floorplans.go
@@ -0,0 +1,191 @@
+package main
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "jacobedwards.org/spaceplanner.app/internal/backend"
+)
+
+type SettableFloorplan struct {
+ Name string `json:"name" binding:"required"`
+ Address string `json:"address"`
+ Synopsis string `json:"synopsis"`
+}
+
+type Point struct {
+ X *int `json:"x" binding:"required"`
+ Y *int `json:"y" binding:"required"`
+}
+
+func (e *Env) CreateFloorplan(c *gin.Context) {
+ var req SettableFloorplan
+
+ user := c.Param("user")
+ if err := c.ShouldBind(&req); err != nil {
+ RespondError(c, 400, "%s", err.Error())
+ return
+ }
+
+ fp, err := e.backend.CreateFloorplan(nil, &backend.Floorplan{User: user, Name: req.Name, Address: req.Address, Synopsis: req.Synopsis})
+ if err != nil {
+ RespondError(c, 400, "%s", err.Error())
+ } else {
+ Respond(c, http.StatusOK, *fp)
+ }
+}
+
+func (e *Env) UpdateFloorplan(c *gin.Context) {
+ user := c.Param("user")
+ name := c.Param("floorplan")
+
+ patches := make([]Patch, 16)
+ if err := c.ShouldBind(&patches); err != nil {
+ RespondError(c, 400, "Unable to read patchset")
+ return
+ }
+
+ tx, err := e.backend.DB.Begin()
+ if err != nil {
+ RespondError(c, 400, "Unable to begin transaction")
+ return
+ }
+
+ fp, err := e.backend.GetFloorplan(tx, user, name)
+ if err != nil {
+ tx.Rollback()
+ RespondError(c, 400, "Unable to get floorplan")
+ return
+ }
+
+ settable := toSettable(fp)
+ if err = applyPatchset(settable, patches); err != nil {
+ tx.Rollback()
+ RespondError(c, 400, "Unable to patch floorplan")
+ return
+ }
+
+ newvals, err := fromSettable(user, settable)
+ if err != nil {
+ tx.Rollback()
+ RespondError(c, 400, "Unable to care anymore")
+ return
+ }
+ fp, err = e.backend.UpdateFloorplan(tx, user, name, newvals)
+ if err != nil {
+ tx.Rollback()
+ RespondError(c, 400, "Unable to push update")
+ return
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ RespondError(c, 400, "Unable to commit update")
+ }
+ Respond(c, 200, fp)
+}
+
+func (e *Env) DeleteFloorplan(c *gin.Context) {
+ user := c.Param("user")
+ floorplan := c.Param("floorplan")
+
+ fp, err := e.backend.DeleteFloorplan(nil, user, floorplan)
+ if err != nil {
+ RespondError(c, 400, "%s", err.Error())
+ } else {
+ Respond(c, 200, fp)
+ }
+}
+
+func (e *Env) GetFloorplans(c *gin.Context) {
+ user := c.Param("user")
+
+ floorplans, err := e.backend.GetFloorplans(nil, user)
+ if err != nil {
+ RespondError(c, 400, "%s", err.Error())
+ } else {
+ Respond(c, 200, floorplans)
+ }
+}
+
+func (e *Env) GetFloorplan(c *gin.Context) {
+ user := c.Param("user")
+ name := c.Param("floorplan")
+
+ fp, err := e.backend.GetFloorplan(nil, user, name)
+ if err != nil {
+ RespondError(c, 400, "%s", err.Error())
+ } else {
+ Respond(c, 200, fp)
+ }
+}
+
+func (e *Env) AddFloorplanPoints(c *gin.Context) {
+ user := c.Param("user")
+ floorplan := c.Param("floorplan")
+
+ var points []Point
+ if err := c.ShouldBind(&points); err != nil {
+ RespondError(c, 400, "%s: Unable to read points", err.Error())
+ return
+ }
+
+ tx, err := e.backend.DB.Begin()
+ if err != nil {
+ RespondError(c, 400, "Unable to begin transaction")
+ return
+ }
+
+ var rpoints []backend.Point
+ for _, point := range points {
+ rp, err := e.backend.AddFloorplanPoint(tx, user, floorplan, backend.Point{ X: *point.X, Y: *point.Y })
+ if err != nil {
+ tx.Rollback()
+ RespondError(c, 400, "%s: Unable to add point", err.Error())
+ return
+ }
+ rpoints = append(rpoints, rp)
+ }
+ if err := tx.Commit(); err != nil {
+ RespondError(c, 400, "Unable to commit")
+ } else {
+ Respond(c, 200, rpoints)
+ }
+}
+
+func (e *Env) GetFloorplanPoints(c *gin.Context) {
+ user := c.Param("user")
+ floorplan := c.Param("floorplan")
+
+ points, err := e.backend.GetFloorplanPoints(nil, user, floorplan)
+ if err != nil {
+ RespondError(c, 400, "Unable to get points")
+ } else {
+ Respond(c, 200, points)
+ }
+}
+
+func fromSettable(user string, data map[string]interface{}) (*backend.Floorplan, error) {
+ for k := range data {
+ switch data[k].(type) {
+ case string:
+ default:
+ return nil, errors.New("Invalid Type")
+ }
+ }
+ return &backend.Floorplan{
+ User: user,
+ Name: data["name"].(string),
+ Address: data["address"].(string),
+ Synopsis: data["synopsis"].(string),
+ }, nil
+}
+
+func toSettable(f *backend.Floorplan) map[string]interface{} {
+ return map[string](interface{}){
+ "name": f.Name,
+ "address": f.Address,
+ "synopsis": f.Synopsis,
+ }
+}
diff --git a/cmd/api/main.go b/cmd/api/main.go
@@ -82,6 +82,18 @@ func setAuthenticatedRoutes(env *Env, r *gin.RouterGroup) {
user.GET("/settings", env.GetUserSettings)
user.GET("/settings/:setting", env.GetUserSetting)
+ fp := r.Group("/floorplans/:user")
+ fp.GET("", env.GetFloorplans)
+ fp.POST("", env.CreateFloorplan)
+
+ fp = fp.Group("/:floorplan")
+ fp.PATCH("", env.UpdateFloorplan)
+ fp.DELETE("", env.DeleteFloorplan)
+ fp.GET("", env.GetFloorplan)
+
+ pt := fp.Group("/points")
+ pt.GET("", env.GetFloorplanPoints)
+ pt.POST("", env.AddFloorplanPoints)
}
func noRoute(c *gin.Context) {
diff --git a/cmd/api/migration/2024-07-31T00:40:35.sql b/cmd/api/migration/2024-07-31T00:40:35.sql
@@ -0,0 +1,25 @@
+CREATE TABLE spaceplanner.floorplans (
+ id bigserial PRIMARY KEY,
+ owner varchar REFERENCES users(name),
+ name varchar,
+ address varchar,
+ synopsis varchar,
+ updated timestamp DEFAULT now(),
+ created timestamp DEFAULT now(),
+ CONSTRAINT id UNIQUE (owner, name)
+);
+
+CREATE TABLE spaceplanner.floorplan_points (
+ floorplan int REFERENCES spaceplanner.floorplans(id),
+ id int,
+ x int,
+ y int,
+ PRIMARY KEY (floorplan, id)
+);
+
+CREATE FUNCTION spaceplanner.floorplan_id (varchar, varchar)
+ RETURNS bigint
+ LANGUAGE sql
+ STABLE
+ RETURNS NULL ON NULL INPUT
+ RETURN (SELECT id FROM floorplans WHERE owner = $1 AND name = $2) ;
diff --git a/cmd/api/patch.go b/cmd/api/patch.go
@@ -0,0 +1,58 @@
+package main
+
+import "errors"
+
+type Patch struct {
+ Op string `json:"op" binding:"required"`
+ Path string `json:"path" binding:"required"`
+ From string `json:"from"`
+ Value interface{} `json:"value" binding:"required"`
+}
+
+func applyPatchset(data map[string]interface{}, patches []Patch) error {
+ for _, patch := range patches {
+ if err := applyPatch(data, patch); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func applyPatch(data map[string]interface{}, p Patch) error {
+ // I'm aware path's are suppost to be able to be of any depth. Later.
+ v, exists := data[p.Path]
+ switch p.Op {
+ case "test":
+ if v != p.Value {
+ return errors.New("test failed")
+ }
+ case "remove":
+ if !exists {
+ return errors.New("Cannot remove non-existent")
+ }
+ data[p.Path] = nil
+ case "add":
+ data[p.Path] = p.Value
+ case "replace":
+ if !exists {
+ return errors.New("Cannot replace non-existent")
+ }
+ data[p.Path] = p.Value
+ case "move":
+ _, fexists := data[p.From]
+ if !fexists {
+ return errors.New("From does not exist")
+ }
+ data[p.Path] = data[p.From]
+ data[p.From] = nil
+ case "copy":
+ fv, fexists := data[p.From]
+ if !fexists {
+ return errors.New("From does not exist")
+ }
+ data[p.Path] = fv
+ default:
+ return errors.New(p.Op + ": Invalid operation")
+ }
+ return nil
+}
diff --git a/internal/backend/floorplan.go b/internal/backend/floorplan.go
@@ -0,0 +1,152 @@
+package backend
+
+import (
+ "database/sql"
+ "time"
+)
+
+type Floorplan struct {
+ id int
+ User string `json:"user"`
+ Name string `json:"name"`
+ Address string `json:"address"`
+ Synopsis string `json:"synopsis"`
+ Updated time.Time `json:"updated"`
+ Created time.Time `json:"created"`
+}
+
+type Point struct {
+ //floorplan_id int
+ Id int `json:"id"`
+ X int `json:"x"`
+ Y int `json:"y"`
+}
+
+type Scanner interface {
+ Scan(dest ...any) error
+}
+
+func (e *Env) CreateFloorplan(tx *sql.Tx, template *Floorplan) (*Floorplan, error) {
+ stmt, err := e.CacheTxStmt(tx, "create_floorplan", `INSERT INTO spaceplanner.floorplans (owner, name, address, synopsis)
+ VALUES ($1, $2, $3, $4) RETURNING *`)
+ if err != nil {
+ return nil, err
+ }
+
+ return scanFloorplan(stmt.QueryRow(template.User, template.Name, template.Address, template.Synopsis))
+}
+
+func (e *Env) UpdateFloorplan(tx *sql.Tx, user string, name string, updated *Floorplan) (*Floorplan, error) {
+ stmt, err := e.CacheTxStmt(tx, "create_floorplan", `UPDATE spaceplanner.floorplans SET (name, address, synopsis) =
+ ($3, $4, $5) WHERE owner = $1 AND name = $2 RETURNING *`)
+ if err != nil {
+ return nil, err
+ }
+
+ return scanFloorplan(stmt.QueryRow(user, name, updated.Name, updated.Address, updated.Synopsis))
+}
+
+func (e *Env) GetFloorplan(tx *sql.Tx, user string, name string) (*Floorplan, error) {
+ stmt, err := e.CacheTxStmt(tx, "get_floorplan", "SELECT * FROM spaceplanner.floorplans WHERE owner = $1 AND name = $2")
+ if err != nil {
+ return nil, err
+ }
+
+ return scanFloorplan(stmt.QueryRow(user, name))
+}
+
+func (e *Env) GetFloorplans(tx *sql.Tx, user string) ([]Floorplan, error) {
+ stmt, err := e.CacheTxStmt(tx, "get_floorplans", "SELECT * FROM spaceplanner.floorplans WHERE owner = $1")
+ if err != nil {
+ return nil, err
+ }
+
+ var floorplans []Floorplan
+ rows, err := stmt.Query(user)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ t, err := scanFloorplan(rows)
+ if err != nil {
+ return nil, err
+ }
+ floorplans = append(floorplans, *t)
+ }
+ return floorplans, nil
+}
+
+func (e *Env) DeleteFloorplan(tx *sql.Tx, user string, name string) (*Floorplan, error) {
+ stmt, err := e.CacheTxStmt(tx, "del_floorplan", "DELETE FROM spaceplanner.floorplans WHERE owner = $1 AND name = $2 RETURNING *")
+ if err != nil {
+ return nil, err
+ }
+
+ return scanFloorplan(stmt.QueryRow(user, name))
+}
+
+func (e *Env) AddFloorplanPoint(tx *sql.Tx, user string, floorplan string, point Point) (Point, error) {
+ stmt, err := e.CacheTxStmt(tx, "add_point", `INSERT INTO floorplan_points VALUES (
+ spaceplanner.floorplan_id($1, $2), (SELECT coalesce(max(id), 0) FROM
+ floorplan_points WHERE floorplan = spaceplanner.floorplan_id($1, $2)) + 1, $3, $4) RETURNING id, x, y`)
+ if err != nil {
+ return Point{}, err
+ }
+
+ row := stmt.QueryRow(user, floorplan, point.X, point.Y)
+ return scanPoint(row)
+
+}
+
+func (e *Env) InsertFloorplanPoint(tx *sql.Tx, user string, floorplan string, point Point) error {
+ stmt, err := e.CacheTxStmt(tx, "ins_point", `WITH t AS (
+ UPDATE floorplan_points
+ WHERE floorplan = spaceplanner.floorplan_id($1, $2) AND id > $3 SET id = id + 1
+ ) INSERT INTO floorplan_points id, x, y VALUES (spaceplanner.floorplan_id($1, $2), $3, $4, $5)`)
+
+ if err != nil {
+ return err
+ }
+ _, err = stmt.Exec(user, floorplan, point.Id, point.X, point.Y)
+ return err
+}
+
+func (e *Env) GetFloorplanPoints(tx *sql.Tx, user string, floorplan string) ([]Point, error) {
+ stmt, err := e.CacheTxStmt(tx, "get_points", "SELECT id, x, y FROM floorplan_points WHERE floorplan = spaceplanner.floorplan_id($1, $2)")
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := stmt.Query(user, floorplan)
+ if err != nil {
+ return nil, err
+ }
+ return collectPoints(rows)
+}
+
+func scanFloorplan(row Scanner) (*Floorplan, error) {
+ var f Floorplan
+ err := row.Scan(&f.id, &f.User, &f.Name, &f.Address, &f.Synopsis, &f.Updated, &f.Created)
+ return &f, err
+}
+
+func collectPoints(rows *sql.Rows) ([]Point, error) {
+ var points []Point
+
+ for rows.Next() {
+ point, err := scanPoint(rows)
+ if err != nil {
+ return nil, err
+ }
+ points = append(points, point)
+ }
+
+ return points, nil
+}
+
+func scanPoint(row Scanner) (Point, error) {
+ var point Point
+
+ err := row.Scan(&point.Id, &point.X, &point.Y)
+ return point, err
+}
+\ No newline at end of file